pi-lens 3.8.32 → 3.8.33

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
@@ -4,6 +4,58 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Added
8
+
9
+ ## [3.8.33] - 2026-04-27
10
+
11
+ ### Fixed
12
+
13
+ - **JSON/JSONC autofix skipped without biome config** — `getAutofixPolicyForFile` now returns `undefined` for `.json`/`.jsonc` files when no `biome.json`/`biome.jsonc` is present, matching the format policy's `defaultWhenUnconfigured: false` gate. Previously biome was always invoked for JSON edits (~688ms) even when it had no config and fixed nothing. `hasBiomeConfig` added to `AutofixPolicyContext` and wired into the autofix context in `runAutofix`.
14
+
15
+ ### Added
16
+
17
+ - **Early-unblock diagnostic aggregation** — `getDiagnostics()` now races `Promise.all` against a first-client-done + grace window (`PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS`, default 400ms). Once the fastest client delivers results, remaining clients have the grace window before the call returns with whatever is ready. Eliminates the previous worst case where a slow push-only server forced the full 1500ms aggregate wait even when a faster server already had errors. `earlyUnblockedCount` is logged in `lsp_diagnostics_aggregate` latency records.
18
+ - **Dynamic LSP capability registration tracking** — `client/registerCapability` and `client/unregisterCapability` handlers now record live registrations (`id → method`) in `dynamicRegistrations`. `applyDynamicCapabilities()` upgrades `workspaceDiagnosticsSupport` to pull mode when `textDocument/diagnostic` or `workspace/diagnostic` is dynamically registered, and reverts when the last such registration is removed (unless statically advertised). Operation support flags are also upgraded for dynamically-registered nav methods. Servers that defer capability advertisement past `initialize` are now treated correctly.
19
+ - **Deno/TypeScript server disambiguation** — `TypeScriptServer.root` now returns `undefined` for any file with a `deno.json` or `deno.jsonc` ancestor, preventing TypeScript LSP from being spawned alongside Deno LSP for the same file. Eliminates false diagnostics for Deno-specific APIs and removes the wasted parallel spawn.
20
+ - **`CONDA_PREFIX` support in Python venv detection** — conda environments do not set `VIRTUAL_ENV`; venv detection now checks `CONDA_PREFIX` as a fallback between `VIRTUAL_ENV` and the local `.venv`/`venv` directories.
21
+ - **pylsp venv initialization** — `PythonPylspServer.spawn` now passes `{ pylsp: { plugins: { jedi: { environment: pythonPath } } } }` when a virtual environment is detected. Previously pylsp always used the system Python, so completions and diagnostics resolved against the wrong package set in virtualenv projects.
22
+
23
+ ### Changed
24
+
25
+ - **Push/pull LSP diagnostic caches split** — `LSPClientState` now maintains separate `pushDiagnostics` and `documentPullDiagnostics` maps with independent timestamps. Public API (`getDiagnostics`, `getAllDiagnostics`, `pruneDiagnostics`) operates on a merged, deduplicated view. Clears and prunes invalidate both sources independently. Makes diagnostic freshness and source attribution inspectable without changing caller behavior.
26
+ - **Explicit LSP touch diagnostics modes** — `touchFile()` now takes `{ diagnostics: "none" | "document" | "full", clientScope: "primary" | "all", source, maxClientWaitMs }` instead of a boolean `waitForDiagnostics` flag. Read/tool-call warming uses `"none"`; write validation uses `"document"`. Latency records include `diagnosticsMode`, `clientScope`, and `source`.
27
+ - **Pipeline reordered around final content** — format → refresh → autofix → refresh → LSP sync once with final content → dispatch. LSP diagnostics and dispatch runners now always operate on the final post-format/post-fix on-disk state. Removed previously-dead `supportsAutofix` / deferred sync logic.
28
+ - **Python venv detection deduplicated** — `PythonServer.spawn` previously ran identical 20-line venv detection blocks in both the direct and managed code paths. Both now call the shared `detectPythonVenv(root)` helper.
29
+
30
+ ### Fixed
31
+
32
+ - **Formatter failures now visible in output** — formatter crashes (missing binary, timeout, I/O error) now append `⚠️ Auto-format failed: <reason>` to pipeline output instead of silently writing to debug logs. Prevents misleading all-clear output when a required format phase failed.
33
+ - **Same-file same-turn pipeline dedupe keyed on content hash** — previously any later pipeline for a file already reported in the same turn was skipped by file path alone, suppressing legitimate second edits. Dedupe is now keyed on post-write content hash: concurrent duplicate events for the same final content are collapsed, but a later edit with changed content runs the full pipeline again.
34
+ - **Autofix side-effect files tracked in turn state** — `runAutofix()` now returns `changedFiles[]`. File-scoped fixers (ruff, biome, eslint, stylelint, sqlfluff, rubocop, ktlint) record the target file on a successful fix; project-wide fixers (cargo clippy --fix, dart fix --apply) snapshot the project tree before and after to detect side-effect changes. Non-target changed files are added to turn state via `cacheManager.addModifiedRange()` so cascade and read-guard see the full mutation set.
35
+
36
+ ### Changed
37
+
38
+ - **Linter dispatch runners promoted to always-on for 11 languages** — runners that previously fired only when LSP failed (`mode: "fallback"`) now run alongside LSP unconditionally (`mode: "all"`): `pyright` (Python), `rust-clippy` (Rust), `go-vet` (Go), `shellcheck` (Shell), `tflint` (Terraform), `elixir-check` + `credo` (Elixir), `cpp-check` (C/C++), `dart-analyze` (Dart), `gleam-check` (Gleam), `psscriptanalyzer` (PowerShell), `prisma-validate` (Prisma). These tools provide orthogonal signal to the LSP that was previously invisible on healthy sessions.
39
+
40
+ ### Added
41
+
42
+ - **Linter policy entries for 9 languages** — `getLinterPolicyForFile` now covers Rust (rust-clippy, smart-default), Shell (shellcheck, smart-default), Terraform (tflint, smart-default), Elixir (credo, smart-default), C/C++ (cpp-check, smart-default), Dart (dart-analyze, smart-default), Gleam (gleam-check, smart-default), PowerShell (psscriptanalyzer, smart-default), and Prisma (prisma-validate, smart-default). These linters now participate in the full policy layer rather than being dispatch-only.
43
+ - **`cargo clippy --fix` autofix for Rust** — `rust-clippy` is now a safe pipeline autofix tool for `.rs` files. After each edit, `cargo clippy --fix --allow-dirty --allow-staged` runs in the nearest `Cargo.toml` directory before dispatch lint, applying machine-fixable clippy suggestions. Gated `smart-default`; skips silently if `cargo` is unavailable or no `Cargo.toml` is found.
44
+ - **`dart fix --apply` autofix for Dart** — `dart-analyze` is now a safe pipeline autofix tool for `.dart` files. After each edit, `dart fix --apply` runs in the nearest `pubspec.yaml` directory before dispatch lint. Gated `smart-default`; skips silently if `dart` is unavailable or no `pubspec.yaml` is found.
45
+
46
+ ### Fixed
47
+
48
+ - **Unknown/support files no longer trigger opportunistic LSP auto-touch** — `tool_call` LSP warming now defaults unknown file kinds to non-LSP-capable and explicitly skips internal/support artifacts such as `.pi-lens/*`, `.harness/*`, `stdout.jsonl`, `stderr.txt`, `prompt.txt`, and harness `case.json` files. This removes pointless `lsp_touch_file` `no_clients` waits on logs, prompts, and turn-state sidecars.
49
+ - **Spawn-heavy LSP capability checks removed from hot paths** — added a pure `supportsLSP(filePath)` check and a lightweight `hasWarmLSP(filePath)` helper so hot write/read paths no longer use `hasLSP()` merely to ask whether a file type is supported. `pipeline` sync/resync, the unified LSP runner, and `lsp_navigation` unsupported-file messaging now avoid accidental client spawns during simple capability checks.
50
+ - **`ktlint` autofix case missing `continue`** — the `ktlint` branch in `runAutofix` lacked a `continue` guard, causing fall-through into the next tool match on every ktlint run.
51
+
52
+ ## [Unreleased — mypy + detekt]
53
+
54
+ ### Added
55
+
56
+ - **`mypy` wired into Python dispatch** — runner already existed but was never included in the dispatch plan or linter policy. Added to Python `writeGroups` in `plan.ts` and to `getLinterPolicyForFile` for `.py`/`.pyi`. When `mypy.ini` or `[tool.mypy]` is present, mypy is appended to `preferredRunners` alongside ruff-lint (gate: `mixed`); unconfigured projects are unaffected.
57
+ - **`detekt` runner for Kotlin** — new runner (`detekt.ts`) that runs `detekt --input <file> --config <config>` for static analysis of `.kt`/`.kts` files. Config-first: activates only when `detekt.yml`, `.detekt.yml`, `config/detekt/detekt.yml`, or `detekt/detekt.yml` is found. Added `hasDetektConfig` helper, `"detekt"` to `LintRunnerName`, `hasDetektConfig` to `LinterPolicyContext`, and detekt to Kotlin's linter policy (appended to `preferredRunners` alongside ktlint when configured). Kotlin `plan.ts` `writeGroups` updated to include detekt.
58
+
7
59
  ## [3.8.32] - 2026-04-26
