pi-lens 3.8.62 → 3.8.63
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 +15 -0
- package/dist/clients/deadline-utils.js +70 -0
- package/dist/clients/installer/index.js +32 -8
- package/dist/clients/lsp/client.js +112 -61
- package/dist/clients/lsp/index.js +10 -0
- package/dist/clients/lsp/launch.js +2 -2
- package/dist/clients/lsp/server.js +29 -22
- package/dist/clients/module-report-lsp.js +1 -16
- package/dist/clients/pipeline.js +58 -37
- package/dist/clients/read-expansion.js +1 -14
- package/package.json +1 -1
- package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-js-test.yml +21 -0
- package/rules/ast-grep-rules/rule-tests/incomplete-string-escaping-test.yml +21 -0
- package/rules/ast-grep-rules/rules/incomplete-string-escaping-js.yml +19 -0
- package/rules/ast-grep-rules/rules/incomplete-string-escaping.yml +19 -0
- package/skills/write-ast-grep-rule/SKILL.md +36 -26
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,21 @@ All notable changes to pi-lens will be documented in this file.
|
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
## [3.8.63] - 2026-07-01
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Consolidated the timeout-race helpers into one shared `clients/deadline-utils.ts` (#366)** — the "race a promise against a timer" pattern had drifted into three near-identical copies (`withTimeout` in the LSP client, `withBudget` in read-expansion, `withinRemaining` in module-report). They're now thin adapters over one `withDeadline` core, which also fixes two latent bugs the copies carried: `withBudget` didn't suppress the loser promise's late rejection (an unhandled rejection when the timer won first), and `withinRemaining` never cleared its timer. Behaviour at every call site is unchanged (covered by the consumer suites); the core's semantics are locked by dedicated tests including an explicit late-rejection-suppression probe.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Bounded the remaining unbounded LSP requests — `workspace/symbol`, `textDocument/codeAction`, `workspace/executeCommand` (#365)** — like the pull-diagnostics fix (#364), these were sent via `safeSendRequest` with no `withTimeout` ceiling, so a language server that accepts a request but never replies (alive but hung) would make the `await` never resolve — hanging symbol search (`pilens_symbol_search`), code-action lookups, and server-command execution. `workspace/symbol` and `textDocument/codeAction` now route through the shared `navRequest` helper (its `withTimeout` ceiling + single-file stale-drop), timing out to `[]`. `workspace/executeCommand` — which is mutating and legitimately long-running — gets a separate, generous 30s anti-deadlock backstop (`PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS`) that returns an honest `executed:false, reason:"…may still be applying server-side"` rather than truncating valid work or pretending it ran; its allowlist-by-advertisement and server-edit-window hardening are preserved. Real (non-timeout) errors still propagate throughout.
|
|
24
|
+
- **Autofix project-snapshot walk no longer freezes the TUI on large repos (#368)** — `snapshotProjectFiles` (the `tool_result` autofix side-effect detection that snapshots the project tree before/after a formatter or fixer runs, to catch files it changed as a side effect) was a fully synchronous `readdirSync`/`statSync` walk bounded only by a 5,000-file cap — a ~130ms event-loop block at the cap (2–4× that under load), stalling keystrokes on large projects while autofix ran. It now walks asynchronously and yields to the event loop every 500 files, so it holds the loop only for a short chunk. The scan cap and directory-exclusion/confinement behavior are unchanged; a cap-scale (~5k-file) event-loop occupancy guard asserts the walk keeps yielding.
|
|
25
|
+
- **Bounded LSP pull-diagnostics request — a hung server can no longer hang `lens_diagnostics` (#349, #364)** — the pull path in `clientWaitForDiagnostics` awaited a `textDocument/diagnostic` request via `safeSendRequest`, which only settles on a reply or a *destroyed* stream. A pull-mode server that is alive but hung (accepts the request, never replies) made that `await` never resolve, hanging the diagnostics wait → the `dispatch_lint` pipeline phase → `flushDebouncedToolResults` → `lens_diagnostics`, forever (and `safeSpawnAsync`'s 30s cap doesn't apply — it's a pipe request, not a spawn). Unlike the navigation/init/shutdown callers, this request had no `withTimeout` ceiling; the `timeoutMs` passed into `clientWaitForDiagnostics` only bounded the push backstop and the pull *retry interval*, never the individual request. The request is now wrapped in the existing `withTimeout` helper, bounded by `min(PULL_REQUEST_TIMEOUT_MS, remaining caller budget)` (env `PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS`, default 10s, mirroring `NAV_REQUEST_TIMEOUT_MS`). On timeout the request is caught as `unavailable`, which per #240 is not read as clean and falls through to the already-bounded push backstop. Explains the intermittent repro — it only fires for pull-mode servers (rust-analyzer being the classic) stalling mid-analysis. Regression test: a pull server whose `sendRequest` never resolves now resolves within the caller's budget instead of hanging.
|
|
26
|
+
- **POSIX LSP teardown now cleans up the whole process tree, not just the direct child (#362, #363)** — on POSIX, LSP servers launched through wrappers (npm shims, shell/node launchers) could leave descendants alive after pi-lens reset or shut down an LSP service; observed most visibly as `vscode-html-language-server` processes accumulating across long-lived zellij sessions and pressuring memory. LSP servers are now spawned detached into their own process group and teardown signals the group (`process.kill(-pid, ...)`) before falling back to the direct child, bringing POSIX cleanup in line with the existing Windows `taskkill /T` behavior. Windows teardown is unchanged (`taskkill /T` mid-session, handle-only kill for `processExiting`). Guarded by the `pid <= 0` check so a group signal can never degrade into `process.kill(-0)` against pi-lens's own process group.
|
|
27
|
+
|
|
13
28
|
## [3.8.62] - 2026-06-28
|
|
14
29
|
|
|
15
30
|
### Added
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One implementation of the "race a promise against a timer" pattern that had
|
|
3
|
+
* drifted into three near-identical copies (#366): `withTimeout` (clients/lsp),
|
|
4
|
+
* `withBudget` (read-expansion), and `withinRemaining` (module-report-lsp).
|
|
5
|
+
*
|
|
6
|
+
* The differences between them were real — timeout can *reject* or *resolve
|
|
7
|
+
* undefined*, and the raced promise's own rejection can *propagate* or be
|
|
8
|
+
* *swallowed* — so they are kept as named adapters over one core. Consolidating
|
|
9
|
+
* fixes two latent bugs the copies had: `withBudget` did not suppress the loser
|
|
10
|
+
* promise's late rejection (an unhandled rejection if the timer won first), and
|
|
11
|
+
* `withinRemaining` never cleared its timer.
|
|
12
|
+
*/
|
|
13
|
+
export function withDeadline(promise, options) {
|
|
14
|
+
const onTimeout = options.onTimeout ?? "reject";
|
|
15
|
+
const onReject = options.onReject ?? "propagate";
|
|
16
|
+
const ms = options.ms ??
|
|
17
|
+
(options.deadlineAt !== undefined ? options.deadlineAt - Date.now() : 0);
|
|
18
|
+
// Past deadline / non-positive budget: settle immediately, no timer.
|
|
19
|
+
if (ms <= 0) {
|
|
20
|
+
return onTimeout === "undefined"
|
|
21
|
+
? Promise.resolve(undefined)
|
|
22
|
+
: Promise.reject(new Error(`Timeout after ${Math.max(0, ms)}ms`));
|
|
23
|
+
}
|
|
24
|
+
// Base promise with rejection handled per `onReject`. In propagate mode we
|
|
25
|
+
// still attach a no-op catch so that if the timer wins the race, the loser
|
|
26
|
+
// promise's later rejection does not surface as an unhandled rejection.
|
|
27
|
+
const base = onReject === "undefined" ? promise.catch(() => undefined) : promise;
|
|
28
|
+
if (onReject === "propagate")
|
|
29
|
+
promise.catch(() => { });
|
|
30
|
+
let timer;
|
|
31
|
+
const timeoutPromise = new Promise((resolve, reject) => {
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
if (onTimeout === "undefined")
|
|
34
|
+
resolve(undefined);
|
|
35
|
+
else
|
|
36
|
+
reject(new Error(`Timeout after ${ms}ms`));
|
|
37
|
+
}, ms);
|
|
38
|
+
});
|
|
39
|
+
return Promise.race([base, timeoutPromise]).finally(() => {
|
|
40
|
+
if (timer)
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve `promise`, or reject with `Error("Timeout after <ms>ms")` once
|
|
46
|
+
* `timeoutMs` elapses. The raced promise's own rejection propagates.
|
|
47
|
+
*/
|
|
48
|
+
export function withTimeout(promise, timeoutMs) {
|
|
49
|
+
return withDeadline(promise, { ms: timeoutMs });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve `promise`, or `undefined` once `budgetMs` elapses (a non-positive
|
|
53
|
+
* budget resolves `undefined` immediately). The raced promise's own rejection
|
|
54
|
+
* propagates.
|
|
55
|
+
*/
|
|
56
|
+
export function withBudget(promise, budgetMs) {
|
|
57
|
+
return withDeadline(promise, { ms: budgetMs, onTimeout: "undefined" });
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve `promise`, or `undefined` once the shared `deadlineAt` passes (a past
|
|
61
|
+
* deadline resolves `undefined` immediately). The raced promise's own rejection
|
|
62
|
+
* is swallowed to `undefined`.
|
|
63
|
+
*/
|
|
64
|
+
export function withinRemaining(promise, deadlineAt) {
|
|
65
|
+
return withDeadline(promise, {
|
|
66
|
+
deadlineAt,
|
|
67
|
+
onTimeout: "undefined",
|
|
68
|
+
onReject: "undefined",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -1150,6 +1150,8 @@ export async function updateProbeCache(toolId, resolvedPath) {
|
|
|
1150
1150
|
export function resetProbeCacheStateForTesting() {
|
|
1151
1151
|
_probeCache = null;
|
|
1152
1152
|
_probeCacheDirty = false;
|
|
1153
|
+
resolvedPathCache.clear();
|
|
1154
|
+
ensureInFlight.clear();
|
|
1153
1155
|
if (_probeCacheFlushTimer !== null) {
|
|
1154
1156
|
clearTimeout(_probeCacheFlushTimer);
|
|
1155
1157
|
_probeCacheFlushTimer = null;
|
|
@@ -2559,8 +2561,17 @@ export async function installTool(toolId) {
|
|
|
2559
2561
|
* Ensure a tool is installed (check first, install if missing)
|
|
2560
2562
|
*/
|
|
2561
2563
|
export async function ensureTool(toolId, opts) {
|
|
2564
|
+
const cacheResolvedPath = (result) => {
|
|
2565
|
+
if (result) {
|
|
2566
|
+
resolvedPathCache.set(toolId, result);
|
|
2567
|
+
void updateProbeCache(toolId, result);
|
|
2568
|
+
}
|
|
2569
|
+
return result;
|
|
2570
|
+
};
|
|
2562
2571
|
// forceReinstall: nuke caches, download from managed source, skip PATH entirely.
|
|
2563
2572
|
// Used when a PATH-resolved tool proves broken at launch (e.g. broken symlink).
|
|
2573
|
+
// allowInstall:false wins over forceReinstall: caches are still cleared, but
|
|
2574
|
+
// the function falls back to discovery-only and never downloads.
|
|
2564
2575
|
if (opts?.forceReinstall) {
|
|
2565
2576
|
const ensureStartMs = Date.now();
|
|
2566
2577
|
logSessionStart(`auto-install ensure ${toolId}: force reinstall — clearing caches`);
|
|
@@ -2576,6 +2587,10 @@ export async function ensureTool(toolId, opts) {
|
|
|
2576
2587
|
catch {
|
|
2577
2588
|
// best-effort
|
|
2578
2589
|
}
|
|
2590
|
+
if (opts.allowInstall === false) {
|
|
2591
|
+
logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
|
|
2592
|
+
return cacheResolvedPath(await getToolPath(toolId));
|
|
2593
|
+
}
|
|
2579
2594
|
// Force download
|
|
2580
2595
|
const installed = await installTool(toolId);
|
|
2581
2596
|
if (!installed) {
|
|
@@ -2583,10 +2598,8 @@ export async function ensureTool(toolId, opts) {
|
|
|
2583
2598
|
return undefined;
|
|
2584
2599
|
}
|
|
2585
2600
|
// Find the newly installed binary (github-local check now comes before PATH)
|
|
2586
|
-
const result = await getToolPath(toolId);
|
|
2601
|
+
const result = cacheResolvedPath(await getToolPath(toolId));
|
|
2587
2602
|
if (result) {
|
|
2588
|
-
resolvedPathCache.set(toolId, result);
|
|
2589
|
-
void updateProbeCache(toolId, result);
|
|
2590
2603
|
logSessionStart(`auto-install ensure ${toolId}: force reinstall success at ${result} (${Date.now() - ensureStartMs}ms)`);
|
|
2591
2604
|
}
|
|
2592
2605
|
return result;
|
|
@@ -2604,10 +2617,13 @@ export async function ensureTool(toolId, opts) {
|
|
|
2604
2617
|
}
|
|
2605
2618
|
// Coalesce the whole ensure operation, not just installation. Most startup
|
|
2606
2619
|
// duplicates race while checking already-installed tools, before installTool()
|
|
2607
|
-
// would ever run.
|
|
2608
|
-
|
|
2620
|
+
// would ever run. The key includes the install policy so a discovery-only
|
|
2621
|
+
// caller cannot accidentally inherit an install-allowed caller's download (or
|
|
2622
|
+
// vice versa).
|
|
2623
|
+
const inFlightKey = opts?.allowInstall === false ? `${toolId}:discovery-only` : toolId;
|
|
2624
|
+
const inFlight = ensureInFlight.get(inFlightKey);
|
|
2609
2625
|
if (inFlight) {
|
|
2610
|
-
logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight ensure`);
|
|
2626
|
+
logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight ensure (${inFlightKey})`);
|
|
2611
2627
|
return inFlight;
|
|
2612
2628
|
}
|
|
2613
2629
|
const ensureStartMs = Date.now();
|
|
@@ -2621,6 +2637,14 @@ export async function ensureTool(toolId, opts) {
|
|
|
2621
2637
|
logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
|
|
2622
2638
|
return existingPath;
|
|
2623
2639
|
}
|
|
2640
|
+
// Discovery and install are SEPARATE concerns. getToolPath() above already
|
|
2641
|
+
// probed PATH / npm-global / managed bin — offline-safe, no download. When the
|
|
2642
|
+
// caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
|
|
2643
|
+
// we must still return a discovered binary and only skip the actual install.
|
|
2644
|
+
if (opts?.allowInstall === false) {
|
|
2645
|
+
logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
|
|
2646
|
+
return undefined;
|
|
2647
|
+
}
|
|
2624
2648
|
const installed = await installTool(toolId);
|
|
2625
2649
|
if (!installed) {
|
|
2626
2650
|
logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
|
|
@@ -2637,12 +2661,12 @@ export async function ensureTool(toolId, opts) {
|
|
|
2637
2661
|
}
|
|
2638
2662
|
return result;
|
|
2639
2663
|
})();
|
|
2640
|
-
ensureInFlight.set(
|
|
2664
|
+
ensureInFlight.set(inFlightKey, ensurePromise);
|
|
2641
2665
|
try {
|
|
2642
2666
|
return await ensurePromise;
|
|
2643
2667
|
}
|
|
2644
2668
|
finally {
|
|
2645
|
-
ensureInFlight.delete(
|
|
2669
|
+
ensureInFlight.delete(inFlightKey);
|
|
2646
2670
|
}
|
|
2647
2671
|
}
|
|
2648
2672
|
// --- Integration Helpers ---
|
|
@@ -11,6 +11,7 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import { access, readFile } from "node:fs/promises";
|
|
13
13
|
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { withTimeout } from "../deadline-utils.js";
|
|
14
15
|
import { logLatency } from "../latency-logger.js";
|
|
15
16
|
// vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
|
|
16
17
|
// `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
|
|
@@ -76,7 +77,20 @@ export const CLIENT_CAPABILITIES = {
|
|
|
76
77
|
const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
|
|
77
78
|
const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
|
|
78
79
|
const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
|
|
80
|
+
// Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
|
|
81
|
+
// NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
|
|
82
|
+
// stream, so a pull-mode server that is alive but hung (accepts the request, never
|
|
83
|
+
// replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
|
|
84
|
+
// the diagnostics flush. On timeout the request is treated as `unavailable`, which
|
|
85
|
+
// (per #240) is NOT read as clean and falls through to the bounded push backstop.
|
|
86
|
+
const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
|
|
79
87
|
const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
|
|
88
|
+
// Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
|
|
89
|
+
// (30s): the command is mutating and legitimately long-running (a real server
|
|
90
|
+
// refactor / organize-imports), so this must not truncate valid work — it only
|
|
91
|
+
// stops a hung server from blocking the caller forever. On timeout the command
|
|
92
|
+
// may still be applying server-side; we surface that rather than pretend it ran.
|
|
93
|
+
const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
|
|
80
94
|
const LSP_CRASH_CODES = new Set([
|
|
81
95
|
"ERR_STREAM_DESTROYED",
|
|
82
96
|
"ERR_STREAM_WRITE_AFTER_END",
|
|
@@ -179,18 +193,36 @@ export async function killProcessTree(proc, pid, options = {}) {
|
|
|
179
193
|
}
|
|
180
194
|
return;
|
|
181
195
|
}
|
|
196
|
+
const killPosixProcessGroup = (signal) => {
|
|
197
|
+
if (pid <= 0)
|
|
198
|
+
return false;
|
|
199
|
+
try {
|
|
200
|
+
process.kill(-pid, signal);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const killDirectChild = (signal) => {
|
|
208
|
+
try {
|
|
209
|
+
proc.kill(signal);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// best-effort
|
|
213
|
+
}
|
|
214
|
+
};
|
|
182
215
|
try {
|
|
183
|
-
|
|
216
|
+
if (!killPosixProcessGroup("SIGTERM")) {
|
|
217
|
+
killDirectChild("SIGTERM");
|
|
218
|
+
}
|
|
184
219
|
if (options.fast) {
|
|
185
220
|
const timer = setTimeout(() => {
|
|
186
|
-
|
|
187
|
-
if (!
|
|
188
|
-
|
|
221
|
+
if (!proc.killed) {
|
|
222
|
+
if (!killPosixProcessGroup("SIGKILL")) {
|
|
223
|
+
killDirectChild("SIGKILL");
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
|
-
catch {
|
|
192
|
-
// best-effort
|
|
193
|
-
}
|
|
194
226
|
}, 1500);
|
|
195
227
|
timer.unref?.();
|
|
196
228
|
proc.unref?.();
|
|
@@ -199,14 +231,11 @@ export async function killProcessTree(proc, pid, options = {}) {
|
|
|
199
231
|
// SIGTERM → 1.5s → SIGKILL escalation.
|
|
200
232
|
// SIGTERM alone can leave zombie processes if the server hangs.
|
|
201
233
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
202
|
-
|
|
203
|
-
if (!
|
|
204
|
-
|
|
234
|
+
if (!proc.killed) {
|
|
235
|
+
if (!killPosixProcessGroup("SIGKILL")) {
|
|
236
|
+
killDirectChild("SIGKILL");
|
|
205
237
|
}
|
|
206
238
|
}
|
|
207
|
-
catch {
|
|
208
|
-
// best-effort
|
|
209
|
-
}
|
|
210
239
|
}
|
|
211
240
|
catch {
|
|
212
241
|
// ignore
|
|
@@ -439,12 +468,17 @@ function setupConnectionLifecycle(state) {
|
|
|
439
468
|
}
|
|
440
469
|
});
|
|
441
470
|
}
|
|
442
|
-
async function clientRequestPullDiagnostics(state, filePath) {
|
|
471
|
+
async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
|
|
443
472
|
if (!isClientAlive(state))
|
|
444
473
|
return { status: "unavailable" };
|
|
445
474
|
const uri = pathToFileURL(filePath).href;
|
|
446
475
|
try {
|
|
447
|
-
|
|
476
|
+
// withTimeout is the backstop against a hung pull-mode server: without it
|
|
477
|
+
// this await never settles unless the stream is destroyed. Bounded by the
|
|
478
|
+
// smaller of the absolute ceiling and the caller's remaining wait budget.
|
|
479
|
+
// On timeout the caught error yields `unavailable` below (never a false
|
|
480
|
+
// `clean`), so it falls through to the push-wait/timeout backstop.
|
|
481
|
+
const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
|
|
448
482
|
if (!report)
|
|
449
483
|
return { status: "unavailable" };
|
|
450
484
|
const normalizedPath = normalizeMapKey(filePath);
|
|
@@ -499,7 +533,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
499
533
|
// `hasFreshDiagnostics()`, which is unconditionally true when there is no
|
|
500
534
|
// version baseline (`minVersion === undefined`), so a failed pull returned
|
|
501
535
|
// 0 and was read as a fresh clean.
|
|
502
|
-
let outcome = await clientRequestPullDiagnostics(state, filePath);
|
|
536
|
+
let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
|
|
503
537
|
if (outcome.status === "found")
|
|
504
538
|
return;
|
|
505
539
|
let sawClean = outcome.status === "clean";
|
|
@@ -513,7 +547,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
|
|
|
513
547
|
// any point is a valid affirmative answer for this touch.
|
|
514
548
|
while (outcome.status !== "found" && Date.now() - startedAt < retryBudgetMs) {
|
|
515
549
|
await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
|
|
516
|
-
outcome = await clientRequestPullDiagnostics(state, filePath);
|
|
550
|
+
outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
|
|
517
551
|
if (outcome.status === "clean")
|
|
518
552
|
sawClean = true;
|
|
519
553
|
}
|
|
@@ -714,18 +748,20 @@ async function toWirePosition(state, filePath, line, character) {
|
|
|
714
748
|
function navStaleDropEnabled() {
|
|
715
749
|
return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
|
|
716
750
|
}
|
|
717
|
-
|
|
751
|
+
// Exported for the timeout regression tests (#365). `timeoutMs` overrides the
|
|
752
|
+
// per-request ceiling so a test can bound a hung server quickly.
|
|
753
|
+
export async function navRequest(state, method, params,
|
|
718
754
|
// When provided, the request is dropped if the document's version advances
|
|
719
755
|
// (an edit landed) between send and response. Omit for non-single-file
|
|
720
756
|
// requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
|
|
721
|
-
staleCheckPath) {
|
|
757
|
+
staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS) {
|
|
722
758
|
if (!isClientAlive(state))
|
|
723
759
|
return null;
|
|
724
760
|
const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
|
|
725
761
|
const requestVersion = normalizedPath !== undefined
|
|
726
762
|
? state.documentVersions.get(normalizedPath)
|
|
727
763
|
: undefined;
|
|
728
|
-
const result = (await withTimeout(safeSendRequest(state.connection, method, params),
|
|
764
|
+
const result = (await withTimeout(safeSendRequest(state.connection, method, params), timeoutMs).catch((err) => {
|
|
729
765
|
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
730
766
|
return undefined;
|
|
731
767
|
}
|
|
@@ -743,6 +779,50 @@ staleCheckPath) {
|
|
|
743
779
|
}
|
|
744
780
|
return result;
|
|
745
781
|
}
|
|
782
|
+
// Run an advertised server command via workspace/executeCommand, with the
|
|
783
|
+
// generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
|
|
784
|
+
// hardening invariants: allowlist-by-advertisement (only commands the server
|
|
785
|
+
// declared) and the serverEditsAllowed window that gates server-driven
|
|
786
|
+
// applyEdit to the duration of an explicit call. Exported with an overridable
|
|
787
|
+
// `timeoutMs` for the #365 regression tests.
|
|
788
|
+
export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS) {
|
|
789
|
+
if (!isClientAlive(state)) {
|
|
790
|
+
return { executed: false, reason: "lsp client not alive" };
|
|
791
|
+
}
|
|
792
|
+
if (!state.advertisedCommands.has(command)) {
|
|
793
|
+
return {
|
|
794
|
+
executed: false,
|
|
795
|
+
reason: `command "${command}" is not advertised by the ${state.serverId} server`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
state.serverEditsAllowed += 1;
|
|
799
|
+
try {
|
|
800
|
+
let result;
|
|
801
|
+
try {
|
|
802
|
+
result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
|
|
803
|
+
command,
|
|
804
|
+
arguments: args ?? [],
|
|
805
|
+
}), timeoutMs);
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
// Generous backstop only: a timeout means the server is hung (or the
|
|
809
|
+
// command is running longer than the ceiling). Surface it honestly — the
|
|
810
|
+
// command may still be applying — instead of hanging the caller. Real
|
|
811
|
+
// (non-timeout) errors still propagate.
|
|
812
|
+
if (err instanceof Error && err.message.startsWith("Timeout after")) {
|
|
813
|
+
return {
|
|
814
|
+
executed: false,
|
|
815
|
+
reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
throw err;
|
|
819
|
+
}
|
|
820
|
+
return { executed: true, result };
|
|
821
|
+
}
|
|
822
|
+
finally {
|
|
823
|
+
state.serverEditsAllowed -= 1;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
746
826
|
async function resolveCodeActionBestEffort(state, action) {
|
|
747
827
|
if (!isClientAlive(state) || action.edit)
|
|
748
828
|
return action;
|
|
@@ -1009,25 +1089,7 @@ export async function createLSPClient(options) {
|
|
|
1009
1089
|
return state.rawCapabilityKeys ?? [];
|
|
1010
1090
|
},
|
|
1011
1091
|
async executeCommand(command, args) {
|
|
1012
|
-
|
|
1013
|
-
return { executed: false, reason: "lsp client not alive" };
|
|
1014
|
-
}
|
|
1015
|
-
// Hardening: allowlist-by-advertisement. Only commands the server
|
|
1016
|
-
// itself declared (static or dynamic) may run — no arbitrary strings.
|
|
1017
|
-
if (!state.advertisedCommands.has(command)) {
|
|
1018
|
-
return {
|
|
1019
|
-
executed: false,
|
|
1020
|
-
reason: `command "${command}" is not advertised by the ${state.serverId} server`,
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
state.serverEditsAllowed += 1;
|
|
1024
|
-
try {
|
|
1025
|
-
const result = await safeSendRequest(state.connection, "workspace/executeCommand", { command, arguments: args ?? [] });
|
|
1026
|
-
return { executed: true, result };
|
|
1027
|
-
}
|
|
1028
|
-
finally {
|
|
1029
|
-
state.serverEditsAllowed -= 1;
|
|
1030
|
-
}
|
|
1092
|
+
return runServerCommand(state, command, args);
|
|
1031
1093
|
},
|
|
1032
1094
|
get diagnosticsVersion() {
|
|
1033
1095
|
return state.diagnosticsVersion;
|
|
@@ -1091,14 +1153,22 @@ export async function createLSPClient(options) {
|
|
|
1091
1153
|
async workspaceSymbol(query) {
|
|
1092
1154
|
if (!isClientAlive(state))
|
|
1093
1155
|
return [];
|
|
1094
|
-
|
|
1156
|
+
// Route through navRequest for the shared withTimeout ceiling — a hung
|
|
1157
|
+
// server would otherwise await forever (safeSendRequest only settles on
|
|
1158
|
+
// a reply or a destroyed stream). No staleCheckPath: not single-file.
|
|
1159
|
+
const result = await navRequest(state, "workspace/symbol", {
|
|
1160
|
+
query,
|
|
1161
|
+
});
|
|
1095
1162
|
return result ?? [];
|
|
1096
1163
|
},
|
|
1097
1164
|
async codeAction(filePath, line, character, endLine, endCharacter) {
|
|
1098
1165
|
if (!isClientAlive(state))
|
|
1099
1166
|
return [];
|
|
1100
1167
|
const uri = pathToFileURL(filePath).href;
|
|
1101
|
-
|
|
1168
|
+
// navRequest adds the shared withTimeout ceiling + single-file
|
|
1169
|
+
// stale-drop (matches documentSymbol); a hung server no longer awaits
|
|
1170
|
+
// forever, and code actions computed against superseded content drop.
|
|
1171
|
+
const result = await navRequest(state, "textDocument/codeAction", {
|
|
1102
1172
|
textDocument: { uri },
|
|
1103
1173
|
range: {
|
|
1104
1174
|
start: await toWirePosition(state, filePath, line, character),
|
|
@@ -1107,7 +1177,7 @@ export async function createLSPClient(options) {
|
|
|
1107
1177
|
context: {
|
|
1108
1178
|
diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
|
|
1109
1179
|
},
|
|
1110
|
-
});
|
|
1180
|
+
}, filePath);
|
|
1111
1181
|
if (!result || !Array.isArray(result))
|
|
1112
1182
|
return [];
|
|
1113
1183
|
const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
|
|
@@ -1222,25 +1292,6 @@ function isStreamError(err) {
|
|
|
1222
1292
|
err.code === "EPIPE");
|
|
1223
1293
|
}
|
|
1224
1294
|
// Using shared path utilities from path-utils.ts
|
|
1225
|
-
async function withTimeout(promise, timeoutMs) {
|
|
1226
|
-
let timeout;
|
|
1227
|
-
// Suppress unhandled rejection if `promise` rejects AFTER the timeout
|
|
1228
|
-
// wins the race — Promise.race settles on the first result but the
|
|
1229
|
-
// losing promises still run, and any later rejection would be uncaught.
|
|
1230
|
-
promise.catch(() => { });
|
|
1231
|
-
try {
|
|
1232
|
-
return await Promise.race([
|
|
1233
|
-
promise,
|
|
1234
|
-
new Promise((_, reject) => {
|
|
1235
|
-
timeout = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
1236
|
-
}),
|
|
1237
|
-
]);
|
|
1238
|
-
}
|
|
1239
|
-
finally {
|
|
1240
|
-
if (timeout)
|
|
1241
|
-
clearTimeout(timeout);
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
1295
|
function positiveIntFromEnv(name, fallback) {
|
|
1245
1296
|
const raw = process.env[name];
|
|
1246
1297
|
if (!raw)
|
|
@@ -528,6 +528,16 @@ export class LSPService {
|
|
|
528
528
|
if (!spawned) {
|
|
529
529
|
logSessionStart(`lsp spawn ${server.id}: unavailable (${Date.now() - startedAt}ms)`);
|
|
530
530
|
recordLsp(server.id, root, "spawn_failed", Date.now() - startedAt);
|
|
531
|
+
// When installs are disabled, an unavailable binary is an expected
|
|
532
|
+
// policy outcome, not proof the server/root is broken. Cool down briefly
|
|
533
|
+
// to avoid hot-looping PATH probes, but do not count toward permanent
|
|
534
|
+
// disablement: a user may install or expose the binary on PATH during the
|
|
535
|
+
// same session and should not need a full LSP reset.
|
|
536
|
+
if (!allowInstall) {
|
|
537
|
+
logSessionStart(`lsp spawn ${server.id}: unavailable with install disabled; temporary cooldown only`);
|
|
538
|
+
this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
531
541
|
const uCount = (this.failureCounts.get(key) ?? 0) + 1;
|
|
532
542
|
this.failureCounts.set(key, uCount);
|
|
533
543
|
const uCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (uCount - 1), BROKEN_MAX_COOLDOWN_MS);
|
|
@@ -294,7 +294,7 @@ function trySpawn(command, args, cwd, env, needsShell) {
|
|
|
294
294
|
cwd,
|
|
295
295
|
env,
|
|
296
296
|
stdio: ["pipe", "pipe", "pipe"],
|
|
297
|
-
detached:
|
|
297
|
+
detached: !isWindows,
|
|
298
298
|
windowsHide: true,
|
|
299
299
|
shell: true,
|
|
300
300
|
});
|
|
@@ -305,7 +305,7 @@ function trySpawn(command, args, cwd, env, needsShell) {
|
|
|
305
305
|
cwd,
|
|
306
306
|
env,
|
|
307
307
|
stdio: ["pipe", "pipe", "pipe"],
|
|
308
|
-
detached:
|
|
308
|
+
detached: !isWindows,
|
|
309
309
|
windowsHide: isWindows,
|
|
310
310
|
});
|
|
311
311
|
}
|
|
@@ -696,20 +696,22 @@ async function findTsserverPath(root, allowInstall) {
|
|
|
696
696
|
/* not found */
|
|
697
697
|
}
|
|
698
698
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
699
|
+
// Discover the typescript install (PATH / npm-global) even when install is
|
|
700
|
+
// disabled; only the download is gated by allowInstall.
|
|
701
|
+
const tscPath = await ensureTool("typescript", {
|
|
702
|
+
allowInstall: canInstall(allowInstall),
|
|
703
|
+
});
|
|
704
|
+
if (tscPath) {
|
|
705
|
+
for (const p of [
|
|
706
|
+
path.join(path.dirname(tscPath), "..", "typescript", "lib", "tsserver.js"),
|
|
707
|
+
path.join(path.dirname(tscPath), "..", "..", "typescript", "lib", "tsserver.js"),
|
|
708
|
+
]) {
|
|
709
|
+
try {
|
|
710
|
+
await fs.access(p);
|
|
711
|
+
return p;
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
/* not found */
|
|
713
715
|
}
|
|
714
716
|
}
|
|
715
717
|
}
|
|
@@ -933,12 +935,16 @@ export const TypeScriptServer = {
|
|
|
933
935
|
/* not found */
|
|
934
936
|
}
|
|
935
937
|
}
|
|
936
|
-
// Fall back to
|
|
938
|
+
// Fall back to a discovered or managed install. ensureTool() runs PATH /
|
|
939
|
+
// npm-global discovery even when install is disabled (only the download is
|
|
940
|
+
// gated by canInstall), so a globally-installed typescript-language-server
|
|
941
|
+
// resolves even without a per-project node_modules/.bin entry.
|
|
937
942
|
if (!lspPath) {
|
|
938
|
-
|
|
939
|
-
|
|
943
|
+
lspPath = await ensureTool("typescript-language-server", {
|
|
944
|
+
allowInstall: canInstall(options?.allowInstall),
|
|
945
|
+
});
|
|
946
|
+
if (lspPath)
|
|
940
947
|
source = "managed";
|
|
941
|
-
}
|
|
942
948
|
if (!lspPath) {
|
|
943
949
|
return undefined;
|
|
944
950
|
}
|
|
@@ -1029,10 +1035,11 @@ export const PythonServer = {
|
|
|
1029
1035
|
initialization: pyrightInit(pythonPath),
|
|
1030
1036
|
};
|
|
1031
1037
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1038
|
+
// Discover a globally-installed pyright even when install is disabled;
|
|
1039
|
+
// only the download is gated by canInstall.
|
|
1040
|
+
const pyrightPath = await ensureTool("pyright", {
|
|
1041
|
+
allowInstall: canInstall(options?.allowInstall),
|
|
1042
|
+
});
|
|
1036
1043
|
if (!pyrightPath)
|
|
1037
1044
|
return undefined;
|
|
1038
1045
|
source = "managed";
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - OPT-IN: disabled by default (budget 0) until validated in a real pi session;
|
|
17
17
|
* enable with PI_LENS_MODULE_REPORT_LSP_BUDGET_MS.
|
|
18
18
|
*/
|
|
19
|
+
import { withinRemaining } from "./deadline-utils.js";
|
|
19
20
|
import { uriToPath } from "./path-utils.js";
|
|
20
21
|
let _lspBudgetMs;
|
|
21
22
|
let lspEnrichmentTail = Promise.resolve();
|
|
@@ -82,12 +83,6 @@ function lspLocationsToUsedBy(locs, cap) {
|
|
|
82
83
|
}
|
|
83
84
|
return out;
|
|
84
85
|
}
|
|
85
|
-
function sleep(ms) {
|
|
86
|
-
return new Promise((resolve) => {
|
|
87
|
-
const timer = setTimeout(resolve, Math.max(0, ms));
|
|
88
|
-
timer.unref?.();
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
86
|
async function runWithExclusiveLspSweep(work) {
|
|
92
87
|
const previous = lspEnrichmentTail.catch(() => undefined);
|
|
93
88
|
let release;
|
|
@@ -102,16 +97,6 @@ async function runWithExclusiveLspSweep(work) {
|
|
|
102
97
|
release();
|
|
103
98
|
}
|
|
104
99
|
}
|
|
105
|
-
/** Resolve `promise`, or `undefined` once the shared deadline passes. */
|
|
106
|
-
async function withinRemaining(promise, deadlineAt) {
|
|
107
|
-
const remaining = deadlineAt - Date.now();
|
|
108
|
-
if (remaining <= 0)
|
|
109
|
-
return undefined;
|
|
110
|
-
return Promise.race([
|
|
111
|
-
promise.catch(() => undefined),
|
|
112
|
-
sleep(remaining).then(() => undefined),
|
|
113
|
-
]);
|
|
114
|
-
}
|
|
115
100
|
/**
|
|
116
101
|
* Best-effort live-LSP enrichment for the requested file's exported symbols.
|
|
117
102
|
* On-demand: the first `references` call spawns/reuses the language server for
|
package/dist/clients/pipeline.js
CHANGED
|
@@ -36,49 +36,70 @@ const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
|
|
|
36
36
|
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
|
|
37
37
|
const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
|
|
38
38
|
const AUTOFIX_CHANGED_FILE_SCAN_LIMIT = 5000;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
// Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
|
|
40
|
+
// `stack`. Extracted from the walk loop to keep each function's cognitive
|
|
41
|
+
// complexity low. Excluded/ignored dirs are not descended; ignored/vanished
|
|
42
|
+
// files are skipped.
|
|
43
|
+
// Files stat'd between event-loop yields. The walk stays on the tool_result
|
|
44
|
+
// hot path; yielding every N keeps its longest synchronous stretch well under
|
|
45
|
+
// the <50ms hook-burst budget even at the AUTOFIX_CHANGED_FILE_SCAN_LIMIT cap.
|
|
46
|
+
const SNAPSHOT_YIELD_EVERY = 500;
|
|
47
|
+
// Scan one directory's entries into `snapshot`, pushing walkable subdirs onto
|
|
48
|
+
// `stack`. Yields to the event loop every SNAPSHOT_YIELD_EVERY files (shared
|
|
49
|
+
// `counter`) so a single huge directory can't hold the loop. Excluded/ignored
|
|
50
|
+
// dirs are not descended; ignored/vanished files are skipped.
|
|
51
|
+
async function snapshotDirInto(dir, ignoreMatcher, stack, snapshot, counter) {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = nodeFs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const fullPath = path.join(dir, entry.name);
|
|
61
|
+
if (entry.isDirectory()) {
|
|
62
|
+
if (!isExcludedDirName(entry.name) &&
|
|
63
|
+
!ignoreMatcher.isIgnored(fullPath, true)) {
|
|
64
|
+
stack.push(fullPath);
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!entry.isFile())
|
|
69
|
+
continue;
|
|
70
|
+
if (ignoreMatcher.isIgnored(fullPath, false))
|
|
71
|
+
continue;
|
|
47
72
|
try {
|
|
48
|
-
|
|
73
|
+
const stat = nodeFs.statSync(fullPath);
|
|
74
|
+
snapshot.set(path.resolve(fullPath), {
|
|
75
|
+
mtimeMs: stat.mtimeMs,
|
|
76
|
+
size: stat.size,
|
|
77
|
+
});
|
|
49
78
|
}
|
|
50
79
|
catch {
|
|
51
|
-
|
|
80
|
+
// ignore vanished files
|
|
52
81
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (entry.isDirectory()) {
|
|
56
|
-
if (!isExcludedDirName(entry.name) &&
|
|
57
|
-
!ignoreMatcher.isIgnored(fullPath, true)) {
|
|
58
|
-
stack.push(fullPath);
|
|
59
|
-
}
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
if (!entry.isFile())
|
|
63
|
-
continue;
|
|
64
|
-
if (ignoreMatcher.isIgnored(fullPath, false))
|
|
65
|
-
continue;
|
|
66
|
-
try {
|
|
67
|
-
const stat = nodeFs.statSync(fullPath);
|
|
68
|
-
snapshot.set(path.resolve(fullPath), {
|
|
69
|
-
mtimeMs: stat.mtimeMs,
|
|
70
|
-
size: stat.size,
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// ignore vanished files
|
|
75
|
-
}
|
|
82
|
+
if (++counter.n % SNAPSHOT_YIELD_EVERY === 0) {
|
|
83
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
76
84
|
}
|
|
77
85
|
}
|
|
86
|
+
}
|
|
87
|
+
// Exported for the event-loop occupancy guard (#361/#368): an O(files) walk on
|
|
88
|
+
// the tool_result autofix path, bounded by AUTOFIX_CHANGED_FILE_SCAN_LIMIT and
|
|
89
|
+
// chunk-yielding every SNAPSHOT_YIELD_EVERY files so it never blocks the TUI.
|
|
90
|
+
export async function snapshotProjectFiles(root) {
|
|
91
|
+
const snapshot = new Map();
|
|
92
|
+
const projectRoot = path.resolve(root);
|
|
93
|
+
const ignoreMatcher = getProjectIgnoreMatcher(projectRoot);
|
|
94
|
+
const stack = [projectRoot];
|
|
95
|
+
const counter = { n: 0 };
|
|
96
|
+
while (stack.length > 0 && snapshot.size < AUTOFIX_CHANGED_FILE_SCAN_LIMIT) {
|
|
97
|
+
await snapshotDirInto(stack.pop(), ignoreMatcher, stack, snapshot, counter);
|
|
98
|
+
}
|
|
78
99
|
return snapshot;
|
|
79
100
|
}
|
|
80
|
-
function diffProjectSnapshot(root, before) {
|
|
81
|
-
const after = snapshotProjectFiles(root);
|
|
101
|
+
async function diffProjectSnapshot(root, before) {
|
|
102
|
+
const after = await snapshotProjectFiles(root);
|
|
82
103
|
const changed = new Set();
|
|
83
104
|
for (const [filePath, next] of after) {
|
|
84
105
|
const prev = before.get(filePath);
|
|
@@ -281,7 +302,7 @@ async function tryRustClippyFix(filePath) {
|
|
|
281
302
|
]);
|
|
282
303
|
if (!cargoDir)
|
|
283
304
|
return [];
|
|
284
|
-
const before = snapshotProjectFiles(cargoDir);
|
|
305
|
+
const before = await snapshotProjectFiles(cargoDir);
|
|
285
306
|
const result = await safeSpawnAsync("cargo", ["clippy", "--fix", "--allow-dirty", "--allow-staged", "-q"], { timeout: 30000, cwd: cargoDir });
|
|
286
307
|
if (result.error || result.status !== 0)
|
|
287
308
|
return [];
|
|
@@ -294,7 +315,7 @@ async function tryDartFix(filePath) {
|
|
|
294
315
|
const pubspecDir = findNearestContaining(path.dirname(path.resolve(filePath)), ["pubspec.yaml"]);
|
|
295
316
|
if (!pubspecDir)
|
|
296
317
|
return [];
|
|
297
|
-
const before = snapshotProjectFiles(pubspecDir);
|
|
318
|
+
const before = await snapshotProjectFiles(pubspecDir);
|
|
298
319
|
const result = await safeSpawnAsync("dart", ["fix", "--apply"], {
|
|
299
320
|
timeout: 30000,
|
|
300
321
|
cwd: pubspecDir,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* within the symbol pass without requiring the agent to have read every line.
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
|
+
import { withBudget } from "./deadline-utils.js";
|
|
10
11
|
/** Only expand reads smaller than this (lines). Larger reads don't benefit. */
|
|
11
12
|
export const EXPANSION_LIMIT_LINES = 100;
|
|
12
13
|
/** Don't expand to a symbol larger than this. */
|
|
@@ -161,20 +162,6 @@ function buildAncestryChain(node, types) {
|
|
|
161
162
|
}
|
|
162
163
|
return chain.reverse(); // outermost first
|
|
163
164
|
}
|
|
164
|
-
function withBudget(promise, budgetMs) {
|
|
165
|
-
if (budgetMs <= 0)
|
|
166
|
-
return Promise.resolve(undefined);
|
|
167
|
-
let t;
|
|
168
|
-
return Promise.race([
|
|
169
|
-
promise,
|
|
170
|
-
new Promise((resolve) => {
|
|
171
|
-
t = setTimeout(() => resolve(undefined), budgetMs);
|
|
172
|
-
}),
|
|
173
|
-
]).finally(() => {
|
|
174
|
-
if (t)
|
|
175
|
-
clearTimeout(t);
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
165
|
function tryExpandMarkdownSection(content, requestedStartRow, requestedLimit, totalLines) {
|
|
179
166
|
const lines = content.split(/\r?\n/);
|
|
180
167
|
const lastRow = totalLines - 1;
|
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
id: incomplete-string-escaping-js
|
|
2
|
+
valid:
|
|
3
|
+
- |
|
|
4
|
+
const escaped = s.replace(/\\/g, "\\\\");
|
|
5
|
+
- |
|
|
6
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
7
|
+
- |
|
|
8
|
+
const html = s.replace(/&/g, "&");
|
|
9
|
+
- |
|
|
10
|
+
const literal = s.replace("|", "\\|");
|
|
11
|
+
- |
|
|
12
|
+
const printable = s.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
13
|
+
- |
|
|
14
|
+
const escaped = s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
invalid:
|
|
16
|
+
- |
|
|
17
|
+
const markdown = s.replace(/\|/g, "\\|");
|
|
18
|
+
- |
|
|
19
|
+
const quoted = s.replace(/"/g, '\\"');
|
|
20
|
+
- |
|
|
21
|
+
const templated = s.replace(/'/g, `\\'`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
id: incomplete-string-escaping
|
|
2
|
+
valid:
|
|
3
|
+
- |
|
|
4
|
+
const escaped = s.replace(/\\/g, "\\\\");
|
|
5
|
+
- |
|
|
6
|
+
const escaped = s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
7
|
+
- |
|
|
8
|
+
const html = s.replace(/&/g, "&");
|
|
9
|
+
- |
|
|
10
|
+
const literal = s.replace("|", "\\|");
|
|
11
|
+
- |
|
|
12
|
+
const printable = s.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
|
|
13
|
+
- |
|
|
14
|
+
const escaped = s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
invalid:
|
|
16
|
+
- |
|
|
17
|
+
const markdown = s.replace(/\|/g, "\\|");
|
|
18
|
+
- |
|
|
19
|
+
const quoted = s.replace(/"/g, '\\"');
|
|
20
|
+
- |
|
|
21
|
+
const templated = s.replace(/'/g, `\\'`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: incomplete-string-escaping-js
|
|
2
|
+
language: JavaScript
|
|
3
|
+
message: "Regex replace escapes with a backslash before escaping backslashes"
|
|
4
|
+
severity: warning
|
|
5
|
+
note: |
|
|
6
|
+
Escaping a delimiter with a backslash is incomplete if existing backslashes
|
|
7
|
+
are not escaped first. Escape `\\` before escaping the target character, e.g.
|
|
8
|
+
`s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|")`.
|
|
9
|
+
rule:
|
|
10
|
+
pattern: $S.replace($RE, $REPL)
|
|
11
|
+
constraints:
|
|
12
|
+
RE:
|
|
13
|
+
kind: regex
|
|
14
|
+
REPL:
|
|
15
|
+
regex: >-
|
|
16
|
+
^["'`]\\\\[^\\A-Za-z0-9$]
|
|
17
|
+
S:
|
|
18
|
+
not:
|
|
19
|
+
kind: call_expression
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: incomplete-string-escaping
|
|
2
|
+
language: TypeScript
|
|
3
|
+
message: "Regex replace escapes with a backslash before escaping backslashes"
|
|
4
|
+
severity: warning
|
|
5
|
+
note: |
|
|
6
|
+
Escaping a delimiter with a backslash is incomplete if existing backslashes
|
|
7
|
+
are not escaped first. Escape `\\` before escaping the target character, e.g.
|
|
8
|
+
`s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|")`.
|
|
9
|
+
rule:
|
|
10
|
+
pattern: $S.replace($RE, $REPL)
|
|
11
|
+
constraints:
|
|
12
|
+
RE:
|
|
13
|
+
kind: regex
|
|
14
|
+
REPL:
|
|
15
|
+
regex: >-
|
|
16
|
+
^["'`]\\\\[^\\A-Za-z0-9$]
|
|
17
|
+
S:
|
|
18
|
+
not:
|
|
19
|
+
kind: call_expression
|
|
@@ -124,31 +124,33 @@ The parser is a real YAML parser, so unquoted special chars throw and the rule i
|
|
|
124
124
|
(NAPI evaluates `regex` with JS RegExp on node.text() — keep it LINEAR so the
|
|
125
125
|
detector can't itself ReDoS.)
|
|
126
126
|
|
|
127
|
-
⚠
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
127
|
+
⚠ String-literal regexes match SOURCE text, not the runtime string value.
|
|
128
|
+
Inspect the exact node text before writing constraints:
|
|
129
|
+
ast-grep run --kind string --lang ts sample.ts --json=compact
|
|
130
|
+
Example: source `"\\|"` is node text `"\\\\|"` in JSON; to match a
|
|
131
|
+
source-level escaped backslash (`\\`) followed by a non-backslash, the rule
|
|
132
|
+
regex needs FOUR regex backslashes, preferably in a YAML block scalar:
|
|
133
|
+
regex: >-
|
|
134
|
+
^["'`]\\\\[^\\A-Za-z0-9$]
|
|
135
|
+
This is how `incomplete-string-escaping` catches both `"\\|"` and
|
|
136
|
+
`'\\"'`. Avoid shell here-doc probes for this class — shell/JSON escaping
|
|
137
|
+
can silently eat a backslash and make the rule look broken.
|
|
138
|
+
|
|
139
|
+
⚠ `-js` twins: remember there are TWO execution surfaces.
|
|
140
|
+
- ast-grep CLI/LSP language-gates by `language:`. A `language: TypeScript`
|
|
141
|
+
rule is not enough for standalone `.js` coverage, so shipped user-facing
|
|
142
|
+
TS/JS rules that should fire under the ast-grep LSP usually need a `-js`
|
|
143
|
+
twin with `language: JavaScript` plus its own fixture.
|
|
144
|
+
- the in-process NAPI fallback (`ast-grep-napi.ts`) parses the target file's
|
|
145
|
+
own grammar and currently runs both TS and JS rules on every jsts file. A
|
|
146
|
+
grammar-agnostic twin can therefore duplicate in fallback mode.
|
|
147
|
+
- **Decide explicitly:** if the rule must cover `.js` through the ast-grep
|
|
148
|
+
CLI/LSP baseline, ship the twin and test both. If a rule is NAPI-only or
|
|
149
|
+
fallback duplication is unacceptable, fix runner dedup/normalization before
|
|
150
|
+
relying on a single TypeScript rule for JS coverage.
|
|
151
|
+
- **Grammar-divergent bodies** still need separate variants regardless:
|
|
152
|
+
e.g. `no-flag-argument` uses `required_parameter` in TS and
|
|
153
|
+
`assignment_pattern` in JS.
|
|
152
154
|
|
|
153
155
|
✅ Node-kind facts (tree-sitter-typescript grammar — NOT the TS compiler / Roslyn):
|
|
154
156
|
- let / const → `lexical_declaration` (var is NOT here)
|
|
@@ -216,13 +218,21 @@ The parser is a real YAML parser, so unquoted special chars throw and the rule i
|
|
|
216
218
|
## Validating a candidate rule against the REAL engine (not the warm MCP cache)
|
|
217
219
|
|
|
218
220
|
```
|
|
221
|
+
|
|
219
222
|
# inspect how a PATTERN parses → find the node kind you actually need
|
|
223
|
+
|
|
220
224
|
ast-grep run -p 'x = false' --lang ts --debug-query=cst file.ts
|
|
225
|
+
|
|
221
226
|
# match by kind (──kind and ──pattern are mutually exclusive in `run`)
|
|
227
|
+
|
|
222
228
|
ast-grep run --kind required_parameter --lang ts file.ts
|
|
229
|
+
|
|
223
230
|
# run ONE rule from an sgconfig against a sample
|
|
231
|
+
|
|
224
232
|
ast-grep scan -c <sgconfig.yml> --filter '^<id>$' sample.ts
|
|
233
|
+
|
|
225
234
|
# run the fixture harness for one rule
|
|
235
|
+
|
|
226
236
|
ast-grep test -c rules/ast-grep-rules/.sgconfig.yml --skip-snapshot-tests --filter '<id>'
|
|
227
|
-
|
|
237
|
+
|
|
228
238
|
```
|