pi-lens 3.8.38 → 3.8.40

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.
Files changed (106) hide show
  1. package/CHANGELOG.md +51 -5
  2. package/README.md +1 -1
  3. package/clients/biome-client.ts +5 -4
  4. package/clients/cache-manager.ts +4 -1
  5. package/clients/dispatch/fact-scheduler.ts +2 -1
  6. package/clients/dispatch/fact-store.ts +4 -6
  7. package/clients/dispatch/integration.ts +4 -5
  8. package/clients/dispatch/rules/quality-rules.ts +10 -15
  9. package/clients/dispatch/runners/biome-check.ts +1 -1
  10. package/clients/dispatch/runners/eslint.ts +1 -1
  11. package/clients/dispatch/runners/lsp.ts +1 -5
  12. package/clients/dispatch/runners/psscriptanalyzer.ts +1 -1
  13. package/clients/dispatch/runners/similarity.ts +1 -4
  14. package/clients/dispatch/utils/lsp-diagnostics.ts +3 -9
  15. package/clients/formatters.ts +12 -7
  16. package/clients/lsp/client.ts +62 -27
  17. package/clients/lsp/index.ts +20 -11
  18. package/clients/lsp/launch.ts +108 -38
  19. package/clients/lsp/server.ts +77 -58
  20. package/clients/pipeline.ts +20 -9
  21. package/clients/read-guard-tool-lines.ts +15 -2
  22. package/clients/read-guard.ts +51 -32
  23. package/clients/runtime-coordinator.ts +2 -2
  24. package/clients/runtime-session.ts +2 -0
  25. package/clients/runtime-tool-result.ts +21 -0
  26. package/clients/secrets-scanner.ts +1 -1
  27. package/clients/session-summary.ts +1 -1
  28. package/clients/tool-policy.ts +1982 -1936
  29. package/clients/tree-sitter-query-loader.ts +3 -2
  30. package/commands/booboo.ts +48 -15
  31. package/index.ts +35 -13
  32. package/package.json +2 -2
  33. package/rules/rule-catalog.json +76 -1
  34. package/rules/tree-sitter-queries/abap/delete-where.yml +46 -0
  35. package/rules/tree-sitter-queries/c/case-range-multiple-values.yml +65 -0
  36. package/rules/tree-sitter-queries/c/goto-into-block.yml +72 -0
  37. package/rules/tree-sitter-queries/c/goto-label-order.yml +65 -0
  38. package/rules/tree-sitter-queries/cobol/alter-statement.yml +39 -0
  39. package/rules/tree-sitter-queries/cobol/lock-table-cobol.yml +35 -0
  40. package/rules/tree-sitter-queries/cpp/no-auto-ptr.yml +49 -0
  41. package/rules/tree-sitter-queries/cpp/no-confused-move-forward.yml +59 -0
  42. package/rules/tree-sitter-queries/cpp/no-memset-sensitive-data.yml +57 -0
  43. package/rules/tree-sitter-queries/cpp/no-scoped-lock-without-args.yml +52 -0
  44. package/rules/tree-sitter-queries/cpp/noexcept-functions.yml +58 -0
  45. package/rules/tree-sitter-queries/cpp/unnecessary-bit-ops.yml +58 -0
  46. package/rules/tree-sitter-queries/csharp/async-await-identifiers.yml +50 -0
  47. package/rules/tree-sitter-queries/csharp/is-with-this.yml +52 -0
  48. package/rules/tree-sitter-queries/csharp/no-dangerous-get-handle.yml +59 -0
  49. package/rules/tree-sitter-queries/csharp/no-operator-eq-reference.yml +60 -0
  50. package/rules/tree-sitter-queries/csharp/no-thread-resume-suspend.yml +60 -0
  51. package/rules/tree-sitter-queries/css/calc-spacing.yml +50 -0
  52. package/rules/tree-sitter-queries/java/infinite-loop.yml +58 -0
  53. package/rules/tree-sitter-queries/java/infinite-recursion.yml +58 -0
  54. package/rules/tree-sitter-queries/java/junit-call-super.yml +63 -0
  55. package/rules/tree-sitter-queries/java/main-should-not-throw.yml +63 -0
  56. package/rules/tree-sitter-queries/java/mockito-initialized.yml +66 -0
  57. package/rules/tree-sitter-queries/java/name-capitalization-conflict.yml +54 -0
  58. package/rules/tree-sitter-queries/java/no-clone-override.yml +60 -0
  59. package/rules/tree-sitter-queries/java/no-double-checked-locking.yml +74 -0
  60. package/rules/tree-sitter-queries/java/no-exit-methods.yml +50 -0
  61. package/rules/tree-sitter-queries/java/no-field-shadowing.yml +66 -0
  62. package/rules/tree-sitter-queries/java/no-future-keywords.yml +49 -0
  63. package/rules/tree-sitter-queries/java/no-octal-values.yml +48 -0
  64. package/rules/tree-sitter-queries/java/no-threadgroup.yml +52 -0
  65. package/rules/tree-sitter-queries/java/no-threads-in-constructors.yml +73 -0
  66. package/rules/tree-sitter-queries/java/no-wait-notify-on-thread.yml +58 -0
  67. package/rules/tree-sitter-queries/java/prepared-statement-valid-indices.yml +55 -0
  68. package/rules/tree-sitter-queries/java/resources-closed.yml +57 -0
  69. package/rules/tree-sitter-queries/java/short-circuit-logic.yml +57 -0
  70. package/rules/tree-sitter-queries/java/spring-session-attributes-setcomplete.yml +75 -0
  71. package/rules/tree-sitter-queries/java/springboot-default-package.yml +69 -0
  72. package/rules/tree-sitter-queries/java/switch-fall-through.yml +70 -0
  73. package/rules/tree-sitter-queries/java/switch-non-case-labels.yml +62 -0
  74. package/rules/tree-sitter-queries/java/tests-include-assertions.yml +60 -0
  75. package/rules/tree-sitter-queries/java/unnecessary-bit-ops-java.yml +57 -0
  76. package/rules/tree-sitter-queries/javascript/switch-case-termination-js.yml +64 -0
  77. package/rules/tree-sitter-queries/javascript/switch-non-case-labels.yml +52 -0
  78. package/rules/tree-sitter-queries/kotlin/prepared-statement-indices.yml +49 -0
  79. package/rules/tree-sitter-queries/php/no-exit-die.yml +52 -0
  80. package/rules/tree-sitter-queries/php/this-in-static-context.yml +75 -0
  81. package/rules/tree-sitter-queries/plsql/delete-update-where.yml +56 -0
  82. package/rules/tree-sitter-queries/plsql/end-loop-semicolon.yml +51 -0
  83. package/rules/tree-sitter-queries/plsql/fetch-bulk-collect-limit.yml +47 -0
  84. package/rules/tree-sitter-queries/plsql/forallsave-exceptions.yml +51 -0
  85. package/rules/tree-sitter-queries/plsql/lock-table.yml +42 -0
  86. package/rules/tree-sitter-queries/plsql/nchar-nvarchar2-bytes.yml +54 -0
  87. package/rules/tree-sitter-queries/plsql/no-synchronize.yml +47 -0
  88. package/rules/tree-sitter-queries/plsql/not-null-initialization.yml +59 -0
  89. package/rules/tree-sitter-queries/plsql/raise-application-error-codes.yml +50 -0
  90. package/rules/tree-sitter-queries/python/exit-signature-check.yml +66 -0
  91. package/rules/tree-sitter-queries/python/in-operator-unsupported.yml +57 -0
  92. package/rules/tree-sitter-queries/python/iter-return-iterator.yml +59 -0
  93. package/rules/tree-sitter-queries/python/no-super-torchscript.yml +52 -0
  94. package/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml +67 -0
  95. package/rules/tree-sitter-queries/python/return-in-generator.yml +58 -0
  96. package/rules/tree-sitter-queries/python/return-in-init.yml +59 -0
  97. package/rules/tree-sitter-queries/python/send-file-mimetype.yml +53 -0
  98. package/rules/tree-sitter-queries/python/yield-return-outside-function.yml +57 -0
  99. package/rules/tree-sitter-queries/typescript/default-not-last.yml +54 -0
  100. package/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +51 -0
  101. package/rules/tree-sitter-queries/typescript/empty-switch-case.yml +54 -0
  102. package/rules/tree-sitter-queries/typescript/infinite-loop.yml +55 -0
  103. package/rules/tree-sitter-queries/typescript/self-assignment.yml +46 -0
  104. package/rules/tree-sitter-queries/typescript/switch-case-termination.yml +64 -0
  105. package/tools/lsp-navigation.js +14 -16
  106. package/tools/lsp-navigation.ts +14 -18