8
60
 
9
61
  ### Fixed
@@ -57,6 +57,7 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
57
57
  writeGroups: [
58
58
  primary("python"),
59
59
  { mode: "fallback", runnerIds: ["ruff-lint"], filterKinds: ["python"] },
60
+ { mode: "fallback", runnerIds: ["mypy"], filterKinds: ["python"] },
60
61
  { mode: "all", runnerIds: ["tree-sitter"], filterKinds: ["python"] },
61
62
  ],
62
63
  fullOnlyGroups: [
@@ -173,8 +174,11 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
173
174
  },
174
175
  kotlin: {
175
176
  name: "Kotlin Linting",
176
- capabilities: ["types", "lint", "format"],
177
- writeGroups: [primary("kotlin")],
177
+ capabilities: ["types", "lint", "format", "smells"],
178
+ writeGroups: [
179
+ primary("kotlin"),
180
+ { mode: "fallback", runnerIds: ["detekt"], filterKinds: ["kotlin"] },
181
+ ],
178
182
  },
179
183
  swift: {
180
184
  name: "Swift Linting",
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { safeSpawnAsync } from "../../safe-spawn.js";
4
+ import { getLinterPolicyForCwd } from "../../tool-policy.js";
5
+ import { PRIORITY } from "../priorities.js";
6
+ import type {
7
+ Diagnostic,
8
+ DispatchContext,
9
+ RunnerDefinition,
10
+ RunnerResult,
11
+ } from "../types.js";
12
+ import { createAvailabilityChecker } from "./utils/runner-helpers.js";
13
+
14
+ const detekt = createAvailabilityChecker("detekt", ".bat");
15
+
16
+ const DETEKT_CONFIG_CANDIDATES = [
17
+ "detekt.yml",
18
+ ".detekt.yml",
19
+ path.join("config", "detekt", "detekt.yml"),
20
+ path.join("detekt", "detekt.yml"),
21
+ ];
22
+
23
+ function findDetektConfig(cwd: string): string | undefined {
24
+ for (const candidate of DETEKT_CONFIG_CANDIDATES) {
25
+ const full = path.join(cwd, candidate);
26
+ if (fs.existsSync(full)) return full;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ // detekt text output: /path/file.kt:10:5: error: Message [RuleId]
32
+ function parseDetektOutput(raw: string, filePath: string): Diagnostic[] {
33
+ const diagnostics: Diagnostic[] = [];
34
+ const pattern =
35
+ /^(.+?):(\d+):(\d+): (error|warning): (.+?)(?:\s+\[([^\]]+)\])?$/gm;
36
+
37
+ const absTarget = path.resolve(filePath);
38
+ for (const match of raw.matchAll(pattern)) {
39
+ const [, file, lineStr, colStr, level, message, rule] = match;
40
+ if (path.resolve(file.trim()) !== absTarget) continue;
41
+
42
+ const severity = level === "error" ? "error" : "warning";
43
+ const lineNum = parseInt(lineStr, 10);
44
+ const colNum = parseInt(colStr, 10);
45
+
46
+ diagnostics.push({
47
+ id: `detekt-${rule ?? "unknown"}-${lineNum}-${colNum}`,
48
+ message: rule ? `[${rule}] ${message}` : message,
49
+ filePath,
50
+ line: lineNum,
51
+ column: colNum,
52
+ severity,
53
+ semantic: severity === "error" ? "blocking" : "warning",
54
+ tool: "detekt",
55
+ rule: rule ?? "detekt",
56
+ });
57
+ }
58
+ return diagnostics;
59
+ }
60
+
61
+ const detektRunner: RunnerDefinition = {
62
+ id: "detekt",
63
+ appliesTo: ["kotlin"],
64
+ priority: PRIORITY.GENERAL_ANALYSIS,
65
+ enabledByDefault: true,
66
+ skipTestFiles: false,
67
+
68
+ async run(ctx: DispatchContext): Promise<RunnerResult> {
69
+ const cwd = ctx.cwd || process.cwd();
70
+
71
+ const policy = getLinterPolicyForCwd(ctx.filePath, cwd);
72
+ if (policy && !policy.preferredRunners.includes("detekt")) {
73
+ return { status: "skipped", diagnostics: [], semantic: "none" };
74
+ }
75
+
76
+ const configPath = findDetektConfig(cwd);
77
+ if (!configPath) {
78
+ return { status: "skipped", diagnostics: [], semantic: "none" };
79
+ }
80
+
81
+ const cmd = detekt.isAvailable(cwd) ? detekt.getCommand(cwd) : null;
82
+ if (!cmd) return { status: "skipped", diagnostics: [], semantic: "none" };
83
+
84
+ const absPath = path.resolve(cwd, ctx.filePath);
85
+ const result = await safeSpawnAsync(
86
+ cmd,
87
+ ["--input", absPath, "--config", configPath],
88
+ { cwd, timeout: 60000 },
89
+ );
90
+
91
+ if (result.error && !result.stdout && !result.stderr) {
92
+ return { status: "skipped", diagnostics: [], semantic: "none" };
93
+ }
94
+
95
+ const raw = `${result.stdout ?? ""}${result.stderr ?? ""}`;
96
+ const diagnostics = parseDetektOutput(raw, ctx.filePath);
97
+
98
+ if (diagnostics.length === 0) {
99
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
100
+ }
101
+
102
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
103
+ return {
104
+ status: hasErrors ? "failed" : "succeeded",
105
+ diagnostics,
106
+ semantic: hasErrors ? "blocking" : "warning",
107
+ };
108
+ },
109
+ };
110
+
111
+ export default detektRunner;
@@ -9,6 +9,7 @@ import biomeCheckJsonRunner from "./biome-check.js";
9
9
  import cppCheckRunner from "./cpp-check.js";
10
10
  import credoRunner from "./credo.js";
11
11
  import dartAnalyzeRunner from "./dart-analyze.js";
12
+ import detektRunner from "./detekt.js";
12
13
  import dotnetBuildRunner from "./dotnet-build.js";
13
14
  import elixirCheckRunner from "./elixir-check.js";
14
15
  import eslintRunner from "./eslint.js";
@@ -87,6 +88,7 @@ export function registerDefaultRunners(registry: RunnerRegistry): void {
87
88
  registry.register(psScriptAnalyzerRunner); // PowerShell linting via PSScriptAnalyzer module (priority 20)
88
89
  registry.register(prismaValidateRunner); // Prisma schema validation via CLI (priority 20)
89
90
  registry.register(ktlintRunner); // Kotlin linting via ktlint (priority 10)
91
+ registry.register(detektRunner); // Kotlin static analysis via detekt (priority 20, config-gated)
90
92
  registry.register(tflintRunner); // Terraform linting via tflint (priority 20)
91
93
  registry.register(taploRunner); // TOML linting/validation via taplo (priority 10)
92
94
  registry.register(dartAnalyzeRunner); // Dart analysis via dart analyze (priority 20)
@@ -103,9 +103,9 @@ const lspRunner: RunnerDefinition = {
103
103
 
104
104
  const lspService = getLSPService();
105
105
 
106
- // Check if we have LSP available for this file
107
- const hasLSP = await lspService.hasLSP(ctx.filePath);
108
- if (!hasLSP) {
106
+ // Fast capability check only actual client creation happens when we
107
+ // open the file below.
108
+ if (!lspService.supportsLSP(ctx.filePath)) {
109
109
  return { status: "skipped", diagnostics: [], semantic: "none" };
110
110
  }
111
111
 
@@ -22,6 +22,7 @@ import {
22
22
  hasNearestPackageJsonDependency,
23
23
  hasNearestPackageJsonField,
24
24
  hasOcamlformatConfig,
25
+ hasOxfmtConfig,
25
26
  hasPhpCsFixerConfig,
26
27
  hasPrettierConfig,
27
28
  hasRubocopConfig,
@@ -271,6 +272,11 @@ function hasExplicitFormatterConfig(
271
272
  return (
272
273
  hasPrettierConfig(cwd) || hasNearestPackageJsonField(cwd, "prettier")
273
274
  );
275
+ case "oxfmt":
276
+ return (
277
+ hasOxfmtConfig(cwd) ||
278
+ hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt")
279
+ );
274
280
  case "ruff":
275
281
  return hasRuffConfig(cwd);
276
282
  case "black":
@@ -379,6 +385,34 @@ export const prettierFormatter: FormatterInfo = {
379
385
  },
380
386
  };
381
387
 
388
+ export const oxfmtFormatter: FormatterInfo = {
389
+ name: "oxfmt",
390
+ command: ["oxfmt", "$FILE"],
391
+ async resolveCommand(filePath, cwd) {
392
+ const local = await findInNodeModules("oxfmt", cwd);
393
+ if (local) return [local, filePath];
394
+ const found = await which("oxfmt");
395
+ if (found) return [found, filePath];
396
+ return null;
397
+ },
398
+ extensions: [
399
+ ".js",
400
+ ".jsx",
401
+ ".mjs",
402
+ ".cjs",
403
+ ".ts",
404
+ ".tsx",
405
+ ".mts",
406
+ ".cts",
407
+ ],
408
+ async detect(cwd: string) {
409
+ return (
410
+ hasOxfmtConfig(cwd) ||
411
+ hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt")
412
+ );
413
+ },
414
+ };
415
+
382
416
  export const ruffFormatter: FormatterInfo = {
383
417
  name: "ruff",
384
418
  command: ["ruff", "format", "$FILE"],
@@ -702,6 +736,7 @@ export const taploFormatter: FormatterInfo = {
702
736
  const ALL_FORMATTERS: FormatterInfo[] = [
703
737
  biomeFormatter,
704
738
  prettierFormatter,
739
+ oxfmtFormatter,
705
740
  ruffFormatter,
706
741
  blackFormatter,
707
742
  sqlfluffFormatter,
@@ -85,13 +85,13 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
85
85
  filterKinds: ["jsts"],
86
86
  },
87
87
  python: {
88
- mode: "fallback",
88
+ mode: "all",
89
89
  runnerIds: ["lsp", "pyright"],
90
90
  filterKinds: ["python"],
91
91
  },
92
- go: { mode: "fallback", runnerIds: ["lsp", "go-vet"], filterKinds: ["go"] },
92
+ go: { mode: "all", runnerIds: ["lsp", "go-vet"], filterKinds: ["go"] },
93
93
  rust: {
94
- mode: "fallback",
94
+ mode: "all",
95
95
  runnerIds: ["lsp", "rust-clippy"],
96
96
  filterKinds: ["rust"],
97
97
  },
@@ -101,13 +101,13 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
101
101
  filterKinds: ["ruby"],
102
102
  },
103
103
  cxx: {
104
- mode: "fallback",
104
+ mode: "all",
105
105
  runnerIds: ["lsp", "cpp-check"],
106
106
  filterKinds: ["cxx"],
107
107
  },
108
108
  cmake: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["cmake"] },
109
109
  shell: {
110
- mode: "fallback",
110
+ mode: "all",
111
111
  runnerIds: ["lsp", "shellcheck"],
112
112
  filterKinds: ["shell"],
113
113
  },
@@ -148,12 +148,12 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
148
148
  filterKinds: ["php"],
149
149
  },
150
150
  powershell: {
151
- mode: "fallback",
151
+ mode: "all",
152
152
  runnerIds: ["lsp", "psscriptanalyzer"],
153
153
  filterKinds: ["powershell"],
154
154
  },
155
155
  prisma: {
156
- mode: "fallback",
156
+ mode: "all",
157
157
  runnerIds: ["lsp", "prisma-validate"],
158
158
  filterKinds: ["prisma"],
159
159
  },
@@ -175,7 +175,7 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
175
175
  },
176
176
  swift: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["swift"] },
177
177
  dart: {
178
- mode: "fallback",
178
+ mode: "all",
179
179
  runnerIds: ["lsp", "dart-analyze"],
180
180
  filterKinds: ["dart"],
181
181
  },
@@ -183,19 +183,19 @@ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
183
183
  zig: { mode: "all", runnerIds: ["lsp", "zig-check"], filterKinds: ["zig"] },
184
184
  haskell: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["haskell"] },
185
185
  elixir: {
186
- mode: "fallback",
186
+ mode: "all",
187
187
  runnerIds: ["lsp", "elixir-check", "credo"],
188
188
  filterKinds: ["elixir"],
189
189
  },
190
190
  gleam: {
191
- mode: "fallback",
191
+ mode: "all",
192
192
  runnerIds: ["lsp", "gleam-check"],
193
193
  filterKinds: ["gleam"],
194
194
  },
195
195
  ocaml: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["ocaml"] },
196
196
  clojure: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["clojure"] },
197
197
  terraform: {
198
- mode: "fallback",
198
+ mode: "all",
199
199
  runnerIds: ["lsp", "tflint"],
200
200
  filterKinds: ["terraform"],
201
201
  },