pi-lens 3.6.6 → 3.7.0
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 +110 -0
- package/README.md +39 -2
- package/clients/ast-grep-client.ts +31 -9
- package/clients/dispatch/dispatcher.ts +165 -116
- package/clients/dispatch/integration.ts +4 -11
- package/clients/dispatch/runners/lsp.ts +5 -1
- package/clients/dispatch/runners/pyright.ts +5 -1
- package/clients/dispatch/runners/similarity.ts +100 -1
- package/clients/dispatch/runners/type-safety.ts +5 -1
- package/clients/formatters.ts +223 -22
- package/clients/installer/index.ts +37 -16
- package/clients/latency-logger.ts +9 -0
- package/clients/lsp/client.ts +249 -110
- package/clients/lsp/interactive-install.ts +177 -22
- package/clients/lsp/server.ts +142 -50
- package/clients/native-rust-client.ts +531 -0
- package/clients/pipeline.ts +16 -11
- package/clients/test-runner-client.ts +86 -1
- package/commands/booboo.ts +2 -2
- package/index.ts +32 -0
- package/package.json +14 -2
- package/rust/Cargo.toml +34 -0
- package/rust/src/cache.rs +127 -0
- package/rust/src/index.rs +407 -0
- package/rust/src/lib.rs +209 -0
- package/rust/src/main.rs +24 -0
- package/rust/src/scan.rs +116 -0
- package/rust/src/similarity.rs +387 -0
- package/skills/ast-grep/SKILL.md +65 -4
|
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import * as ts from "typescript";
|
|
11
11
|
import { EXCLUDED_DIRS } from "../../file-utils.js";
|
|
12
|
+
import { NativeRustCoreClient } from "../../native-rust-client.js";
|
|
12
13
|
import {
|
|
13
14
|
buildProjectIndex,
|
|
14
15
|
findSimilarFunctions,
|
|
@@ -23,12 +24,18 @@ import type {
|
|
|
23
24
|
RunnerResult,
|
|
24
25
|
} from "../types.js";
|
|
25
26
|
|
|
27
|
+
// Singleton Rust client — initialised once, reused across runner invocations.
|
|
28
|
+
const rustClient = new NativeRustCoreClient();
|
|
29
|
+
|
|
30
|
+
/** Feature flag: set to false to force the pure-TypeScript path. */
|
|
31
|
+
const USE_RUST = true;
|
|
32
|
+
|
|
26
33
|
// ============================================================================
|
|
27
34
|
// Configuration
|
|
28
35
|
// ============================================================================
|
|
29
36
|
|
|
30
37
|
const CONFIG = {
|
|
31
|
-
SIMILARITY_THRESHOLD: 0.
|
|
38
|
+
SIMILARITY_THRESHOLD: 0.9, // 90% minimum similarity — below this false positives dominate
|
|
32
39
|
MIN_TRANSITIONS: 20, // Skip functions with <20 AST transitions
|
|
33
40
|
MAX_SUGGESTIONS: 3, // Max 3 suggestions per file
|
|
34
41
|
USAGE_THRESHOLD: 2, // Only suggest utilities with 2+ uses (placeholder)
|
|
@@ -64,6 +71,24 @@ const similarityRunner: RunnerDefinition = {
|
|
|
64
71
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
// ── Rust fast-path ─────────────────────────────────────────────────────
|
|
75
|
+
// Try Rust for file scanning + similarity detection. If the Rust binary
|
|
76
|
+
// is available, use it. On any failure, fall through to the pure-TS path.
|
|
77
|
+
if (USE_RUST && rustClient.isAvailable()) {
|
|
78
|
+
try {
|
|
79
|
+
const rustResult = await runWithRust(
|
|
80
|
+
filePath,
|
|
81
|
+
projectRoot,
|
|
82
|
+
CONFIG.SIMILARITY_THRESHOLD,
|
|
83
|
+
CONFIG.MAX_SUGGESTIONS,
|
|
84
|
+
);
|
|
85
|
+
if (rustResult !== null) return rustResult;
|
|
86
|
+
} catch {
|
|
87
|
+
// Fall through to TypeScript implementation.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ── TypeScript fallback ─────────────────────────────────────────────────
|
|
91
|
+
|
|
67
92
|
const index = await loadOrBuildIndex(projectRoot);
|
|
68
93
|
if (!index || index.entries.size === 0) {
|
|
69
94
|
return { status: "skipped", diagnostics: [], semantic: "none" };
|
|
@@ -261,6 +286,80 @@ function buildSuggestionMessage(
|
|
|
261
286
|
return `Function '${func.name}' has ${similarityPct}% similarity to existing utility '${name}()' in ${location}. Consider reusing the existing utility.`;
|
|
262
287
|
}
|
|
263
288
|
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// Rust fast-path
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Run similarity detection via the Rust binary.
|
|
295
|
+
*
|
|
296
|
+
* Flow:
|
|
297
|
+
* 1. Scan project files with Rust (respects .gitignore, much faster than glob).
|
|
298
|
+
* 2. Build the Rust index (persisted to .pi-lens/rust-index.json).
|
|
299
|
+
* 3. Query similarity for the current file.
|
|
300
|
+
* 4. Convert matches to Diagnostics.
|
|
301
|
+
*
|
|
302
|
+
* Returns `null` if the Rust path cannot produce results (no matches is still
|
|
303
|
+
* a valid result — returned as an empty-diagnostic RunnerResult).
|
|
304
|
+
*/
|
|
305
|
+
async function runWithRust(
|
|
306
|
+
filePath: string,
|
|
307
|
+
projectRoot: string,
|
|
308
|
+
threshold: number,
|
|
309
|
+
maxSuggestions: number,
|
|
310
|
+
): Promise<RunnerResult | null> {
|
|
311
|
+
// 1. Scan project files.
|
|
312
|
+
const scanned = await rustClient.scanProject(projectRoot, [".ts", ".tsx"]);
|
|
313
|
+
if (scanned.length === 0) return null;
|
|
314
|
+
|
|
315
|
+
const relativeFiles = scanned.map((e) =>
|
|
316
|
+
path.relative(projectRoot, e.path).replace(/\\/g, "/"),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// 2. Build index (saves to .pi-lens/rust-index.json).
|
|
320
|
+
await rustClient.buildIndex(projectRoot, relativeFiles);
|
|
321
|
+
|
|
322
|
+
// 3. Find similarities for the current file.
|
|
323
|
+
const matches = await rustClient.findSimilarities(
|
|
324
|
+
projectRoot,
|
|
325
|
+
filePath,
|
|
326
|
+
threshold,
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
if (matches.length === 0) {
|
|
330
|
+
return { status: "succeeded", diagnostics: [], semantic: "none" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 4. Convert to Diagnostics.
|
|
334
|
+
const diagnostics: Diagnostic[] = matches
|
|
335
|
+
.slice(0, maxSuggestions)
|
|
336
|
+
.map((m) => {
|
|
337
|
+
const similarityPct = Math.round(m.similarity * 100);
|
|
338
|
+
// source_id format: "path/to/file.ts::funcName@line"
|
|
339
|
+
const [srcFile, srcFunc] = m.source_id.split("::");
|
|
340
|
+
const [targetFile, targetFunc] = m.target_id.split("::");
|
|
341
|
+
const funcName = srcFunc?.split("@")[0] ?? "?";
|
|
342
|
+
const targetName = targetFunc?.split("@")[0] ?? "?";
|
|
343
|
+
void srcFile; // file is implicit (it's the current file)
|
|
344
|
+
return {
|
|
345
|
+
id: `similarity-rust-${m.source_id}-${m.target_id}`,
|
|
346
|
+
tool: "similarity",
|
|
347
|
+
filePath,
|
|
348
|
+
line: 1, // Rust gives us the function source_id; line resolution is TODO
|
|
349
|
+
column: 1,
|
|
350
|
+
message: `Function '${funcName}' has ${similarityPct}% similarity to '${targetName}()' in ${targetFile}. Consider reusing the existing utility.`,
|
|
351
|
+
severity: "warning" as const,
|
|
352
|
+
semantic: "warning" as const,
|
|
353
|
+
};
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
status: "succeeded",
|
|
358
|
+
diagnostics,
|
|
359
|
+
semantic: diagnostics.length > 0 ? "warning" : "none",
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
264
363
|
// ============================================================================
|
|
265
364
|
// Index Management
|
|
266
365
|
// ============================================================================
|
|
@@ -51,7 +51,11 @@ const typeSafetyRunner: RunnerDefinition = {
|
|
|
51
51
|
return {
|
|
52
52
|
status: hasErrors ? "failed" : "succeeded",
|
|
53
53
|
diagnostics,
|
|
54
|
-
semantic: hasErrors
|
|
54
|
+
semantic: hasErrors
|
|
55
|
+
? "blocking"
|
|
56
|
+
: diagnostics.length > 0
|
|
57
|
+
? "warning"
|
|
58
|
+
: "none",
|
|
55
59
|
};
|
|
56
60
|
},
|
|
57
61
|
};
|
package/clients/formatters.ts
CHANGED
|
@@ -17,10 +17,16 @@ import { safeSpawn } from "./safe-spawn.js";
|
|
|
17
17
|
|
|
18
18
|
export interface FormatterInfo {
|
|
19
19
|
name: string;
|
|
20
|
-
command: string[]; // Command with $FILE placeholder
|
|
20
|
+
command: string[]; // Command with $FILE placeholder — used as fallback
|
|
21
21
|
extensions: string[];
|
|
22
22
|
/** Detect if this formatter should be used for a project */
|
|
23
23
|
detect(cwd: string): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Optionally resolve the full command at runtime (venv, vendor/bin, bundle exec).
|
|
26
|
+
* Return null to fall back to the static `command` field.
|
|
27
|
+
* filePath is already resolved to an absolute path.
|
|
28
|
+
*/
|
|
29
|
+
resolveCommand?(filePath: string, cwd: string): Promise<string[] | null>;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export interface FormatterResult {
|
|
@@ -82,11 +88,107 @@ async function which(command: string): Promise<string | null> {
|
|
|
82
88
|
return result.stdout?.trim().split("\n")[0] ?? null;
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
// --- Venv / Local Binary Helpers ---
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk up from cwd looking for a binary in .venv or venv.
|
|
95
|
+
* Returns the absolute path if found, null otherwise.
|
|
96
|
+
*/
|
|
97
|
+
async function findInVenv(binary: string, cwd: string): Promise<string | null> {
|
|
98
|
+
const isWin = process.platform === "win32";
|
|
99
|
+
const candidates = isWin
|
|
100
|
+
? [
|
|
101
|
+
`.venv/Scripts/${binary}.exe`,
|
|
102
|
+
`venv/Scripts/${binary}.exe`,
|
|
103
|
+
`.venv/Scripts/${binary}`,
|
|
104
|
+
`venv/Scripts/${binary}`,
|
|
105
|
+
]
|
|
106
|
+
: [`.venv/bin/${binary}`, `venv/bin/${binary}`];
|
|
107
|
+
|
|
108
|
+
let dir = cwd;
|
|
109
|
+
const root = path.parse(dir).root;
|
|
110
|
+
while (dir !== root) {
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
const full = path.join(dir, candidate);
|
|
113
|
+
if (await fileExists(full)) return full;
|
|
114
|
+
}
|
|
115
|
+
const parent = path.dirname(dir);
|
|
116
|
+
if (parent === dir) break;
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check vendor/bin for PHP Composer-managed tools.
|
|
124
|
+
* Walks up from cwd to find vendor/bin/<binary>.
|
|
125
|
+
*/
|
|
126
|
+
async function findInVendorBin(
|
|
127
|
+
binary: string,
|
|
128
|
+
cwd: string,
|
|
129
|
+
): Promise<string | null> {
|
|
130
|
+
const isWin = process.platform === "win32";
|
|
131
|
+
const name = isWin ? `${binary}.bat` : binary;
|
|
132
|
+
let dir = cwd;
|
|
133
|
+
const root = path.parse(dir).root;
|
|
134
|
+
while (dir !== root) {
|
|
135
|
+
const full = path.join(dir, "vendor", "bin", name);
|
|
136
|
+
if (await fileExists(full)) return full;
|
|
137
|
+
const parent = path.dirname(dir);
|
|
138
|
+
if (parent === dir) break;
|
|
139
|
+
dir = parent;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check node_modules/.bin for locally installed Node tools.
|
|
146
|
+
* Walks up from cwd to find node_modules/.bin/<binary>.
|
|
147
|
+
*/
|
|
148
|
+
async function findInNodeModules(
|
|
149
|
+
binary: string,
|
|
150
|
+
cwd: string,
|
|
151
|
+
): Promise<string | null> {
|
|
152
|
+
const isWin = process.platform === "win32";
|
|
153
|
+
let dir = cwd;
|
|
154
|
+
const root = path.parse(dir).root;
|
|
155
|
+
while (dir !== root) {
|
|
156
|
+
const candidates = isWin
|
|
157
|
+
? [
|
|
158
|
+
path.join(dir, "node_modules", ".bin", `${binary}.cmd`),
|
|
159
|
+
path.join(dir, "node_modules", ".bin", binary),
|
|
160
|
+
]
|
|
161
|
+
: [path.join(dir, "node_modules", ".bin", binary)];
|
|
162
|
+
for (const full of candidates) {
|
|
163
|
+
if (await fileExists(full)) return full;
|
|
164
|
+
}
|
|
165
|
+
const parent = path.dirname(dir);
|
|
166
|
+
if (parent === dir) break;
|
|
167
|
+
dir = parent;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Returns true if `bundle exec <gem>` should be used:
|
|
174
|
+
* bundle binary is available AND Gemfile.lock exists in the tree.
|
|
175
|
+
*/
|
|
176
|
+
async function canUseBundleExec(cwd: string): Promise<boolean> {
|
|
177
|
+
if ((await which("bundle")) === null) return false;
|
|
178
|
+
const lockfiles = await findUp(["Gemfile.lock"], cwd);
|
|
179
|
+
return lockfiles.length > 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
85
182
|
// --- Formatter Definitions ---
|
|
86
183
|
|
|
87
184
|
export const biomeFormatter: FormatterInfo = {
|
|
88
185
|
name: "biome",
|
|
89
186
|
command: ["npx", "@biomejs/biome", "format", "--write", "$FILE"],
|
|
187
|
+
async resolveCommand(filePath, cwd) {
|
|
188
|
+
const local = await findInNodeModules("biome", cwd);
|
|
189
|
+
if (local) return [local, "format", "--write", filePath];
|
|
190
|
+
return null;
|
|
191
|
+
},
|
|
90
192
|
extensions: [
|
|
91
193
|
".js",
|
|
92
194
|
".jsx",
|
|
@@ -111,10 +213,10 @@ export const biomeFormatter: FormatterInfo = {
|
|
|
111
213
|
const found = await findUp(configs, cwd);
|
|
112
214
|
if (found.length > 0) return true;
|
|
113
215
|
|
|
114
|
-
// Check if biome is in package.json devDependencies
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
117
|
-
const pkg = (await readJson(
|
|
216
|
+
// Check if biome is in the nearest package.json devDependencies
|
|
217
|
+
const pkgPaths = await findUp(["package.json"], cwd);
|
|
218
|
+
if (pkgPaths.length > 0) {
|
|
219
|
+
const pkg = (await readJson(pkgPaths[0])) as {
|
|
118
220
|
devDependencies?: Record<string, string>;
|
|
119
221
|
};
|
|
120
222
|
if (pkg.devDependencies?.["@biomejs/biome"]) return true;
|
|
@@ -127,6 +229,11 @@ export const biomeFormatter: FormatterInfo = {
|
|
|
127
229
|
export const prettierFormatter: FormatterInfo = {
|
|
128
230
|
name: "prettier",
|
|
129
231
|
command: ["npx", "prettier", "--write", "$FILE"],
|
|
232
|
+
async resolveCommand(filePath, cwd) {
|
|
233
|
+
const local = await findInNodeModules("prettier", cwd);
|
|
234
|
+
if (local) return [local, "--write", filePath];
|
|
235
|
+
return null;
|
|
236
|
+
},
|
|
130
237
|
extensions: [
|
|
131
238
|
".js",
|
|
132
239
|
".jsx",
|
|
@@ -168,10 +275,10 @@ export const prettierFormatter: FormatterInfo = {
|
|
|
168
275
|
const found = await findUp(configs, cwd);
|
|
169
276
|
if (found.length > 0) return true;
|
|
170
277
|
|
|
171
|
-
// Check package.json
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
174
|
-
const pkg = (await readJson(
|
|
278
|
+
// Check the nearest package.json for prettier
|
|
279
|
+
const pkgPaths = await findUp(["package.json"], cwd);
|
|
280
|
+
if (pkgPaths.length > 0) {
|
|
281
|
+
const pkg = (await readJson(pkgPaths[0])) as {
|
|
175
282
|
devDependencies?: Record<string, string>;
|
|
176
283
|
dependencies?: Record<string, string>;
|
|
177
284
|
prettier?: unknown;
|
|
@@ -191,6 +298,11 @@ export const ruffFormatter: FormatterInfo = {
|
|
|
191
298
|
name: "ruff",
|
|
192
299
|
command: ["ruff", "format", "$FILE"],
|
|
193
300
|
extensions: [".py", ".pyi"],
|
|
301
|
+
async resolveCommand(filePath, cwd) {
|
|
302
|
+
const venv = await findInVenv("ruff", cwd);
|
|
303
|
+
if (venv) return [venv, "format", filePath];
|
|
304
|
+
return null;
|
|
305
|
+
},
|
|
194
306
|
async detect(cwd: string) {
|
|
195
307
|
// Check for ruff config
|
|
196
308
|
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"];
|
|
@@ -226,6 +338,11 @@ export const blackFormatter: FormatterInfo = {
|
|
|
226
338
|
name: "black",
|
|
227
339
|
command: ["black", "$FILE"],
|
|
228
340
|
extensions: [".py", ".pyi"],
|
|
341
|
+
async resolveCommand(filePath, cwd) {
|
|
342
|
+
const venv = await findInVenv("black", cwd);
|
|
343
|
+
if (venv) return [venv, filePath];
|
|
344
|
+
return null;
|
|
345
|
+
},
|
|
229
346
|
async detect(cwd: string) {
|
|
230
347
|
// Check for black config in pyproject.toml
|
|
231
348
|
const configs = ["pyproject.toml"];
|
|
@@ -351,6 +468,11 @@ export const rubocopFormatter: FormatterInfo = {
|
|
|
351
468
|
name: "rubocop",
|
|
352
469
|
command: ["rubocop", "-a", "--no-color", "$FILE"],
|
|
353
470
|
extensions: [".rb", ".rake", ".gemspec", ".ru"],
|
|
471
|
+
async resolveCommand(filePath, cwd) {
|
|
472
|
+
if (await canUseBundleExec(cwd))
|
|
473
|
+
return ["bundle", "exec", "rubocop", "-a", "--no-color", filePath];
|
|
474
|
+
return null;
|
|
475
|
+
},
|
|
354
476
|
async detect(cwd: string) {
|
|
355
477
|
// Only run if project has explicit RuboCop config
|
|
356
478
|
const configs = [".rubocop.yml", ".rubocop.yaml"];
|
|
@@ -370,6 +492,11 @@ export const standardrbFormatter: FormatterInfo = {
|
|
|
370
492
|
name: "standardrb",
|
|
371
493
|
command: ["standardrb", "--fix", "$FILE"],
|
|
372
494
|
extensions: [".rb", ".rake"],
|
|
495
|
+
async resolveCommand(filePath, cwd) {
|
|
496
|
+
if (await canUseBundleExec(cwd))
|
|
497
|
+
return ["bundle", "exec", "standardrb", "--fix", filePath];
|
|
498
|
+
return null;
|
|
499
|
+
},
|
|
373
500
|
async detect(cwd: string) {
|
|
374
501
|
// standardrb is only used if explicitly in Gemfile (no config file — it is the config)
|
|
375
502
|
const gemfile = path.join(cwd, "Gemfile");
|
|
@@ -403,6 +530,80 @@ export const terraformFormatter: FormatterInfo = {
|
|
|
403
530
|
},
|
|
404
531
|
};
|
|
405
532
|
|
|
533
|
+
export const phpCsFixerFormatter: FormatterInfo = {
|
|
534
|
+
name: "php-cs-fixer",
|
|
535
|
+
command: ["php-cs-fixer", "fix", "$FILE"],
|
|
536
|
+
extensions: [".php"],
|
|
537
|
+
async resolveCommand(filePath, cwd) {
|
|
538
|
+
const vendor = await findInVendorBin("php-cs-fixer", cwd);
|
|
539
|
+
if (vendor) return [vendor, "fix", filePath];
|
|
540
|
+
return null;
|
|
541
|
+
},
|
|
542
|
+
async detect(cwd: string) {
|
|
543
|
+
const vendorBin = await findInVendorBin("php-cs-fixer", cwd);
|
|
544
|
+
const globalBin = await which("php-cs-fixer");
|
|
545
|
+
if (!vendorBin && !globalBin) return false;
|
|
546
|
+
// Only run if project has explicit config
|
|
547
|
+
const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"];
|
|
548
|
+
const found = await findUp(configs, cwd);
|
|
549
|
+
return found.length > 0;
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
export const csharpierFormatter: FormatterInfo = {
|
|
554
|
+
name: "csharpier",
|
|
555
|
+
command: ["dotnet", "csharpier", "$FILE"],
|
|
556
|
+
extensions: [".cs"],
|
|
557
|
+
async detect(_cwd: string) {
|
|
558
|
+
// Check dotnet is available AND csharpier tool is installed
|
|
559
|
+
if ((await which("dotnet")) === null) return false;
|
|
560
|
+
const result = safeSpawn("dotnet", ["csharpier", "--version"], {
|
|
561
|
+
timeout: 5000,
|
|
562
|
+
});
|
|
563
|
+
return !result.error && result.status === 0;
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
export const fantomasFormatter: FormatterInfo = {
|
|
568
|
+
name: "fantomas",
|
|
569
|
+
command: ["fantomas", "$FILE"],
|
|
570
|
+
extensions: [".fs", ".fsi", ".fsx"],
|
|
571
|
+
async detect(_cwd: string) {
|
|
572
|
+
return (await which("fantomas")) !== null;
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
export const swiftformatFormatter: FormatterInfo = {
|
|
577
|
+
name: "swiftformat",
|
|
578
|
+
command: ["swiftformat", "$FILE"],
|
|
579
|
+
extensions: [".swift"],
|
|
580
|
+
async detect(_cwd: string) {
|
|
581
|
+
return (await which("swiftformat")) !== null;
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
export const styluaFormatter: FormatterInfo = {
|
|
586
|
+
name: "stylua",
|
|
587
|
+
command: ["stylua", "$FILE"],
|
|
588
|
+
extensions: [".lua"],
|
|
589
|
+
async detect(cwd: string) {
|
|
590
|
+
if ((await which("stylua")) === null) return false;
|
|
591
|
+
// Prefer explicit config but also run if binary is present in a Lua project
|
|
592
|
+
const configs = ["stylua.toml", ".stylua.toml"];
|
|
593
|
+
const found = await findUp(configs, cwd);
|
|
594
|
+
return found.length > 0;
|
|
595
|
+
},
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
export const ormoluFormatter: FormatterInfo = {
|
|
599
|
+
name: "ormolu",
|
|
600
|
+
command: ["ormolu", "--mode", "inplace", "$FILE"],
|
|
601
|
+
extensions: [".hs", ".lhs"],
|
|
602
|
+
async detect(_cwd: string) {
|
|
603
|
+
return (await which("ormolu")) !== null;
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
|
|
406
607
|
// --- Registry ---
|
|
407
608
|
|
|
408
609
|
const ALL_FORMATTERS: FormatterInfo[] = [
|
|
@@ -421,6 +622,12 @@ const ALL_FORMATTERS: FormatterInfo[] = [
|
|
|
421
622
|
clangFormatFormatter,
|
|
422
623
|
ktlintFormatter,
|
|
423
624
|
terraformFormatter,
|
|
625
|
+
phpCsFixerFormatter,
|
|
626
|
+
csharpierFormatter,
|
|
627
|
+
fantomasFormatter,
|
|
628
|
+
swiftformatFormatter,
|
|
629
|
+
styluaFormatter,
|
|
630
|
+
ormoluFormatter,
|
|
424
631
|
rubocopFormatter,
|
|
425
632
|
standardrbFormatter,
|
|
426
633
|
gleamFormatter,
|
|
@@ -514,19 +721,13 @@ export async function formatFile(
|
|
|
514
721
|
const cwd = path.dirname(absolutePath);
|
|
515
722
|
const contentBefore = await fs.readFile(absolutePath, "utf-8");
|
|
516
723
|
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (await fileExists(localBiome)) {
|
|
525
|
-
cmd = [localBiome, ...cmd.slice(2)]; // Replace "npx" "@biomejs/biome" with local path
|
|
526
|
-
} else if (await fileExists(localBiomeWin)) {
|
|
527
|
-
cmd = [localBiomeWin, ...cmd.slice(2)];
|
|
528
|
-
}
|
|
529
|
-
}
|
|
724
|
+
// Resolve command: prefer local (venv/vendor/node_modules) over global
|
|
725
|
+
const resolved = formatter.resolveCommand
|
|
726
|
+
? await formatter.resolveCommand(absolutePath, cwd)
|
|
727
|
+
: null;
|
|
728
|
+
const cmd =
|
|
729
|
+
resolved ??
|
|
730
|
+
formatter.command.map((c) => c.replace("$FILE", absolutePath));
|
|
530
731
|
|
|
531
732
|
// Run formatter
|
|
532
733
|
const result = safeSpawn(cmd[0], cmd.slice(1), { timeout: 15000, cwd });
|
|
@@ -39,6 +39,19 @@ import path from "node:path";
|
|
|
39
39
|
// Global installation directory for pi-lens tools
|
|
40
40
|
const TOOLS_DIR = path.join(process.cwd(), ".pi-lens", "tools");
|
|
41
41
|
|
|
42
|
+
// Debug flag - set via PI_LENS_DEBUG=1 or --debug
|
|
43
|
+
const DEBUG =
|
|
44
|
+
process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Log debug messages only when DEBUG is enabled
|
|
48
|
+
*/
|
|
49
|
+
function debugLog(...args: unknown[]): void {
|
|
50
|
+
if (DEBUG) {
|
|
51
|
+
console.error("[auto-install:debug]", ...args);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
// --- Tool Definitions ---
|
|
43
56
|
|
|
44
57
|
interface ToolDefinition {
|
|
@@ -75,6 +88,15 @@ const TOOLS: ToolDefinition[] = [
|
|
|
75
88
|
binaryName: "pyright",
|
|
76
89
|
},
|
|
77
90
|
// Linting/formatting tools
|
|
91
|
+
{
|
|
92
|
+
id: "prettier",
|
|
93
|
+
name: "Prettier",
|
|
94
|
+
checkCommand: "prettier",
|
|
95
|
+
checkArgs: ["--version"],
|
|
96
|
+
installStrategy: "npm",
|
|
97
|
+
packageName: "prettier",
|
|
98
|
+
binaryName: "prettier",
|
|
99
|
+
},
|
|
78
100
|
{
|
|
79
101
|
id: "ruff",
|
|
80
102
|
name: "Ruff",
|
|
@@ -237,22 +259,18 @@ async function verifyToolBinary(binPath: string): Promise<boolean> {
|
|
|
237
259
|
|
|
238
260
|
proc.on("exit", (code) => {
|
|
239
261
|
if (code === 0) {
|
|
240
|
-
|
|
241
|
-
`[auto-install] Verified: ${binPath} (version: ${stdout.trim()})`,
|
|
242
|
-
);
|
|
262
|
+
debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
|
|
243
263
|
resolve(true);
|
|
244
264
|
} else {
|
|
245
|
-
console.error(
|
|
246
|
-
|
|
247
|
-
);
|
|
265
|
+
console.error(`[auto-install] Verification failed for ${binPath}`);
|
|
266
|
+
debugLog("Exit code:", code, "stderr:", stderr);
|
|
248
267
|
resolve(false);
|
|
249
268
|
}
|
|
250
269
|
});
|
|
251
270
|
|
|
252
271
|
proc.on("error", (err) => {
|
|
253
|
-
console.error(
|
|
254
|
-
|
|
255
|
-
);
|
|
272
|
+
console.error(`[auto-install] Verification failed for ${binPath}`);
|
|
273
|
+
debugLog("Error:", err.message);
|
|
256
274
|
resolve(false);
|
|
257
275
|
});
|
|
258
276
|
});
|
|
@@ -322,11 +340,11 @@ async function installNpmTool(
|
|
|
322
340
|
}
|
|
323
341
|
|
|
324
342
|
// NEW: Verify the binary actually works before returning
|
|
325
|
-
|
|
343
|
+
debugLog(`Verifying ${binaryName}...`);
|
|
326
344
|
const isValid = await verifyToolBinary(binPath);
|
|
327
345
|
if (!isValid) {
|
|
328
346
|
console.error(
|
|
329
|
-
`[auto-install] ${packageName} installed but verification failed
|
|
347
|
+
`[auto-install] ${packageName} installed but verification failed (binary may be corrupted)`,
|
|
330
348
|
);
|
|
331
349
|
// Clean up the broken installation
|
|
332
350
|
try {
|
|
@@ -358,9 +376,9 @@ async function installNpmTool(
|
|
|
358
376
|
});
|
|
359
377
|
} catch (err) {
|
|
360
378
|
console.error(
|
|
361
|
-
`[auto-install] Failed to install
|
|
362
|
-
err,
|
|
379
|
+
`[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
|
|
363
380
|
);
|
|
381
|
+
debugLog("Full error:", err);
|
|
364
382
|
return undefined;
|
|
365
383
|
}
|
|
366
384
|
}
|
|
@@ -394,9 +412,9 @@ async function installPipTool(
|
|
|
394
412
|
});
|
|
395
413
|
} catch (err) {
|
|
396
414
|
console.error(
|
|
397
|
-
`[auto-install] Failed to install
|
|
398
|
-
err,
|
|
415
|
+
`[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
|
|
399
416
|
);
|
|
417
|
+
debugLog("Full error:", err);
|
|
400
418
|
return undefined;
|
|
401
419
|
}
|
|
402
420
|
}
|
|
@@ -434,7 +452,10 @@ export async function installTool(toolId: string): Promise<boolean> {
|
|
|
434
452
|
return false;
|
|
435
453
|
}
|
|
436
454
|
} catch (err) {
|
|
437
|
-
console.error(
|
|
455
|
+
console.error(
|
|
456
|
+
`[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`,
|
|
457
|
+
);
|
|
458
|
+
debugLog("Full error:", err);
|
|
438
459
|
return false;
|
|
439
460
|
}
|
|
440
461
|
}
|
|
@@ -13,7 +13,10 @@ try {
|
|
|
13
13
|
|
|
14
14
|
export interface LatencyEntry {
|
|
15
15
|
type: "runner" | "tool_result" | "phase";
|
|
16
|
+
/** ISO timestamp when this entry was written (= finish time for runners) */
|
|
16
17
|
ts?: string;
|
|
18
|
+
/** ISO timestamp when the runner/phase started — diff with ts = durationMs */
|
|
19
|
+
startedAt?: string;
|
|
17
20
|
toolName?: string;
|
|
18
21
|
filePath: string;
|
|
19
22
|
fullPath?: string;
|
|
@@ -25,6 +28,12 @@ export interface LatencyEntry {
|
|
|
25
28
|
status?: string;
|
|
26
29
|
diagnosticCount?: number;
|
|
27
30
|
semantic?: string;
|
|
31
|
+
/** For dispatch_complete: actual wall-clock time (groups run in parallel) */
|
|
32
|
+
wallClockMs?: number;
|
|
33
|
+
/** For dispatch_complete: sum of all individual runner durationMs */
|
|
34
|
+
sumMs?: number;
|
|
35
|
+
/** wallClockMs - sumMs ≥ 0 means parallelism saved this many ms */
|
|
36
|
+
parallelGainMs?: number;
|
|
28
37
|
metadata?: Record<string, unknown>;
|
|
29
38
|
}
|
|
30
39
|
|