package/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.40] - 2026-05-04
8
+
9
+ ### Added
10
+
11
+ - **60+ SonarCloud BLOCKER tree-sitter rules** — comprehensive BLOCKER severity rules across 13 languages:
12
+ - **Java (11 rules)**: no-exit-methods, no-threads-in-constructors, switch-fall-through, no-wait-notify-on-thread, no-double-checked-locking, no-future-keywords, no-field-shadowing, junit-call-super, no-octal-values, short-circuit-logic, infinite-loop, infinite-recursion, name-capitalization-conflict, mockito-initialized, resources-closed, unnecessary-bit-ops-java
13
+ - **TypeScript (5 rules)**: infinite-loop, self-assignment, duplicate-function-arg, empty-switch-case, default-not-last, switch-case-termination
14
+ - **JavaScript (1 rule)**: switch-case-termination-js (replaces switch-fall-through-js)
15
+ - **PL/SQL (7 rules)**: forallsave-exceptions, not-null-initialization, end-loop-semicolon, raise-application-error-codes, no-synchronize, lock-table, nchar-nvarchar2-bytes, delete-update-where, fetch-bulk-collect-limit
16
+ - **Python (8 rules)**: send-file-mimetype, no-super-torchscript, return-in-init, yield-return-outside-function, notimplemented-boolean-context, exit-signature-check, return-in-generator, iter-return-iterator, in-operator-unsupported
17
+ - **C++ (5 rules)**: unnecessary-bit-ops, noexcept-functions, no-auto-ptr, no-memset-sensitive-data, no-scoped-lock-without-args, no-confused-move-forward
18
+ - **PHP (2 rules)**: this-in-static-context, no-exit-die
19
+ - **C (3 rules)**: case-range-multiple-values, goto-label-order, goto-into-block
20
+ - **C# (5 rules)**: is-with-this, no-operator-eq-reference, no-dangerous-get-handle, no-thread-resume-suspend, async-await-identifiers
21
+ - **Kotlin (1 rule)**: prepared-statement-indices
22
+ - **ABAP (1 rule)**: delete-where
23
+ - **COBOL (2 rules)**: alter-statement, lock-table-cobol
24
+ - **CSS (1 rule)**: calc-spacing
25
+ - **rule-catalog.json** updated with all 60+ new rule registrations
26
+
27
+ ### Fixed
28
+
29
+ - **Read-guard: false `file_modified` blocks after own edits** — `ReadGuard` was blocking the second edit to a file because the model's first write changed the file's mtime, making `FileTime.hasChanged()` return `true` on the next `checkEdit`. Added `recordWritten(filePath)` to `ReadGuard` and wired it into the `tool_result` handler (post-write, file already on disk), so the FileTime stamp stays in sync with the model's own writes. Eliminates the spurious `file_modified` blocks that appeared on every multi-edit file in a session.
30
+
31
+ - **LSP: parallel-turn root-resolution timeouts** — `NearestRoot` performed a fresh `fs.stat` directory walk on every call with no caching. When Claude Code edited multiple files simultaneously (e.g. a 4-file turn), all pipelines raced `NearestRoot` concurrently, saturating Windows filesystem I/O and triggering the 750ms `lsp_client_wait_timeout` on all but the first. `NearestRoot` now maintains per-instance result and in-flight caches keyed by resolved directory: successful roots are cached for the session lifetime; concurrent calls for the same directory share one walk promise. Only successful roots are cached so a `package.json` created mid-session is still detected on the next call.
32
+
33
+ - **Memory: `lastAnalyzedStateByFile` cleared each turn** — module-level Map in `runtime-tool-result.ts` accumulated dead entries across turns (entries from previous turns can never match the new `turnIndex`). Now cleared at `turn_start` alongside `runtime.beginTurn()`, keeping the map bounded to files touched in the current turn only. (refs #50)
34
+ - **Memory: `recentTouches` stale entry eviction** — `LSPService.recentTouches` grew unboundedly across a session with one entry per unique file path. Entries older than `TOUCH_DEBOUNCE_MS` are already ignored by `shouldSkipTouch`; a threshold-based sweep (triggered when size > 200) now removes them. (refs #50)
35
+ - **Memory: orphaned LSP child processes on Windows** — `clientShutdown` only called `process.kill()` which on Windows terminates the direct child but leaves grandchildren (e.g. `tsserver.js`) as orphaned OS processes each holding 300–600MB. Both the normal shutdown and crash paths now go through a shared `killProcessTree` helper: on Windows it runs `taskkill /F /T` via absolute `SystemRoot` path and awaits completion before returning; on other platforms it sends `SIGTERM`. The SIGKILL fallback timer is also skipped on Windows since `taskkill /F` already force-terminates. (refs #50)
36
+ - **Memory: file-time session state not cleared on session reset** — `clearAllSessions()` from `file-time.ts` is now called during `handleSessionStart`, clearing stale file timestamp state that previously accumulated across session switches. (refs #50)
37
+ - **Memory: pending ast-grep warn timers not cancelled on session reset** — `resetDispatchBaselines()` left active `astGrepWarnDebounceTimers` running into a cleared session context. Now explicitly cancelled and cleared on reset. (refs #50)
38
+ - **Security: `taskkill` spawned via absolute path** — both the normal shutdown and crash paths now resolve `taskkill.exe` through `process.env.SystemRoot` instead of relying on PATH, eliminating the SonarCloud PATH-injection hotspot.
39
+ - **LSP: shutdown cannot hang indefinitely** — `client.shutdown()` now bounds the graceful `shutdown` request and proceeds to `exit`/process-tree kill if a server stops responding.
40
+ - **LSP: test cleanup stop helper hardened on Windows** — `stopLSP()` now uses the absolute `taskkill.exe` path, handles already-exited processes, and avoids orphaning grandchildren by killing the process tree before the direct child on Windows.
41
+
42
+ - **booboo project root detection** — `resolveProjectRoot` now walks up to the nearest ancestor with a root marker (`package.json`, `tsconfig.json`, `.git`, etc.), then falls back to walking down one level if exactly one immediate subdirectory has a root marker. Fixes scans running against the wrong directory in nested-project layouts (e.g. `pi-models/pi-models/`).
43
+
44
+ - **Switch-case false positives eliminated** — replaced naive `switch-fall-through` rules with `switch-case-termination` rules that properly recognize `return`, `throw`, and `continue` as valid case terminators. Reduced false positive hits from 174 to 0.
45
+ - **Self-assignment false positives fixed** — changed from `post_filter: same_identifier` to inline `#eq?` predicate so `wave = nextWave` is no longer flagged as self-assignment
46
+
47
+ ## [3.8.39] - 2026-05-02
48
+
49
+ ### Fixed
50
+
51
+ - **Context injection now prepends guidance before the user prompt** — pi-lens previously appended session guidance after the user's message; provider bridges that treat the last message as the active user action would demote the real request. Guidance is now prepended so the user's prompt stays last. (PR #48 by @tifandotme)
52
+ - **jscpd no longer runs on YAML/JSON/Markdown files** — `getFilesForJscpd` now filters to source code extensions only, preventing multi-second delays at `turn_end` when editing rule YAMLs or config files.
53
+ - **ReDoS S5852 final (gleam/zig parsers)** — rewrote `gleamRe` and `zigRe` as line-by-line parsers, eliminating the multiline flag that SonarCloud continued to flag despite `[ \t]*` substitution.
54
+ - **SonarCloud MAJOR code smells (batch 1 & 2)** — `readonly` members, `void` operator removals, nested ternaries, nested template literals, optional chains, duplicate branches, and redundant type alias across 15+ files.
55
+ - **Type-narrow `severityMap` for `Diagnostic.severity` union** — properly satisfies the union type for diagnostic severity mapping.
56
+ - **9 tree-sitter query bugs in new rule files** — predicate outside outermost parens (`cpp/no-auto-ptr`); false-positive `post_filter` gate added (`cpp/no-confused-move-forward`); leaf-node child match removed (`php/this-in-static-context`); invalid node name `class_hereditary` replaced (`java/no-field-shadowing`); field order corrected (`java/no-wait-notify-on-thread`); duplicate `modifiers` blocks merged (`java/spring-session-attributes-setcomplete`); invalid anonymous-node field label removed (`csharp/is-with-this`); inline alternation replaced with two patterns (`python/in-operator-unsupported`); adjacent sibling requirement removed, delegated to `post_filter` (`python/return-in-generator`).
57
+
7
58
  ## [3.8.38] - 2026-05-02
8
59
 
9
60
  ### Added
@@ -1123,7 +1174,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1123
1174
  - **Rust performance core (`pi-lens-core`)** — Optional Rust binary for CPU-intensive operations.
1124
1175
  All features fall back to TypeScript automatically if the binary is not available (it is **not**
1125
1176
  built automatically on `npm install` — run `npm run rust:build` once if you have Rust installed).
1126
-
1127
1177
  - **File scanning** — ripgrep’s `ignore` crate for `.gitignore`-aware project scanning
1128
1178
  - **Similarity detection** — parallel 57×72 state-matrix index, persisted to
1129
1179
  `.pi-lens/rust-index.json` between invocations (fixes in-memory cache that reset on every
@@ -1177,7 +1227,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1177
1227
  - Removed `clients/interviewer-templates.ts` (240 lines)
1178
1228
  - Removed initialization from `index.ts`
1179
1229
  - **Deleted deprecated commands** — All were superseded by `/lens-booboo`:
1180
-
1181
1230
  - `/lens-booboo-fix` command (fix-from-booboo.ts, 430 lines) — showed warning to use `/lens-booboo`
1182
1231
  - `/lens-fix-simplified` command (fix-simplified.ts, 770 lines) — never registered, unused
1183
1232
  - `/lens-rate` command (rate.ts, 340 lines) — showed warning to use `/lens-booboo`
@@ -1196,7 +1245,6 @@ All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents
1196
1245
  - Broken runner tests (7 files) — thin CLI wrappers with wrong imports
1197
1246
  - Trivial utility tests (5 files) — file extension parsing, string sanitization
1198
1247
  - **Added meaningful integration tests**:
1199
-
1200
1248
  - `tests/clients/dispatch/dispatcher-flow.test.ts` — Runner registration, execution, delta mode, conditional runners
1201
1249
  - `tests/extension-hooks.test.ts` — pi API: tool/command/flag registration, event handlers
1202
1250
  - `tests/mocks/runner-factory.ts` — Mock runners for testing without real CLI tools
@@ -1532,7 +1580,6 @@ Migrated 20 critical security rules to NAPI (fast native execution):
1532
1580
  Three new lint runners with full test coverage:
1533
1581
 
1534
1582
  - **Spellcheck runner** (`clients/dispatch/runners/spellcheck.ts`): Markdown spellchecking
1535
-
1536
1583
  - Uses `typos-cli` (Rust-based, fast, low false positives)
1537
1584
  - Checks `.md` and `.mdx` files
1538
1585
  - Priority 30, runs after code quality checks
@@ -1540,7 +1587,6 @@ Three new lint runners with full test coverage:
1540
1587
  - Install: `cargo install typos-cli`
1541
1588
 
1542
1589
  - **Oxlint runner** (`clients/dispatch/runners/oxlint.ts`): Fast JS/TS linting
1543
-
1544
1590
  - Uses `oxlint` from Oxc project (Rust-based, ~100x faster than ESLint)
1545
1591
  - Zero-config by default
1546
1592
  - JSON output with fix suggestions
package/README.md CHANGED
@@ -41,7 +41,7 @@ At `session_start`, pi-lens:
41
41
  - applies language-aware startup defaults for tool preinstall
42
42
  - warms caches and optional indexes (with overlap/session guardrails)
43
43
  - emits missing-tool install hints for detected languages when relevant
44
- - injects session guidance through internal context (non-user channel) to reduce acknowledgement-only first responses
44
+ - prepends session guidance before the user's prompt so provider bridges keep the real prompt active
45
45
  - opens `warmFiles` (if configured in `.pi-lens/lsp.json`) to seed lazy-indexing language servers like clangd before the first symbol query
46
46
 
47
47
  For one-shot print sessions (for example `pi --print ...`), pi-lens auto-uses a quick startup path that skips heavy bootstrap work to reduce startup latency. Override with `PI_LENS_STARTUP_MODE=full|minimal|quick`.
@@ -263,9 +263,10 @@ export class BiomeClient {
263
263
  const content = fs.readFileSync(absolutePath, "utf-8");
264
264
 
265
265
  try {
266
- // Single invocation: check --write applies safe formatting + lint fixes.
267
- // No pre-flight checkFile() needed content diff tells us if anything changed.
268
- const result = this.spawnBiome(["check", "--write", absolutePath]);
266
+ // lint --write applies safe lint fixes only — no formatting.
267
+ // Formatting is deferred to agent_end to avoid mid-turn file modifications
268
+ // that trigger read-guard "file modified since read" blocks.
269
+ const result = this.spawnBiome(["lint", "--write", absolutePath]);
269
270
 
270
271
  if (result.error) {
271
272
  return {
@@ -325,7 +326,7 @@ export class BiomeClient {
325
326
  try {
326
327
  const before = await fs.promises.readFile(absolutePath, "utf-8");
327
328
  const result = await this.spawnBiomeAsync([
328
- "check",
329
+ "lint",
329
330
  "--write",
330
331
  absolutePath,
331
332
  ]);
@@ -307,10 +307,13 @@ export class CacheManager {
307
307
 
308
308
  /**
309
309
  * Get files that need jscpd re-scan (any edit).
310
+ * Only returns source code files jscpd can meaningfully analyse.
310
311
  */
311
312
  getFilesForJscpd(cwd: string): string[] {
312
313
  const state = this.readTurnState(cwd);
313
- return Object.keys(state.files);
314
+ return Object.keys(state.files).filter((f) =>
315
+ /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|cs|php|cpp|c|h|hpp|swift|kt)$/.test(f),
316
+ );
314
317
  }
315
318
 
316
319
  /**
@@ -61,7 +61,8 @@ export function scheduleProviders(providers: FactProvider[]): FactProvider[] {
61
61
  }
62
62
  }
63
63
  }
64
- wave = nextWave.sort((a, b) => a.id.localeCompare(b.id));
64
+ nextWave.sort((a, b) => a.id.localeCompare(b.id));
65
+ wave = nextWave;
65
66
  }
66
67
 
67
68
  if (result.length < providers.length) {
@@ -1,7 +1,5 @@
1
1
  import { normalizeMapKey } from "../path-utils.js";
2
2
 
3
- type FactValue = unknown;
4
-
5
3
  export interface ReadonlyFactStore {
6
4
  getFileFact<T>(filePath: string, factId: string): T | undefined;
7
5
  hasFileFact(filePath: string, factId: string): boolean;
@@ -10,8 +8,8 @@ export interface ReadonlyFactStore {
10
8
  }
11
9
 
12
10
  export class FactStore implements ReadonlyFactStore {
13
- private readonly fileFacts = new Map<string, Map<string, FactValue>>();
14
- private readonly sessionFacts = new Map<string, FactValue>();
11
+ private readonly fileFacts = new Map<string, Map<string, unknown>>();
12
+ private readonly sessionFacts = new Map<string, unknown>();
15
13
 
16
14
  // All file-keyed methods normalize the path internally via normalizeMapKey().
17
15
  // Callers always pass raw/resolved paths — normalization is not their concern.
@@ -20,7 +18,7 @@ export class FactStore implements ReadonlyFactStore {
20
18
  return this.fileFacts.get(normalizeMapKey(filePath))?.get(factId) as T | undefined;
21
19
  }
22
20
 
23
- setFileFact(filePath: string, factId: string, value: FactValue): void {
21
+ setFileFact(filePath: string, factId: string, value: unknown): void {
24
22
  const key = normalizeMapKey(filePath);
25
23
  let facts = this.fileFacts.get(key);
26
24
  if (!facts) {
@@ -51,7 +49,7 @@ export class FactStore implements ReadonlyFactStore {
51
49
  return this.sessionFacts.get(factId) as T | undefined;
52
50
  }
53
51
 
54
- setSessionFact(factId: string, value: FactValue): void {
52
+ setSessionFact(factId: string, value: unknown): void {
55
53
  this.sessionFacts.set(factId, value);
56
54
  }
57
55
 
@@ -358,6 +358,8 @@ export function resetDispatchBaselines(): void {
358
358
  primaryFilesThisTurn.clear();
359
359
  cascadeDiagnosticBaselines.clear();
360
360
  cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
361
+ for (const timer of astGrepWarnDebounceTimers.values()) clearTimeout(timer);
362
+ astGrepWarnDebounceTimers.clear();
361
363
  }
362
364
 
363
365
  let cascadeSessionStats = { runs: 0, diagnosticsSurfaced: 0, coldSnapshotTouches: 0 };
@@ -479,7 +481,7 @@ export async function computeCascadeForFile(
479
481
  .filter((n) => !primaryFilesThisTurn.has(normalizeMapKey(n)))
480
482
  .sort((a, b) => {
481
483
  const rank = (p: string) =>
482
- importerSet.has(p) ? 0 : callerSet.has(p) ? 1 : 2;
484
+ importerSet.has(p) ? 0 : (callerSet.has(p) ? 1 : 2);
483
485
  return rank(a) - rank(b);
484
486
  })
485
487
  .slice(0, MAX_FILES);
@@ -584,10 +586,7 @@ export async function computeCascadeForFile(
584
586
  // write sequence. A new write (higher writeSeq) invalidates the cache entry.
585
587
  const cached =
586
588
  writeSeq != null ? neighborTouchCache.get(cacheKey) : undefined;
587
- if (
588
- cached != null &&
589
- cached.turnSeq === turnSeq
590
- ) {
589
+ if (cached?.turnSeq === turnSeq) {
591
590
  producedLspData = true;
592
591
  const durationMs = Date.now() - neighborStart;
593
592
  logCascade({
@@ -149,21 +149,16 @@ export const noBooleanParamsRule: FactRule = {
149
149
  ts.isIdentifier(param.name) ? param.name.text : "";
150
150
  if (BOOLEAN_PREFIX_OK.test(name)) continue;
151
151
 
152
- let isBoolean = false;
153
- if (param.type.kind === ts.SyntaxKind.BooleanKeyword) {
154
- isBoolean = true;
155
- } else if (
156
- ts.isUnionTypeNode(param.type) &&
157
- param.type.types.every(
158
- (t) =>
159
- t.kind === ts.SyntaxKind.BooleanKeyword ||
160
- (ts.isLiteralTypeNode(t) &&
161
- (t.literal.kind === ts.SyntaxKind.TrueKeyword ||
162
- t.literal.kind === ts.SyntaxKind.FalseKeyword)),
163
- )
164
- ) {
165
- isBoolean = true;
166
- }
152
+ const isBoolean =
153
+ param.type.kind === ts.SyntaxKind.BooleanKeyword ||
154
+ (ts.isUnionTypeNode(param.type) &&
155
+ param.type.types.every(
156
+ (t) =>
157
+ t.kind === ts.SyntaxKind.BooleanKeyword ||
158
+ (ts.isLiteralTypeNode(t) &&
159
+ (t.literal.kind === ts.SyntaxKind.TrueKeyword ||
160
+ t.literal.kind === ts.SyntaxKind.FalseKeyword)),
161
+ ));
167
162
 
168
163
  if (!isBoolean) continue;
169
164
  const { line, character } = sf.getLineAndCharacterOfPosition(param.getStart(sf));
@@ -134,7 +134,7 @@ const biomeCheckJsonRunner: RunnerDefinition = {
134
134
  diagnostics: [
135
135
  {
136
136
  id: "biome:parse-error:1",
137
- message: `Biome JSON parse failed: ${parsed.parseError}${preview ? ` (output preview: ${preview})` : ""}`,
137
+ message: "Biome JSON parse failed: " + parsed.parseError + (preview ? " (output preview: " + preview + ")" : ""),
138
138
  filePath: ctx.filePath,
139
139
  line: 1,
140
140
  column: 1,
@@ -125,7 +125,7 @@ const eslintRunner: RunnerDefinition = {
125
125
  diagnostics: [
126
126
  {
127
127
  id: "eslint:parse-error:1",
128
- message: `ESLint JSON parse failed: ${parsed.parseError}${preview ? ` (output preview: ${preview})` : ""}`,
128
+ message: "ESLint JSON parse failed: " + parsed.parseError + (preview ? " (output preview: " + preview + ")" : ""),
129
129
  filePath: ctx.filePath,
130
130
  line: 1,
131
131
  column: 1,
@@ -212,11 +212,7 @@ const lspRunner: RunnerDefinition = {
212
212
  );
213
213
 
214
214
  const hasErrors = diagnostics.some((d) => d.semantic === "blocking");
215
- const resultSemantic = hasErrors
216
- ? "blocking"
217
- : diagnostics.length > 0
218
- ? "warning"
219
- : "none";
215
+ const resultSemantic = hasErrors ? "blocking" : (diagnostics.length > 0 ? "warning" : "none");
220
216
 
221
217
  return {
222
218
  status: hasErrors ? "failed" : "succeeded",
@@ -86,7 +86,7 @@ function parsePSAnalyzerOutput(raw: string, filePath: string): Diagnostic[] {
86
86
  .map((item) => {
87
87
  const sev = (item.Severity ?? "Warning").toLowerCase();
88
88
  const severity: "error" | "warning" | "info" =
89
- sev === "error" || sev === "parseerror" ? "error" : sev === "information" ? "info" : "warning";
89
+ (sev === "error" || sev === "parseerror") ? "error" : (sev === "information" ? "info" : "warning");
90
90
  const rule = item.RuleName ?? "PSScriptAnalyzer";
91
91
  return {
92
92
  id: `psscriptanalyzer-${rule}-${item.Line}`,
@@ -394,10 +394,7 @@ function getArrowSignature(
394
394
  | import("typescript").ArrowFunction
395
395
  | import("typescript").FunctionExpression,
396
396
  ): string {
397
- const params = node.parameters
398
- .map((p) => (tsModule.isIdentifier(p.name) ? p.name.text : "param"))
399
- .join(", ");
400
- return `(${params})`;
397
+ return getSignature(tsModule, node as unknown as import("typescript").FunctionDeclaration);
401
398
  }
402
399
 
403
400
  // ============================================================================
@@ -16,16 +16,10 @@ export function convertLspDiagnostics(
16
16
  return diags
17
17
  .filter((d) => d.range?.start?.line !== undefined)
18
18
  .map((d, idx) => {
19
- const severity =
20
- d.severity === 1
21
- ? "error"
22
- : d.severity === 2
23
- ? "warning"
24
- : d.severity === 4
25
- ? "hint"
26
- : "info";
19
+ const severityMap: Record<number, "error" | "warning" | "hint"> = { 1: "error", 2: "warning", 4: "hint" };
20
+ const severity: "error" | "warning" | "info" | "hint" = severityMap[d.severity] ?? "info";
27
21
  const semantic =
28
- d.severity === 1 ? "blocking" : d.severity === 2 ? "warning" : "none";
22
+ d.severity === 1 ? "blocking" : (d.severity === 2 ? "warning" : "none");
29
23
  const code = String(d.code ?? "unknown");
30
24
  const source = options.source ?? d.source ?? tool;
31
25
  const hasSuggestion = options.fixSuggestionByIndex?.has(idx) ?? false;
@@ -54,7 +54,7 @@ async function tryLazyInstallFormatterTool(
54
54
  const ok = !res.error && res.status === 0;
55
55
  if (!ok) {
56
56
  console.error(
57
- `[format] lazy-install rubocop failed: ${res.error?.message ?? res.stderr ?? `exit ${res.status}`}`,
57
+ `[format] lazy-install rubocop failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`,
58
58
  );
59
59
  }
60
60
  return ok;
@@ -67,7 +67,7 @@ async function tryLazyInstallFormatterTool(
67
67
  const ok = !res.error && res.status === 0;
68
68
  if (!ok) {
69
69
  console.error(
70
- `[format] lazy-install rustfmt failed: ${res.error?.message ?? res.stderr ?? `exit ${res.status}`}`,
70
+ `[format] lazy-install rustfmt failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`,
71
71
  );
72
72
  }
73
73
  return ok;
@@ -919,11 +919,16 @@ export async function getFormattersForFile(
919
919
 
920
920
  const enabled = selected ? [selected] : [];
921
921
 
922
- const selectionReason = selected
923
- ? (formatterPolicy
924
- ? (candidateFormatters.some((f) => hasExplicitFormatterConfig(f.name, cwd)) ? "explicit-config" : "smart-default")
925
- : "detect")
926
- : "none";
922
+ let selectionReason: string;
923
+ if (!selected) {
924
+ selectionReason = "none";
925
+ } else if (!formatterPolicy) {
926
+ selectionReason = "detect";
927
+ } else {
928
+ selectionReason = candidateFormatters.some((f) => hasExplicitFormatterConfig(f.name, cwd))
929
+ ? "explicit-config"
930
+ : "smart-default";
931
+ }
927
932
  logLatency({
928
933
  type: "phase",
929
934
  phase: "formatter_selected",
@@ -8,9 +8,9 @@
8
8
  * - Request/response handling
9
9
  */
10
10
 
11
- import { existsSync } from "node:fs";
12
11
  import { spawn as nodeSpawn } from "node:child_process";
13
12
  import { EventEmitter } from "node:events";
13
+ import { existsSync } from "node:fs";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import type { MessageConnection } from "vscode-jsonrpc";
16
16
  import {
@@ -252,6 +252,10 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv(
252
252
  "PI_LENS_LSP_PULL_RETRY_INTERVAL_MS",
253
253
  250,
254
254
  );
255
+ const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv(
256
+ "PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS",
257
+ 1000,
258
+ );
255
259
 
256
260
  const LSP_CRASH_CODES = new Set([
257
261
  "ERR_STREAM_DESTROYED",
@@ -340,6 +344,33 @@ function disposeClientConnection(state: LSPClientState): void {
340
344
  }
341
345
  }
342
346
 
347
+ async function killProcessTree(
348
+ proc: { kill(signal?: NodeJS.Signals | number): boolean },
349
+ pid: number,
350
+ ): Promise<void> {
351
+ if (process.platform === "win32" && pid > 0) {
352
+ await new Promise<void>((resolve) => {
353
+ try {
354
+ // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
355
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
356
+ const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
357
+ shell: false,
358
+ windowsHide: true,
359
+ });
360
+ killer.once("close", () => resolve());
361
+ killer.once("error", () => resolve());
362
+ } catch {
363
+ resolve();
364
+ }
365
+ });
366
+ return;
367
+ }
368
+
369
+ try {
370
+ proc.kill("SIGTERM");
371
+ } catch {}
372
+ }
373
+
343
374
  function mergeDiagnosticLists(
344
375
  push: LSPDiagnostic[] | undefined,
345
376
  pull: LSPDiagnostic[] | undefined,
@@ -483,9 +514,7 @@ function setupIncomingHandlers(
483
514
  );
484
515
  state.connection.onRequest(
485
516
  "client/unregisterCapability",
486
- async (params: {
487
- unregisterations?: Array<{ id: string }>;
488
- }) => {
517
+ async (params: { unregisterations?: Array<{ id: string }> }) => {
489
518
  for (const unreg of params?.unregisterations ?? []) {
490
519
  if (unreg.id) {
491
520
  state.dynamicRegistrations.delete(unreg.id);
@@ -715,9 +744,12 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
715
744
  state.openDocuments.clear();
716
745
  state.diagnosticEmitter.removeAllListeners();
717
746
  try {
718
- await safeSendRequest(state.connection, "shutdown", {});
747
+ await withTimeout(
748
+ safeSendRequest(state.connection, "shutdown", {}),
749
+ SHUTDOWN_REQUEST_TIMEOUT_MS,
750
+ );
719
751
  } catch {
720
- /* ignore */
752
+ /* ignore — proceed to exit/kill so shutdown cannot hang the session */
721
753
  }
722
754
  try {
723
755
  await safeSendNotification(state.connection, "exit", {});
@@ -725,7 +757,10 @@ async function clientShutdown(state: LSPClientState): Promise<void> {
725
757
  /* ignore */
726
758
  }
727
759
  disposeClientConnection(state);
728
- state.lspProcess.process.kill();
760
+ const pid = state.lspProcess.pid;
761
+ // On Windows, killing the direct child first can orphan grandchildren before
762
+ // taskkill can traverse the tree. Kill the full tree first and wait briefly.
763
+ await killProcessTree(state.lspProcess.process, pid);
729
764
  }
730
765
 
731
766
  async function navRequest<T>(
@@ -902,17 +937,11 @@ export async function createLSPClient(options: {
902
937
  // Hard-kill the hung process so it doesn't become a zombie.
903
938
  // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
904
939
  const pid = lspProcess.pid;
905
- lspProcess.process.kill("SIGTERM");
906
- if (process.platform === "win32" && pid > 0) {
907
- try {
908
- nodeSpawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
909
- shell: false,
910
- windowsHide: true,
911
- });
912
- } catch {}
913
- }
940
+ void killProcessTree(lspProcess.process, pid);
914
941
  setTimeout(() => {
915
- if (!lspProcess.process.killed) lspProcess.process.kill("SIGKILL");
942
+ if (!lspProcess.process.killed && process.platform !== "win32") {
943
+ lspProcess.process.kill("SIGKILL");
944
+ }
916
945
  }, 2000);
917
946
  throw err;
918
947
  } finally {
@@ -940,7 +969,8 @@ export async function createLSPClient(options: {
940
969
  );
941
970
  }
942
971
 
943
- state.workspaceDiagnosticsSupport = detectWorkspaceDiagnosticsSupport(initResult);
972
+ state.workspaceDiagnosticsSupport =
973
+ detectWorkspaceDiagnosticsSupport(initResult);
944
974
  state.operationSupport = detectOperationSupport(initResult);
945
975
  state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
946
976
 
@@ -1247,19 +1277,24 @@ async function withTimeout<T>(
1247
1277
  promise: Promise<T>,
1248
1278
  timeoutMs: number,
1249
1279
  ): Promise<T> {
1280
+ let timeout: ReturnType<typeof setTimeout> | undefined;
1250
1281
  // Suppress unhandled rejection if `promise` rejects AFTER the timeout
1251
1282
  // wins the race — Promise.race settles on the first result but the
1252
1283
  // losing promises still run, and any later rejection would be uncaught.
1253
1284
  promise.catch(() => {});
1254
- return Promise.race([
1255
- promise,
1256
- new Promise<T>((_, reject) =>
1257
- setTimeout(
1258
- () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1259
- timeoutMs,
1260
- ),
1261
- ),
1262
- ]);
1285
+ try {
1286
+ return await Promise.race([
1287
+ promise,
1288
+ new Promise<T>((_, reject) => {
1289
+ timeout = setTimeout(
1290
+ () => reject(new Error(`Timeout after ${timeoutMs}ms`)),
1291
+ timeoutMs,
1292
+ );
1293
+ }),
1294
+ ]);
1295
+ } finally {
1296
+ if (timeout) clearTimeout(timeout);
1297
+ }
1263
1298
  }
1264
1299
 
1265
1300
  function positiveIntFromEnv(name: string, fallback: number): number {
@@ -135,13 +135,13 @@ export interface LSPTouchFileOptions {
135
135
 
136
136
  export class LSPService {
137
137
  private state: LSPState;
138
- private workspaceProbeLogged = new Set<string>();
139
- private warmStartLogged = new Set<string>();
140
- private optionalFailureLogged = new Set<string>();
141
- private optionalDisabled = new Set<string>();
138
+ private readonly workspaceProbeLogged = new Set<string>();
139
+ private readonly warmStartLogged = new Set<string>();
140
+ private readonly optionalFailureLogged = new Set<string>();
141
+ private readonly optionalDisabled = new Set<string>();
142
142
  /** Consecutive failure counts for exponential backoff circuit breaker */
143
- private failureCounts = new Map<string, number>();
144
- private recentTouches = new Map<
143
+ private readonly failureCounts = new Map<string, number>();
144
+ private readonly recentTouches = new Map<
145
145
  string,
146
146
  { fingerprint: string; touchedAt: number; clientScope: "primary" | "all" }
147
147
  >();
@@ -197,11 +197,22 @@ export class LSPService {
197
197
  clientScope: "primary" | "all",
198
198
  ): void {
199
199
  const key = `${normalizeMapKey(filePath)}:${clientScope}`;
200
+ const now = Date.now();
200
201
  this.recentTouches.set(key, {
201
202
  fingerprint: this.fingerprintContent(content),
202
- touchedAt: Date.now(),
203
+ touchedAt: now,
203
204
  clientScope,
204
205
  });
206
+ // Trim entries that are already past the debounce window — shouldSkipTouch
207
+ // ignores them anyway, so they serve no purpose. Only sweep when the map
208
+ // exceeds the threshold to avoid iterating on every call.
209
+ if (this.recentTouches.size > 200) {
210
+ for (const [k, v] of this.recentTouches) {
211
+ if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
212
+ this.recentTouches.delete(k);
213
+ }
214
+ }
215
+ }
205
216
  }
206
217
 
207
218
  /**
@@ -309,7 +320,7 @@ export class LSPService {
309
320
  if (!root) continue;
310
321
  const key = `${server.id}:${normalizeMapKey(root)}`;
311
322
  const existing = this.state.clients.get(key);
312
- if (existing && existing.isAlive()) {
323
+ if (existing?.isAlive()) {
313
324
  return { client: existing, info: server };
314
325
  }
315
326
  }
@@ -392,9 +403,7 @@ export class LSPService {
392
403
  }
393
404
  }
394
405
 
395
- private shouldAllowInstall(filePath: string, root: string): boolean {
396
- void filePath;
397
- void root;
406
+ private shouldAllowInstall(_filePath: string, _root: string): boolean {
398
407
  return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
399
408
  }
400
409