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 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
- const inFlight = ensureInFlight.get(toolId);
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(toolId, ensurePromise);
2664
+ ensureInFlight.set(inFlightKey, ensurePromise);
2641
2665
  try {
2642
2666
  return await ensurePromise;
2643
2667
  }
2644
2668
  finally {
2645
- ensureInFlight.delete(toolId);
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
- proc.kill("SIGTERM");
216
+ if (!killPosixProcessGroup("SIGTERM")) {
217
+ killDirectChild("SIGTERM");
218
+ }
184
219
  if (options.fast) {
185
220
  const timer = setTimeout(() => {
186
- try {
187
- if (!proc.killed) {
188
- proc.kill("SIGKILL");
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
- try {
203
- if (!proc.killed) {
204
- proc.kill("SIGKILL");
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
- const report = await safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } });
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
- async function navRequest(state, method, params,
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), NAV_REQUEST_TIMEOUT_MS).catch((err) => {
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
- if (!isClientAlive(state)) {
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
- const result = await safeSendRequest(connection, "workspace/symbol", { query });
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
- const result = await safeSendRequest(connection, "textDocument/codeAction", {
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: false,
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: false,
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
- if (canInstall(allowInstall)) {
700
- const tscPath = await ensureTool("typescript");
701
- if (tscPath) {
702
- for (const p of [
703
- path.join(path.dirname(tscPath), "..", "typescript", "lib", "tsserver.js"),
704
- path.join(path.dirname(tscPath), "..", "..", "typescript", "lib", "tsserver.js"),
705
- ]) {
706
- try {
707
- await fs.access(p);
708
- return p;
709
- }
710
- catch {
711
- /* not found */
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 auto-installed version
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
- if (canInstall(options?.allowInstall)) {
939
- lspPath = await ensureTool("typescript-language-server");
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
- if (!canInstall(options?.allowInstall)) {
1033
- return undefined;
1034
- }
1035
- const pyrightPath = await ensureTool("pyright");
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
@@ -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
- function snapshotProjectFiles(root) {
40
- const snapshot = new Map();
41
- const projectRoot = path.resolve(root);
42
- const ignoreMatcher = getProjectIgnoreMatcher(projectRoot);
43
- const stack = [projectRoot];
44
- while (stack.length > 0 && snapshot.size < AUTOFIX_CHANGED_FILE_SCAN_LIMIT) {
45
- const dir = stack.pop();
46
- let entries;
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
- entries = nodeFs.readdirSync(dir, { withFileTypes: true });
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
- continue;
80
+ // ignore vanished files
52
81
  }
53
- for (const entry of entries) {
54
- const fullPath = path.join(dir, entry.name);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.62",
3
+ "version": "3.8.63",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {
@@ -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, "&amp;");
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, "&amp;");
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
- `-js` twins: ship ONE rule for a grammar-AGNOSTIC body, TWO for a grammar-DIVERGENT one.
128
- The runner (`ast-grep-napi.ts`) runs EVERY ts/js rule on every jsts file — it only
129
- language-gates NON-ts/js rules (`lang !== "typescript" && lang !== "javascript"`). So
130
- whether a twin duplicates depends on whether its BODY matches the same nodes:
131
- - **Grammar-agnostic body** (patterns / common kinds that parse identically in the
132
- tree-sitter `typescript` and `javascript` grammars `member_expression`,
133
- `call_expression`, `lexical_declaration`, ternary, etc.): one `language: TypeScript`
134
- rule already covers .js in the runner. A `-js` twin DUPLICATES (both fire on the same
135
- node; dedup is by rule id, not finding). Shipped offenders: `prefer-at` + `prefer-at-js`,
136
- `strict-equality` + `-js`consolidate.
137
- - **Grammar-divergent body** (node kinds that DIFFER between the two grammars): a single
138
- rule MISSES the other grammar's form, so you NEED both twins — and they do NOT
139
- duplicate, because each rule's kinds only exist in its own grammar. Example
140
- (`no-flag-argument`, #305): a default parameter is `required_parameter` in the TS
141
- grammar but `assignment_pattern` in the JS grammar; the TS rule gives 0 matches on
142
- .js and the JS rule gives 0 on .ts. Other TS-only nodes: `optional_parameter`, type
143
- annotations, `type_alias_declaration`, `enum_declaration`, `as`/satisfies expressions.
144
- - **Decide:** does the rule name a node KIND that's specific to one grammar? Twin.
145
- Pure pattern / shared kinds? Single rule.
146
- - **Verify no dup before shipping a twin:** copy the twin, set its `language:` to the
147
- base's, run `ast-grep scan` on a base-grammar file — `>0` matches = it duplicates
148
- (drop the twin); `0` = grammar-divergent and safe (keep both).
149
- - **CLI runner trap:** `ast-grep scan`/`test` GATE by the rule's `language:` (a TS rule
150
- scans only .ts), so a TS rule shows 0 on a .js file in the CLI — but the RUNNER runs
151
- it. Don't conclude "need a twin" from CLI gating; reason about the grammar instead.
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
  ```