pi-lens 3.8.63 → 3.8.65
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 +32 -0
- package/README.md +12 -9
- package/dist/clients/deadline-utils.js +23 -0
- package/dist/clients/installer/index.js +18 -27
- package/dist/clients/log-cleanup.js +36 -13
- package/dist/clients/lsp/client.js +44 -0
- package/dist/clients/lsp/index.js +208 -33
- package/dist/clients/lsp/interactive-install.js +14 -4
- package/dist/clients/lsp/launch.js +29 -100
- package/dist/clients/mcp/review.js +7 -4
- package/dist/clients/package-manager.js +223 -0
- package/dist/clients/pipeline.js +55 -2
- package/dist/clients/project-metadata.js +3 -23
- package/dist/clients/safe-spawn.js +9 -0
- package/dist/clients/tree-sitter-client.js +72 -14
- package/dist/index.js +16 -0
- package/dist/mcp/server.js +4 -2
- package/dist/tools/ast-grep-search.js +10 -6
- package/dist/tools/lens-diagnostics.js +49 -10
- package/dist/tools/lsp-diagnostics.js +27 -4
- package/dist/tools/scan-progress.js +53 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-bash.wasm.json +5 -0
- package/grammars/tree-sitter-css.wasm +0 -0
- package/grammars/tree-sitter-css.wasm.json +5 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-go.wasm.json +5 -0
- package/grammars/tree-sitter-html.wasm +0 -0
- package/grammars/tree-sitter-html.wasm.json +5 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-java.wasm.json +5 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm.json +5 -0
- package/grammars/tree-sitter-json.wasm +0 -0
- package/grammars/tree-sitter-json.wasm.json +5 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-python.wasm.json +5 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm.json +5 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm.json +5 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm.json +5 -0
- package/grammars/tree-sitter-yaml.wasm +0 -0
- package/grammars/tree-sitter-yaml.wasm.json +5 -0
- package/package.json +9 -7
- package/scripts/analyze-pi-lens-logs.mjs +101 -3
- package/scripts/download-grammars.js +171 -30
- package/scripts/grammars.lock.json +32 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js package-manager resolution and command building.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for "which package manager should we use here, and how
|
|
5
|
+
* do we spell each command (run script / install / exec / global bin) for it".
|
|
6
|
+
* Supports npm, pnpm, yarn and bun so pi-lens works on whatever manager the
|
|
7
|
+
* machine actually ships.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order (see `resolveNodePackageManager`):
|
|
10
|
+
* 1. What the project declares — lockfile, then the corepack `packageManager`
|
|
11
|
+
* field — *if that manager is actually installed*.
|
|
12
|
+
* 2. Otherwise the first installed manager in `PREFERENCE` (npm first for
|
|
13
|
+
* maximum compatibility, bun last).
|
|
14
|
+
* 3. `npm` as a final fallback so callers always get a usable value.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { isCommandAvailableAsync, safeSpawnAsync } from "./safe-spawn.js";
|
|
20
|
+
/**
|
|
21
|
+
* Fallback preference when nothing is declared (or the declared manager is
|
|
22
|
+
* missing). npm first for maximum compatibility; bun last. A project lockfile
|
|
23
|
+
* always overrides this order.
|
|
24
|
+
*/
|
|
25
|
+
const PREFERENCE = ["npm", "pnpm", "yarn", "bun"];
|
|
26
|
+
function onWindows() {
|
|
27
|
+
return process.platform === "win32";
|
|
28
|
+
}
|
|
29
|
+
function isNodePackageManager(value) {
|
|
30
|
+
return (value === "npm" || value === "pnpm" || value === "yarn" || value === "bun");
|
|
31
|
+
}
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// DETECTION
|
|
34
|
+
// ============================================================================
|
|
35
|
+
/**
|
|
36
|
+
* Detect the package manager a Node.js project declares, without checking
|
|
37
|
+
* whether it is installed. Lockfiles win over the corepack `packageManager`
|
|
38
|
+
* field. Returns `undefined` when the project makes no declaration.
|
|
39
|
+
*/
|
|
40
|
+
export function detectNodePackageManager(targetPath) {
|
|
41
|
+
if (fs.existsSync(path.join(targetPath, "bun.lockb")) ||
|
|
42
|
+
fs.existsSync(path.join(targetPath, "bun.lock"))) {
|
|
43
|
+
return "bun";
|
|
44
|
+
}
|
|
45
|
+
if (fs.existsSync(path.join(targetPath, "pnpm-lock.yaml"))) {
|
|
46
|
+
return "pnpm";
|
|
47
|
+
}
|
|
48
|
+
if (fs.existsSync(path.join(targetPath, "yarn.lock"))) {
|
|
49
|
+
return "yarn";
|
|
50
|
+
}
|
|
51
|
+
if (fs.existsSync(path.join(targetPath, "package-lock.json"))) {
|
|
52
|
+
return "npm";
|
|
53
|
+
}
|
|
54
|
+
return readPackageManagerField(targetPath);
|
|
55
|
+
}
|
|
56
|
+
/** Read the corepack `"packageManager": "pnpm@8.15.0"` field from package.json. */
|
|
57
|
+
function readPackageManagerField(targetPath) {
|
|
58
|
+
try {
|
|
59
|
+
const pkgPath = path.join(targetPath, "package.json");
|
|
60
|
+
if (!fs.existsSync(pkgPath))
|
|
61
|
+
return undefined;
|
|
62
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
63
|
+
if (typeof pkg.packageManager !== "string")
|
|
64
|
+
return undefined;
|
|
65
|
+
const name = pkg.packageManager.split("@")[0].trim().toLowerCase();
|
|
66
|
+
return isNodePackageManager(name) ? name : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// AVAILABILITY (cached per process; reset in tests)
|
|
74
|
+
// ============================================================================
|
|
75
|
+
const availabilityCache = new Map();
|
|
76
|
+
function isAvailable(pm) {
|
|
77
|
+
let cached = availabilityCache.get(pm);
|
|
78
|
+
if (!cached) {
|
|
79
|
+
cached = isCommandAvailableAsync(pm);
|
|
80
|
+
availabilityCache.set(pm, cached);
|
|
81
|
+
}
|
|
82
|
+
return cached;
|
|
83
|
+
}
|
|
84
|
+
/** Clear the process-wide availability cache. Intended for tests. */
|
|
85
|
+
export function _resetPackageManagerCache() {
|
|
86
|
+
availabilityCache.clear();
|
|
87
|
+
}
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// RESOLUTION
|
|
90
|
+
// ============================================================================
|
|
91
|
+
/**
|
|
92
|
+
* Resolve which package manager to use for `cwd`: the project's declared manager
|
|
93
|
+
* if installed, otherwise the first installed manager in `PREFERENCE`, otherwise
|
|
94
|
+
* `npm`.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveNodePackageManager(cwd = process.cwd()) {
|
|
97
|
+
const declared = detectNodePackageManager(cwd);
|
|
98
|
+
if (declared && (await isAvailable(declared))) {
|
|
99
|
+
return declared;
|
|
100
|
+
}
|
|
101
|
+
for (const pm of PREFERENCE) {
|
|
102
|
+
if (await isAvailable(pm))
|
|
103
|
+
return pm;
|
|
104
|
+
}
|
|
105
|
+
return "npm";
|
|
106
|
+
}
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// COMMAND BUILDERS
|
|
109
|
+
// ============================================================================
|
|
110
|
+
/** Platform-specific executable name (`.cmd`/`.exe` on Windows). */
|
|
111
|
+
export function pmBinary(pm) {
|
|
112
|
+
if (!onWindows())
|
|
113
|
+
return pm;
|
|
114
|
+
return pm === "bun" ? "bun.exe" : `${pm}.cmd`;
|
|
115
|
+
}
|
|
116
|
+
/** Args to run a package.json script — `run <script>` works for all managers. */
|
|
117
|
+
export function runScriptArgs(script) {
|
|
118
|
+
return ["run", script];
|
|
119
|
+
}
|
|
120
|
+
/** Human-readable "run script" command for display (bare manager name). */
|
|
121
|
+
export function formatRunScript(pm, script) {
|
|
122
|
+
return `${pm} run ${script}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Args to install a single package. npm uses `install`; pnpm/yarn/bun use `add`.
|
|
126
|
+
* `--legacy-peer-deps` is npm-only and silently dropped for other managers.
|
|
127
|
+
*/
|
|
128
|
+
export function installArgs(pm, pkg, options = {}) {
|
|
129
|
+
const args = [pm === "npm" ? "install" : "add"];
|
|
130
|
+
if (options.ignoreScripts)
|
|
131
|
+
args.push("--ignore-scripts");
|
|
132
|
+
if (options.legacyPeerDeps && pm === "npm")
|
|
133
|
+
args.push("--legacy-peer-deps");
|
|
134
|
+
args.push(pkg);
|
|
135
|
+
return args;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Args to install a single package **globally** (`-g`). npm/pnpm/bun spell this
|
|
139
|
+
* `install -g` / `add -g`; yarn uses `global add` (yarn classic — Berry removed
|
|
140
|
+
* global installs, but pi-lens's manager resolution prefers npm/pnpm first, so
|
|
141
|
+
* yarn is only chosen when it is the declared/only manager). The resulting
|
|
142
|
+
* binary is found again by `allAvailableGlobalBinDirs`, which covers every
|
|
143
|
+
* manager's global bin dir.
|
|
144
|
+
*/
|
|
145
|
+
export function globalInstallArgs(pm, pkg) {
|
|
146
|
+
switch (pm) {
|
|
147
|
+
case "yarn":
|
|
148
|
+
return ["global", "add", pkg];
|
|
149
|
+
case "npm":
|
|
150
|
+
return ["install", "-g", pkg];
|
|
151
|
+
default: // pnpm, bun
|
|
152
|
+
return ["add", "-g", pkg];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Command + args to run a package's binary without a global install — the
|
|
157
|
+
* `npx --no <pkg>` equivalent for each manager (`bun x`, `pnpm dlx`, `yarn dlx`).
|
|
158
|
+
*/
|
|
159
|
+
export function execArgs(pm, pkg, args = []) {
|
|
160
|
+
switch (pm) {
|
|
161
|
+
case "bun":
|
|
162
|
+
return { command: pmBinary("bun"), args: ["x", pkg, ...args] };
|
|
163
|
+
case "pnpm":
|
|
164
|
+
return { command: pmBinary("pnpm"), args: ["dlx", pkg, ...args] };
|
|
165
|
+
case "yarn":
|
|
166
|
+
return { command: pmBinary("yarn"), args: ["dlx", pkg, ...args] };
|
|
167
|
+
default:
|
|
168
|
+
// --no prevents silently downloading an uncached package.
|
|
169
|
+
return {
|
|
170
|
+
command: onWindows() ? "npx.cmd" : "npx",
|
|
171
|
+
args: ["--no", pkg, ...args],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// GLOBAL BIN DISCOVERY
|
|
177
|
+
// ============================================================================
|
|
178
|
+
/** Directories where a given manager installs global binaries. */
|
|
179
|
+
async function globalBinDirsFor(pm) {
|
|
180
|
+
if (pm === "bun") {
|
|
181
|
+
// bun has no per-call query cost — the global bin dir is deterministic.
|
|
182
|
+
const base = process.env.BUN_INSTALL || path.join(os.homedir(), ".bun");
|
|
183
|
+
return [path.join(base, "bin")];
|
|
184
|
+
}
|
|
185
|
+
const query = pm === "npm"
|
|
186
|
+
? ["config", "get", "prefix"]
|
|
187
|
+
: pm === "pnpm"
|
|
188
|
+
? ["bin", "-g"]
|
|
189
|
+
: ["global", "bin"]; // yarn
|
|
190
|
+
const res = await safeSpawnAsync(pmBinary(pm), query, { timeout: 5000 });
|
|
191
|
+
if (res.status !== 0 || res.error)
|
|
192
|
+
return [];
|
|
193
|
+
const out = res.stdout.trim();
|
|
194
|
+
if (!out)
|
|
195
|
+
return [];
|
|
196
|
+
// npm reports a prefix; binaries live in `<prefix>/bin` on Unix, `<prefix>`
|
|
197
|
+
// on Windows. pnpm/yarn already print the bin dir directly.
|
|
198
|
+
if (pm === "npm") {
|
|
199
|
+
return [onWindows() ? out : path.join(out, "bin")];
|
|
200
|
+
}
|
|
201
|
+
return [out];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Global bin directories for every installed manager, deduped. Used to locate a
|
|
205
|
+
* globally-installed tool binary when PATH is stale (e.g. right after an
|
|
206
|
+
* `install -g`) or when the tool was installed via a non-npm manager.
|
|
207
|
+
*/
|
|
208
|
+
export async function allAvailableGlobalBinDirs() {
|
|
209
|
+
const dirs = [];
|
|
210
|
+
const seen = new Set();
|
|
211
|
+
for (const pm of PREFERENCE) {
|
|
212
|
+
if (!(await isAvailable(pm)))
|
|
213
|
+
continue;
|
|
214
|
+
for (const dir of await globalBinDirsFor(pm)) {
|
|
215
|
+
const normalized = path.resolve(dir);
|
|
216
|
+
if (seen.has(normalized))
|
|
217
|
+
continue;
|
|
218
|
+
seen.add(normalized);
|
|
219
|
+
dirs.push(normalized);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return dirs;
|
|
223
|
+
}
|
package/dist/clients/pipeline.js
CHANGED
|
@@ -30,12 +30,25 @@ import { emitLensAnalysisComplete } from "./lens-events.js";
|
|
|
30
30
|
import { getLSPService } from "./lsp/index.js";
|
|
31
31
|
import { clearGraphCache } from "./review-graph/builder.js";
|
|
32
32
|
import { RUNTIME_CONFIG } from "./runtime-config.js";
|
|
33
|
-
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
33
|
+
import { getAmbientAbortSignal, safeSpawnAsync } from "./safe-spawn.js";
|
|
34
|
+
import { combineAbortSignals } from "./deadline-utils.js";
|
|
34
35
|
import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
|
|
35
36
|
const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
|
|
36
37
|
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
|
|
37
38
|
const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
|
|
38
39
|
const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
|
|
40
|
+
/**
|
|
41
|
+
* Hard ceiling for the pre-dispatch LSP sync (`resyncLspFile`). The sync sends a
|
|
42
|
+
* didChange/didOpen; that write can backpressure indefinitely when the language
|
|
43
|
+
* server's stdin isn't being drained (a wedged/CPU-bound server), which would
|
|
44
|
+
* hang the whole edit with no per-call bound — client acquisition is capped, but
|
|
45
|
+
* the notify write is not. So the sync is abandoned after this budget (the edit
|
|
46
|
+
* proceeds; the dispatch LSP runner, which has its own 30s cap, still tries).
|
|
47
|
+
*/
|
|
48
|
+
function lspSyncBudgetMs() {
|
|
49
|
+
const raw = Number(process.env.PI_LENS_LSP_SYNC_BUDGET_MS);
|
|
50
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 3000;
|
|
51
|
+
}
|
|
39
52
|
// Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
|
|
40
53
|
// `stack`. Extracted from the walk loop to keep each function's cognitive
|
|
41
54
|
// complexity low. Excluded/ignored dirs are not descended; ignored/vanished
|
|
@@ -615,12 +628,52 @@ export async function resyncLspFile(filePath, fileContent, needsContentRefresh,
|
|
|
615
628
|
// the cache clear (no preserveDiagnostics) so the wait resolves on fresh,
|
|
616
629
|
// correctly-positioned diagnostics rather than stale pre-edit ones — the
|
|
617
630
|
// didChange triggers a server recompute regardless of cache preservation.
|
|
618
|
-
|
|
631
|
+
//
|
|
632
|
+
// The touch is client-wait-capped, but its didChange/didOpen *write* can
|
|
633
|
+
// backpressure forever on a wedged server (stdin not drained), which would
|
|
634
|
+
// hang the whole edit with no bound and — until this — no log. Race it
|
|
635
|
+
// against a hard budget + the turn's abort signal (Escape): whichever wins,
|
|
636
|
+
// the edit proceeds. A wedged server no longer parks the pipeline.
|
|
637
|
+
const budgetMs = lspSyncBudgetMs();
|
|
638
|
+
const abort = getAmbientAbortSignal();
|
|
639
|
+
if (abort?.aborted)
|
|
640
|
+
return;
|
|
641
|
+
const bail = combineAbortSignals(abort, AbortSignal.timeout(budgetMs));
|
|
642
|
+
const startedAt = Date.now();
|
|
643
|
+
const touch = lspService
|
|
644
|
+
.touchFile(filePath, fileContent, {
|
|
619
645
|
diagnostics: "none",
|
|
620
646
|
source: "lsp_sync",
|
|
621
647
|
clientScope: "primary",
|
|
622
648
|
maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
|
|
649
|
+
})
|
|
650
|
+
.then(() => "done")
|
|
651
|
+
.catch((err) => {
|
|
652
|
+
dbg(`LSP resync after autofix error: ${err}`);
|
|
653
|
+
return "done";
|
|
623
654
|
});
|
|
655
|
+
const bailed = new Promise((resolve) => {
|
|
656
|
+
if (!bail || bail.aborted)
|
|
657
|
+
return resolve("bailed");
|
|
658
|
+
bail.addEventListener("abort", () => resolve("bailed"), { once: true });
|
|
659
|
+
});
|
|
660
|
+
const outcome = await Promise.race([touch, bailed]);
|
|
661
|
+
if (outcome === "bailed") {
|
|
662
|
+
// Abandon the still-pending write; the edit continues. Log it so this
|
|
663
|
+
// stall — previously an invisible hang — is queryable in latency.log.
|
|
664
|
+
logLatency({
|
|
665
|
+
type: "phase",
|
|
666
|
+
phase: "lsp_sync_abandoned",
|
|
667
|
+
filePath,
|
|
668
|
+
durationMs: Date.now() - startedAt,
|
|
669
|
+
metadata: {
|
|
670
|
+
source: "lsp_sync",
|
|
671
|
+
reason: abort?.aborted ? "aborted" : "timeout",
|
|
672
|
+
budgetMs,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
dbg(`LSP resync ${abort?.aborted ? "aborted (Escape)" : `timed out after ${budgetMs}ms`}; server slow/wedged for ${filePath}`);
|
|
676
|
+
}
|
|
624
677
|
}
|
|
625
678
|
}
|
|
626
679
|
catch (err) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { detectNodePackageManager, formatRunScript, } from "./package-manager.js";
|
|
14
15
|
// --- Detection Functions ---
|
|
15
16
|
/**
|
|
16
17
|
* Detect project metadata from a target directory.
|
|
@@ -204,24 +205,6 @@ function detectNodeProject(targetPath) {
|
|
|
204
205
|
}
|
|
205
206
|
return metadata;
|
|
206
207
|
}
|
|
207
|
-
/**
|
|
208
|
-
* Detect Node.js package manager from lockfiles
|
|
209
|
-
*/
|
|
210
|
-
function detectNodePackageManager(targetPath) {
|
|
211
|
-
if (fs.existsSync(path.join(targetPath, "bun.lockb")) || fs.existsSync(path.join(targetPath, "bun.lock"))) {
|
|
212
|
-
return "bun";
|
|
213
|
-
}
|
|
214
|
-
if (fs.existsSync(path.join(targetPath, "pnpm-lock.yaml"))) {
|
|
215
|
-
return "pnpm";
|
|
216
|
-
}
|
|
217
|
-
if (fs.existsSync(path.join(targetPath, "yarn.lock"))) {
|
|
218
|
-
return "yarn";
|
|
219
|
-
}
|
|
220
|
-
if (fs.existsSync(path.join(targetPath, "package-lock.json"))) {
|
|
221
|
-
return "npm";
|
|
222
|
-
}
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
208
|
/**
|
|
226
209
|
* Detect Python project from pyproject.toml, setup.py, requirements.txt
|
|
227
210
|
*/
|
|
@@ -663,13 +646,10 @@ export function getAvailableCommands(metadata) {
|
|
|
663
646
|
for (const priority of scriptPriority) {
|
|
664
647
|
const matching = Object.entries(metadata.scripts).find(([name]) => name.toLowerCase().includes(priority));
|
|
665
648
|
if (matching) {
|
|
666
|
-
const
|
|
667
|
-
metadata.packageManager === "pnpm" ? "pnpm" :
|
|
668
|
-
metadata.packageManager === "yarn" ? "yarn" :
|
|
669
|
-
"npm run";
|
|
649
|
+
const pm = (metadata.packageManager ?? "npm");
|
|
670
650
|
commands.push({
|
|
671
651
|
action: priority,
|
|
672
|
-
command:
|
|
652
|
+
command: formatRunScript(pm, matching[0]),
|
|
673
653
|
});
|
|
674
654
|
}
|
|
675
655
|
}
|
|
@@ -35,6 +35,15 @@ let ambientAbortSignal;
|
|
|
35
35
|
export function setAmbientAbortSignal(signal) {
|
|
36
36
|
ambientAbortSignal = signal;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The current turn's abort signal, for in-process awaits that aren't child
|
|
40
|
+
* spawns (e.g. an LSP JSON-RPC write that can backpressure on a wedged server).
|
|
41
|
+
* Child spawns read the ambient signal internally; this getter lets the
|
|
42
|
+
* interactive pipeline honor Escape on non-spawn LSP calls too.
|
|
43
|
+
*/
|
|
44
|
+
export function getAmbientAbortSignal() {
|
|
45
|
+
return ambientAbortSignal;
|
|
46
|
+
}
|
|
38
47
|
// ============================================================================
|
|
39
48
|
// INTERNAL HELPERS
|
|
40
49
|
// ============================================================================
|
|
@@ -94,6 +94,53 @@ export class TreeSitterClient {
|
|
|
94
94
|
return cwdCandidate;
|
|
95
95
|
return undefined;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The `grammars/` dir bundled inside the pi-lens package (the core grammars
|
|
99
|
+
* shipped in the tarball, so common languages parse offline on every package
|
|
100
|
+
* manager). Resolved from the package root; cached. Absent in a source
|
|
101
|
+
* checkout where `prepare` hasn't populated it.
|
|
102
|
+
*/
|
|
103
|
+
_bundledGrammarsDir;
|
|
104
|
+
bundledGrammarsDir() {
|
|
105
|
+
// Cache only a positive hit; keep re-checking until it exists (prepare may
|
|
106
|
+
// not have populated it yet at first probe).
|
|
107
|
+
if (this._bundledGrammarsDir)
|
|
108
|
+
return this._bundledGrammarsDir;
|
|
109
|
+
try {
|
|
110
|
+
const dir = resolvePackagePath(import.meta.url, "grammars");
|
|
111
|
+
if (fs.existsSync(dir))
|
|
112
|
+
this._bundledGrammarsDir = dir;
|
|
113
|
+
return this._bundledGrammarsDir;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* All directories that may hold grammar wasms, in precedence order: the
|
|
121
|
+
* bundled core dir, the resolved `this.grammarsDir`, and the web-tree-sitter
|
|
122
|
+
* grammars dir (the lazy-fetch write target). Deduped.
|
|
123
|
+
*/
|
|
124
|
+
grammarSourceDirs() {
|
|
125
|
+
const dirs = [];
|
|
126
|
+
const push = (d) => {
|
|
127
|
+
if (d && !dirs.includes(d))
|
|
128
|
+
dirs.push(d);
|
|
129
|
+
};
|
|
130
|
+
push(this.bundledGrammarsDir());
|
|
131
|
+
push(this.grammarsDir || undefined);
|
|
132
|
+
push(this.resolveWebTreeSitterAsset("grammars"));
|
|
133
|
+
return dirs;
|
|
134
|
+
}
|
|
135
|
+
/** Absolute path to `grammarFile` across all source dirs, else undefined. */
|
|
136
|
+
resolveGrammarFile(grammarFile) {
|
|
137
|
+
for (const dir of this.grammarSourceDirs()) {
|
|
138
|
+
const candidate = path.join(dir, grammarFile);
|
|
139
|
+
if (fs.existsSync(candidate))
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
97
144
|
/** Find tree-sitter grammar directory */
|
|
98
145
|
findGrammarsDir() {
|
|
99
146
|
const grammarsDir = this.resolveWebTreeSitterAsset("grammars");
|
|
@@ -101,7 +148,8 @@ export class TreeSitterClient {
|
|
|
101
148
|
fs.existsSync(path.join(grammarsDir, "tree-sitter-typescript.wasm"))) {
|
|
102
149
|
return grammarsDir;
|
|
103
150
|
}
|
|
104
|
-
// Fallback: tree-sitter-wasms package
|
|
151
|
+
// Fallback: a real `tree-sitter-wasms` package, if the user installed one
|
|
152
|
+
// (it is not a pi-lens dependency — grammars ship bundled / lazy-fetched).
|
|
105
153
|
try {
|
|
106
154
|
const wasmsOut = path.join(path.dirname(_require.resolve("tree-sitter-wasms/package.json")), "out");
|
|
107
155
|
if (fs.existsSync(wasmsOut))
|
|
@@ -145,7 +193,7 @@ export class TreeSitterClient {
|
|
|
145
193
|
* throws.
|
|
146
194
|
*/
|
|
147
195
|
async ensureGrammar(grammarFile) {
|
|
148
|
-
if (this.
|
|
196
|
+
if (this.resolveGrammarFile(grammarFile)) {
|
|
149
197
|
return true;
|
|
150
198
|
}
|
|
151
199
|
const inflight = this.grammarEnsurePromises.get(grammarFile);
|
|
@@ -166,7 +214,14 @@ export class TreeSitterClient {
|
|
|
166
214
|
console.error(`[pi-lens] fetched missing tree-sitter grammar ${grammarFile} at runtime (install scripts were skipped by the package manager)`);
|
|
167
215
|
}
|
|
168
216
|
else {
|
|
169
|
-
|
|
217
|
+
// Surface the degradation once per grammar (the promise cache dedupes)
|
|
218
|
+
// instead of failing silently — otherwise pnpm/bun users offline get
|
|
219
|
+
// no signal that a language's tree-sitter features are unavailable.
|
|
220
|
+
console.error(`[pi-lens] tree-sitter grammar '${grammarFile}' is unavailable — ` +
|
|
221
|
+
`symbol search, module reports and structural rules for this language will be degraded. ` +
|
|
222
|
+
`The package manager skipped install scripts and the runtime download failed (offline or CDN unreachable). ` +
|
|
223
|
+
`Fix: reinstall with a manager that runs postinstall, allow its build scripts ` +
|
|
224
|
+
`(pnpm approve-builds / bun trustedDependencies), or restore network access.`);
|
|
170
225
|
}
|
|
171
226
|
return ok;
|
|
172
227
|
})();
|
|
@@ -238,14 +293,14 @@ export class TreeSitterClient {
|
|
|
238
293
|
this.dbg(`No grammar file for ${languageId}`);
|
|
239
294
|
return null;
|
|
240
295
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
if (!grammarPath
|
|
296
|
+
// Look across the bundled core `grammars/` dir and the postinstall/lazy
|
|
297
|
+
// dir. Lazily fetch only if the grammar is in neither (pnpm/bun skip
|
|
298
|
+
// postinstall; the long-tail grammars aren't bundled). Only the language
|
|
299
|
+
// actually being parsed is fetched.
|
|
300
|
+
let grammarPath = this.resolveGrammarFile(grammarFile);
|
|
301
|
+
if (!grammarPath) {
|
|
247
302
|
if (await this.ensureGrammar(grammarFile)) {
|
|
248
|
-
grammarPath =
|
|
303
|
+
grammarPath = this.resolveGrammarFile(grammarFile);
|
|
249
304
|
}
|
|
250
305
|
}
|
|
251
306
|
this.dbg(`Grammar path: ${grammarPath}, exists: ${grammarPath && fs.existsSync(grammarPath)}`);
|
|
@@ -356,14 +411,17 @@ export class TreeSitterClient {
|
|
|
356
411
|
this.dbg(`Found ${injections.length} injections in ${filePath}`);
|
|
357
412
|
return injections;
|
|
358
413
|
}
|
|
359
|
-
/** Check if tree-sitter is available (
|
|
414
|
+
/** Check if tree-sitter is available (a core grammar resolves somewhere). */
|
|
360
415
|
isAvailable() {
|
|
361
|
-
if
|
|
416
|
+
// Available if the core TS grammar resolves in ANY source dir — the bundled
|
|
417
|
+
// `grammars/` counts even when web-tree-sitter/grammars is empty (no
|
|
418
|
+
// postinstall on pnpm/bun, or a fresh CI checkout).
|
|
419
|
+
if (this.resolveGrammarFile("tree-sitter-typescript.wasm"))
|
|
362
420
|
return true;
|
|
363
|
-
// Re-evaluate in case grammars were installed after
|
|
421
|
+
// Re-evaluate the legacy dir in case grammars were installed after start.
|
|
364
422
|
const dir = this.findGrammarsDir();
|
|
365
423
|
this.grammarsDir = dir;
|
|
366
|
-
return fs.existsSync(dir);
|
|
424
|
+
return !!dir && fs.existsSync(dir);
|
|
367
425
|
}
|
|
368
426
|
/** Check if specific language is supported */
|
|
369
427
|
async isLanguageSupported(languageId) {
|
package/dist/index.js
CHANGED
|
@@ -1548,6 +1548,22 @@ export default function (pi) {
|
|
|
1548
1548
|
// Publish this turn's abort signal so the dispatch's linter/type-check
|
|
1549
1549
|
// child processes are killed if the agent is interrupted (#197 ctx.signal).
|
|
1550
1550
|
setAmbientAbortSignal(ctx?.signal);
|
|
1551
|
+
// Earliest possible marker for the edit pipeline: the first instrumented
|
|
1552
|
+
// phase is `read_file` deep inside runPipeline, so a stall before that (or
|
|
1553
|
+
// upstream, before pi-lens even received the event) leaves NO trace — that
|
|
1554
|
+
// is exactly why a wedged-LSP edit hang was invisible in latency.log. This
|
|
1555
|
+
// row means "pi-lens received this edit"; if it is present but nothing
|
|
1556
|
+
// follows, the stall is in the pipeline; if it is absent, it is upstream.
|
|
1557
|
+
const rtToolName = event?.toolName;
|
|
1558
|
+
if (rtToolName === "edit" || rtToolName === "write") {
|
|
1559
|
+
logLatency({
|
|
1560
|
+
type: "phase",
|
|
1561
|
+
phase: "tool_result_received",
|
|
1562
|
+
filePath: event?.input?.path ?? "<unknown>",
|
|
1563
|
+
durationMs: 0,
|
|
1564
|
+
metadata: { toolName: rtToolName },
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1551
1567
|
try {
|
|
1552
1568
|
const { biomeClient, ruffClient, metricsClient, agentBehaviorClient } = await loadBootstrapClients();
|
|
1553
1569
|
return await handleToolResult({
|
package/dist/mcp/server.js
CHANGED
|
@@ -483,13 +483,15 @@ async function callTool(name, args) {
|
|
|
483
483
|
}
|
|
484
484
|
if (name === "pilens_rebuild") {
|
|
485
485
|
const outcome = await runRebuild(REPO_ROOT, REBUILD_SCRIPT);
|
|
486
|
+
const runCmd = `${outcome.packageManager} run ${outcome.script}`;
|
|
486
487
|
const headline = outcome.ok
|
|
487
|
-
? `✓ rebuild succeeded (
|
|
488
|
-
: `✗ rebuild FAILED (
|
|
488
|
+
? `✓ rebuild succeeded (${runCmd}, ${outcome.durationMs}ms). Fresh analyses now reflect the latest build.`
|
|
489
|
+
: `✗ rebuild FAILED (${runCmd}, ${outcome.durationMs}ms).`;
|
|
489
490
|
return {
|
|
490
491
|
...toolText(outcome.ok ? headline : `${headline}\n\n${outcome.output}`, {
|
|
491
492
|
ok: outcome.ok,
|
|
492
493
|
script: outcome.script,
|
|
494
|
+
packageManager: outcome.packageManager,
|
|
493
495
|
durationMs: outcome.durationMs,
|
|
494
496
|
repoRoot: REPO_ROOT,
|
|
495
497
|
}),
|
|
@@ -7,6 +7,7 @@ import { Type } from "../clients/deps/typebox.js";
|
|
|
7
7
|
import { astGrepRemediationHint, classifyAstGrepError, logAstGrepToolEvent, } from "../clients/ast-grep-tool-logger.js";
|
|
8
8
|
import { hasStructuralIntent, synthesizeRule, } from "../clients/ast-grep-yaml-synth.js";
|
|
9
9
|
import { compactRenderResult } from "./render-compact.js";
|
|
10
|
+
import { combineAbortSignals } from "../clients/deadline-utils.js";
|
|
10
11
|
import { LANGUAGES } from "./shared.js";
|
|
11
12
|
/**
|
|
12
13
|
* Build the agent-facing error text, appending a remediation hint derived from
|
|
@@ -303,6 +304,9 @@ export function createAstGrepSearchTool(astGrepClient) {
|
|
|
303
304
|
})),
|
|
304
305
|
}),
|
|
305
306
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
307
|
+
// Escape aborts the turn via ctx.signal; the positional signal is the
|
|
308
|
+
// tool-call one. Honor both so a broad search cancels on Escape.
|
|
309
|
+
const abortSignal = combineAbortSignals(_signal, ctx.signal);
|
|
306
310
|
const startedAt = Date.now();
|
|
307
311
|
const { paths, selector, context, skip, maxMatches, groupByFile, strictness, rule, insideKind, hasKind, follows, precedes, validateOnly, } = params;
|
|
308
312
|
const pattern = typeof params.pattern === "string" ? params.pattern : "";
|
|
@@ -398,7 +402,7 @@ export function createAstGrepSearchTool(astGrepClient) {
|
|
|
398
402
|
details: {},
|
|
399
403
|
};
|
|
400
404
|
}
|
|
401
|
-
if (
|
|
405
|
+
if (abortSignal?.aborted)
|
|
402
406
|
return abortError();
|
|
403
407
|
if (!(await astGrepClient.ensureAvailable())) {
|
|
404
408
|
logOutcome("error", {
|
|
@@ -415,7 +419,7 @@ export function createAstGrepSearchTool(astGrepClient) {
|
|
|
415
419
|
details: {},
|
|
416
420
|
};
|
|
417
421
|
}
|
|
418
|
-
if (
|
|
422
|
+
if (abortSignal?.aborted)
|
|
419
423
|
return abortError();
|
|
420
424
|
if (!hasRawRule && looksLikeRuleYamlOrPlainText(pattern)) {
|
|
421
425
|
logOutcome("error", {
|
|
@@ -503,10 +507,10 @@ export function createAstGrepSearchTool(astGrepClient) {
|
|
|
503
507
|
}
|
|
504
508
|
// Phase 4: raw YAML rule passthrough — routes through sg scan --config
|
|
505
509
|
if (effectiveRule && effectiveRule.trim().length > 0) {
|
|
506
|
-
if (
|
|
510
|
+
if (abortSignal?.aborted)
|
|
507
511
|
return abortError();
|
|
508
512
|
const ruleResult = await astGrepClient.searchWithRule(effectiveRule, searchPaths);
|
|
509
|
-
if (
|
|
513
|
+
if (abortSignal?.aborted)
|
|
510
514
|
return abortError();
|
|
511
515
|
if (ruleResult.error) {
|
|
512
516
|
logOutcome("error", { errorRaw: ruleResult.error });
|
|
@@ -555,14 +559,14 @@ export function createAstGrepSearchTool(astGrepClient) {
|
|
|
555
559
|
},
|
|
556
560
|
};
|
|
557
561
|
}
|
|
558
|
-
if (
|
|
562
|
+
if (abortSignal?.aborted)
|
|
559
563
|
return abortError();
|
|
560
564
|
const result = await astGrepClient.search(pattern, lang, searchPaths, {
|
|
561
565
|
selector,
|
|
562
566
|
context,
|
|
563
567
|
strictness,
|
|
564
568
|
});
|
|
565
|
-
if (
|
|
569
|
+
if (abortSignal?.aborted)
|
|
566
570
|
return abortError();
|
|
567
571
|
if (result.error) {
|
|
568
572
|
logOutcome("error", { errorRaw: result.error });
|