pi-lens 3.8.51 → 3.8.53

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
@@ -2,6 +2,68 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [3.8.53] - 2026-06-16
8
+
9
+ ### Added
10
+
11
+ - **ktfmt wired as a config-gated Kotlin formatter + safe autofix (closes #129)** — projects that use [ktfmt](https://github.com/facebook/ktfmt) (Facebook's opinionated, gofmt-style Kotlin formatter) now get real formatting support. ktfmt is a *pure formatter* (no lint rules), so it's wired only where that fits: as a **formatter** (`getFormattersForFile` → `ktfmtFormatter`, in-place) and a **safe pipeline autofix** (`runAutofix` → `tryKtfmtFix`), **not** as a lint runner — a "not formatted" nag would be redundant with the autofix pass (unlike shfmt, which has no autofix). Both are **config-first**: ktfmt activates only when the project opts in (a `.ktfmt`/`.ktfmt.kts` marker or the ktfmt gradle plugin in `build.gradle{.kts}`, via `hasKtfmtConfig`). When opted in, ktfmt **replaces ktlint** for formatting (the lint policy drops ktlint from `preferredRunners` so its style suggestions don't conflict with ktfmt's output); detekt's *semantic* lint is unaffected. Installs via the new maven-JAR strategy. Validated end-to-end on the dev box through the harness `--format` and `--autofix` layers (ktfmt reformats + applies a fix). Guards: `formatters.test.ts` (ktfmt wins over the ktlint default when opted in), `tool-policy.test.ts` (lint suppresses ktlint / autofix selects ktfmt + `hasKtfmtConfig` detection), and the `autofix-policy-consistency` gate-match. _(Follow-up filed: re-evaluate ktlint's default lint runner now that ktlint is itself a safe autofix — the same redundancy question applies to pure-formatter-linters generally.)_
12
+
13
+ - **Maven-JAR auto-install strategy (refs #129)** — the installer gained a `maven` strategy alongside npm/pip/gem/github: it downloads a runnable fat JAR from Maven Central into the managed bin and writes a `java -jar` launcher next to it, so the tool resolves like any managed binary (gated on a JRE). First consumer registered: **ktfmt** (`com.facebook:ktfmt:0.63:with-dependencies`) — verified end-to-end on the dev box (`ensureTool("ktfmt")` → launcher → `ktfmt --version`). Unblocks JVM-ecosystem tools that ship only as Maven JARs (ktfmt, google-java-format, SpotBugs). Guard: a `maven`-strategy install-contract case in `tool-registry-consistency.test.ts`.
14
+
15
+ - **Upgrade `vscode-jsonrpc` 8 → 9 (the LSP JSON-RPC transport)** — v9 introduced an `exports` map exposing the Node entry as the `./node` subpath, so the old `vscode-jsonrpc/node.js` file-path import no longer resolves (TS2307). Migrated the one import in `clients/lsp/client.ts` to `vscode-jsonrpc/node`; the API (`createMessageConnection`/`StreamMessageReader`/`StreamMessageWriter`/`MessageConnection`) and the internal `lib/node/ril.js` the error-classifier heuristic checks are unchanged. Verified with a live LSP initialize handshake. Supersedes the lockfile-only dependabot bump, which couldn't carry the required code change (closes #183).
16
+
17
+ - **Pipeline safe-autofix expanded to golangci-lint, detekt, markdownlint, oxlint (refs #209)** — four more fixable linters now apply their safe `--fix`/`--auto-correct` in the pipeline's autofix phase, each gated to **match its lint-policy strategy**: golangci-lint (Go, config-first — closes the gap where Go had no pipeline autofix), detekt (Kotlin, config-first — an alternative to the Windows-broken ktlint #218), markdownlint (smart-default), and oxlint (JS/TS, config-gated, mirroring the eslint→oxlint→biome lint precedence). Added to `AUTOFIX_CAPABILITIES` + `getAutofixPolicyForFile`. A new guard, `autofix-policy-consistency.test.ts`, locks the three hand-coded policy maps together — every autofix-selectable tool must be capability-declared and reachable, and each language's autofix gate must match its lint gate (catching config-first↔smart-default drift; it already caught an oxlint mismatch).
18
+
19
+ - **Tool-smoke harness gained an `--autofix` layer covering the pipeline's safe-autofix phase (refs #209)** — the safe-autofix phase (`runAutofix`, what `runPipeline` invokes) applies fixable linters in fix mode gated by the autofix policy. It **mutates files**, yet was exercised by neither the lint layer (lint-only) nor `--format` (formatters) — the highest-stakes path with no live coverage. `node scripts/smoke-tools.mjs --autofix` drives that exact phase per fixture (a safely-autofixable violation) and asserts the expected tool was policy-selected and applied a fix (`fixedCount > 0`, file changed). Validated end-to-end on the dev box for 11 tools: ruff (F401), biome (useConst), rubocop (spacing), sqlfluff (LT01), rust-clippy (needless_return), dart-analyze (prefer_const_declarations), stylelint (color-hex-length), eslint (semi), golangci-lint (gofmt), markdownlint (MD009), oxlint (no-var). ktlint is blocked by the Windows install bug (#218); detekt is wired + consistency-tested but live-validation needs the detekt CLI + formatting plugin (CI-deferred). `runAutofix` is now exported; the harness git-inits each autofix workspace so VCS-gated fixers (cargo fix) run as they would in a real repo.
20
+
21
+ - **Tool-smoke harness gained a `--format` layer covering the formatter pipeline (refs #209)** — formatters are a wholly separate subsystem (`getFormattersForFile` → `formatFile`, what `runFormatPhase` drives) that the lint-dispatch path the harness exercised never touched, so the formatters had zero live coverage despite mutating files in place (a silently-broken formatter is higher-stakes than a missed lint). `node scripts/smoke-tools.mjs --format` drives that exact entry per fixture: it asserts the expected formatter is **selected** for the file (config-gated formatters ship the config their `detect()` needs — `.prettierrc` / `gleam.toml` / `Gemfile` / `pyproject.toml [tool.black]` / `.cmake-format.yaml`) and that running it actually **reformats** a deliberately mis-formatted-but-valid fixture (`changed === true`). Now covers **28 of the 31 supported formatters** across 32 fixtures, all validated end-to-end on the dev box: biome, prettier, ruff, black, taplo, shfmt, gofmt, rustfmt, dart, zig, mix, gleam, rubocop, standardrb, sqlfluff, csharpier, terraform, fantomas, psscriptanalyzer-format, cmake-format, oxfmt, stylua, ormolu, cljfmt, php-cs-fixer, google-java-format, clang-format (+ ktlint, which the layer caught broken on Windows → #218). Config-gated formatters ship the config their `detect()` requires (stylua.toml / .cljfmt.edn / .php-cs-fixer.php / .editorconfig / Gemfile / pyproject.toml). The remaining 3 — nixfmt, ocamlformat, swiftformat — have no usable Windows toolchain (Nix/opam/Swift) and are left for nightly-CI. Wired into the nightly workflow alongside the tool and `--lsp` layers. (Note: the nightly run exercises whichever formatter tools it can install on the runner; standalone-binary formatters not auto-installed by pi-lens report ⚠ until a setup step is added.)
22
+
23
+ - **Tool-smoke harness now covers eight more toolchain-gated languages (refs #209)** — added live fixtures + harness entries for `zig` (zig-check), `java` (javac), `dart` (dart-analyze), `php` (php-lint), `ruby` (rubocop), `kotlin` (ktlint), `gleam` (gleam-check), and `elixir` (elixir-check), all **validated end-to-end on the Windows dev box** after installing the toolchains (JDK 21, Dart, Ruby 3.4 + MSYS2 devkit, Gleam, Zig, PHP 8.4, Erlang/OTP 29 + Elixir 1.20.1). Each produced a parseable diagnostic on its fixture's known defect. The gleam fixture is a minimal package (`gleam.toml` + `src/`) since `gleam check` compiles the whole project. This batch surfaced two genuinely-broken runners (see Fixed: #215, #216) — exactly the regression class the harness exists to catch.
24
+
25
+ - **Tool-smoke harness language expansion + LSP-install gap fix (refs #209)** — the dispatch tool-smoke fixtures now also install each kind's LSP server (not just the linter), so the lsp runner no longer spuriously `server_error`s for want of an uninstalled server. Added fixtures: `terraform` (tflint tool + terraform-ls LSP, both standalone); toolchain-gated `go` (go-vet), `powershell` (PSScriptAnalyzer), `rust` (rust-clippy tool + rust-analyzer LSP), and `csharp` (dotnet-build) — all four verified end-to-end on this box (Go/Rust/.NET/PowerShell toolchains present; rust-clippy → clippy::len_zero, dotnet-build → CS0029); plus LSP-handshake fixtures for `prisma` (@prisma/language-server — 2 diagnostics) and `php` (intelephense). Confirms the fallback→all fix end-to-end: terraform runs `lsp + tflint` together. The go fixture surfaced #214 (go-vet returns 0 diagnostics in dispatch though `go vet` reports them manually). Harness `--verbose` now prints each failed runner's `failureKind`/message so found-errors aren't misread as crashes.
26
+
27
+ - **LSP handshake layer in the tool-smoke harness (refs #209)** — `scripts/smoke-tools.mjs --lsp` drives the **same production entry the lsp runner uses** (`LSPService.touchFile`, with a generous cold-spawn budget) for each LSP fixture, so a pass means the real server installed, spawned, completed the JSON-RPC initialize handshake, and replied — not a hand-rolled handshake (the trap that false-failed typescript in the dropped smoke-lsp). Verified end-to-end for typescript-language-server, pyright, yaml-language-server, vscode-json-language-server, and bash-language-server (all handshook; yaml/json returned diagnostics). Shares the harness's startup temp-sweep and tears down spawned servers via `LSPService.shutdown()`.
28
+
29
+ ### Fixed
30
+
31
+ - **CI: `tool-discovery.test.ts` is now hermetic — no real GitHub-API fetch** — the `ensureTool force-reinstall` tests asserted on a post-download spawn, which required `installTool`'s real `node:https` GitHub-release fetch; in restricted CI (notably **dependabot PRs**) that fetch fails → 0 spawns → flaky red. `node:https` is now mocked (records the fetch, fails deterministically) and the test asserts the fetch was *attempted* (proves installTool was reached) rather than a network-dependent spawn.
32
+
33
+ - **LSP launch no longer logs scary "candidate failed / npm shim failed / Run npm install" lines when a later candidate succeeds** — `resolveAndLaunch` tries candidates in order (local `node_modules/.bin` → global PATH → managed install); each failure was logged immediately, so the common "no local install, fall back to global" path flooded the logs with failure lines that read as an LSP-availability smell even though the server launched fine. Failures are now **deferred** and surfaced only when *all* candidates fail (the all-failed case stays fully diagnosable). Guard: `resolve-and-launch-fallback.test.ts`.
34
+
35
+ - **ktlint now works on Windows — installer fetches the jar alongside ktlint.bat (closes #218)** — ktlint's Windows asset is `ktlint.bat`, a wrapper that runs `java -jar %~dp0ktlint`; the installer fetched only the `.bat`, so every invocation failed `Unable to access jarfile` (and the lint runner masked it — the error text became a fallback diagnostic that looked like a finding). The github install strategy gained an optional `extraAssets` hook; ktlint declares `["ktlint"]` (the jar) on win32, so both files now land in the managed bin. Verified end-to-end on Windows: ktlint lint emits real diagnostics and ktlint format (`-F`) reformats. Guards in `tool-registry-consistency.test.ts`: any win32 `.bat`/`.cmd` wrapper asset must declare `extraAssets`, plus a ktlint-specific check.
36
+
37
+ - **shfmt no longer nags on every unformatted shell write (closes #211)** — the shfmt runner reported a "not formatted" warning against shfmt's built-in defaults on every `.sh` write, even when the project never opted into shfmt formatting. The format-diff *warning* is now gated on a `.editorconfig` (shfmt's only config source) — out of the box shfmt reports only genuine **parse errors** (always-on, blocking); the formatting warning appears once a project opts in via `.editorconfig`. Guard: `shfmt.test.ts`.
38
+
39
+ - **shellcheck now surfaces `info`-level findings like SC2086 (closes #213)** — with no `.shellcheckrc`, the runner forced `--severity warning`, which dropped `info` findings entirely — including SC2086 (double-quote-to-prevent-globbing), a high-value, commonly-relevant check. Default is now `--severity info` (surfaces SC2086-class findings, mapped non-blocking) while still excluding pure `style` rules to limit noise; projects opt into `style` via `.shellcheckrc`. Guard updated in `shellcheck.test.ts`.
40
+
41
+ - **markdownlint produced 0 diagnostics — parser didn't match modern markdownlint-cli2 output (closes #212)** — `parseMarkdownlintOutput`'s regex expected the rule code immediately after `line[:col]`, but markdownlint-cli2 now emits a **severity token** (`error`/`warning`) in between (`file:1:1 error MD018/… msg`), and some rules carry **multiple** slash-separated names (`MD041/first-line-heading/first-line-h1`). Both made the regex miss every line → silent "succeeded, 0 diagnostics". The severity token is now optional (older/relative output still parses) and multi-segment rule codes are handled. (The issue's original "Windows abs-path glob" diagnosis was wrong — the file lints fine; the parser was the culprit, on every platform with current cli2.) Guard: a markdownlint-cli2-format case in `markdownlint-fixable.test.ts`.
42
+
43
+ - **ESLint autofix never applied fixes — keyed on `fixableErrorCount` from `--fix-dry-run` (closes #220)** — the pipeline's safe-autofix phase (`tryEslintFix`) ran `eslint --fix-dry-run --format json` and decided whether to apply fixes by summing `fixableErrorCount` + `fixableWarningCount`. But `--fix-dry-run` reports the **post-fix** state: when every problem is auto-fixable (the common case), ESLint clears `messages`, sets `fixableErrorCount: 0`, and puts the fixed source in the **`output`** field — so the count was 0 and eslint fixes were **never applied**. Now also treats a dry-run `output` field as a fix signal (apply `--fix` when `fixableCount > 0` *or* any result carries `output`). Guard: `pipeline-eslint-autofix.test.ts`. Found by the #209 `--autofix` layer (eslint v10.5.0).
44
+
45
+ - **`zig-check` never ran: availability probe used `zig --version`, which zig rejects (closes #215)** — the shared `createAvailabilityChecker` hard-coded a `--version` probe, but zig's version subcommand is `zig version` (`zig --version` → `error: unknown command: --version`, exit 1). So the probe always failed and zig-check silently skipped on **every** machine with zig installed. `createAvailabilityChecker` now takes an optional `versionArgs` (default `["--version"]`); zig-check passes `["version"]`. Guard: `runner-helpers.test.ts` asserts the override reaches the spawn. Found by the #209 harness (zig 0.16.0 reported `skipped` despite being on PATH).
46
+
47
+ - **`elixir-check` silently dropped all diagnostics on modern Elixir (closes #216)** — `parseElixirOutput` only understood the legacy diagnostic format, so on Elixir 1.16+ the runner was a no-op. Two bugs: (1) Elixir 1.16+ emits a multi-line "code snippet" format with the location on a trailing `└─ path:line:col` line, several lines after the `error:`/`warning:` header — the parser now forward-scans to that footer while keeping legacy support; (2) `elixirc` reports paths **relative to its cwd**, but the parser resolved them against `process.cwd()` instead of the runner cwd (and compared case-sensitively, breaking on Windows' lowercased drive letter) — `parseElixirOutput` now takes `cwd`, resolves against it, and matches case-insensitively on win32. Guard: `elixir-parser.test.ts` (modern error/warning, cwd-relative paths, legacy format, win32 drive-case). Found by the #209 harness (Elixir 1.20.1/OTP 29 ran clean but produced 0 diagnostics on a known compile error).
48
+
49
+ - **Windows: tools whose path contains a space now run (closes #214)** — `safeSpawnAsync`'s Windows `shell:true` path built the cmd.exe string by escaping only the **args**, not the command, so a tool resolved under a spaced path — e.g. Go at `C:\Program Files\Go\bin\go.exe` — made cmd.exe parse `C:\Program` as the command and fail with `'C:\Program' is not recognized`. This silently broke **any** such tool on Windows (npm/.pi-lens tool paths have no spaces, so it stayed hidden; the #209 harness exposed it via go-vet returning 0 diagnostics). The command is now escaped like the args (`buildWindowsShellCommand`, extracted + unit-tested); `cmdEscapeArg` is a no-op for space-free commands so the previously-working paths are unchanged. Found by the #209 tool-smoke harness; go-vet now reports diagnostics through dispatch.
50
+
51
+ - **`smart-default` linters no longer suppressed by the LSP in fallback dispatch groups (refs #209)** — the primary dispatch group for css/yaml/html/docker/toml/ruby/kotlin paired the `lsp` runner with the language's dedicated linter under `mode:"fallback"`, where the first success wins. Once the language server installed and handshook (now reliable), the LSP succeeded and the linter was **silently suppressed** — dropping rules the generic LSP never emits (yamllint style, stylelint, hadolint best-practices, htmlhint, ktlint, rubocop, taplo). Those linters are classified `smart-default` in tool-policy (designed to run with built-in defaults), and `shell`/`fish`/`powershell`/`prisma` already pair LSP+linter via `mode:"all"` — so this was an inconsistency, not intent. All seven groups are now `mode:"all"`; LSP↔linter duplicate diagnostics remain handled by `suppressLintOverlapsWithLsp` + dedup. A new guard (`tests/clients/dispatch/lsp-linter-coverage.test.ts`) fails if any `smart-default` linter ever sits behind the `lsp` in a fallback group again. Type-checker/compiler fallbacks (jsts lsp+ts-lsp, python lsp+pyright, csharp lsp+dotnet-build, …) are intentionally left as fallback. Tool-smoke harness gained css/html/toml/sql/dockerfile fixtures (+ css/html/docker/toml LSP fixtures) confirming each linter now executes alongside its LSP.
52
+
53
+ - **Wire `markdownlint` and `shfmt` into their dispatch plans — they were registered but never ran (refs #209)** — a new deterministic per-PR guard (`tests/clients/dispatch/dispatch-coverage.test.ts`) cross-checks every registered runner against the static plans (`TOOL_PLANS` ∪ `FULL_LINT_PLANS`) and fails if any runner is wired into no plan (the "markdownlint class": registered + installs + tested, but silently never dispatched) or if a plan references a phantom runner id. It immediately caught `markdownlint` (markdown's write group was only `["spellcheck","vale"]`, though its linter policy already preferred it) and `shfmt` (shell's group omitted it). Both are now in their plans, so `.md` writes get markdownlint structural lint and `.sh` writes get shfmt format-diff + parse-error checks (shfmt is check-only — never auto-applies). The live tool-smoke harness gained a `shell` fixture and confirms all three (markdownlint/shellcheck/shfmt) now execute through the real dispatch path.
54
+
55
+ ## [3.8.52] - 2026-06-14
56
+
57
+ ### Fixed
58
+
59
+ - **read-guard: canonicalize path map keys — stops false `zero_read` blocks (closes #210)** — `ReadGuard` keyed its `reads`/`edits`/`exemptions` maps on the **raw** file-path string, relying on the read-path and edit-path strings being byte-for-byte identical. `resolveToolCallFilePath` returns absolute paths verbatim, so the key was whatever separator/casing the model emitted — and models freely mix `/` and `\` on Windows. The regression trigger: read-guard started recording reads from new sources that produce a *different* path form than the Edit tool — `ast_grep_search` matches (#169, slash-normalized from ast-grep output) and LSP-expanded synthetic reads (URI → forward slash). On Windows a file read via search/LSP got a `C:/…` key while the follow-up edit arrived `C:\…` → key miss → false `zero_read` ("Edit without read") despite the file having been read, repeatedly, in a real session (`pi-free`: reads logged `C:/…`, the blocking edit `C:\…`). Every map access now keys through `normalizeFilePath` (folds separators + Windows casing), so record and lookup always agree. **Why it slipped:** every read-guard test used the *same* POSIX path on both `recordRead` and `checkEdit`, so the raw keys always matched — no test exercised cross-separator/cross-source agreement. Closed by `tests/clients/read-guard-path-normalization.test.ts` (forward↔back-slash both directions, Windows case-folding, exemption parity, and a negative: a genuinely-unread file still blocks).
60
+
61
+ ### Added
62
+
63
+ - **Live tool-smoke harness driving the real dispatch path (refs #209, layer 2)** — `scripts/smoke-tools.mjs` installs (via the real `ensureTool` auto-install) and runs each supported tool against a minimal real project per language (`tests/fixtures/tool-smoke/<lang>/`), driving pi-lens's **real** dispatch path so a smoke pass means the actual runner→spawn code worked (not a hand-rolled stand-in). Step 1 (default) asserts each target tool spawns and exits cleanly (no `timeout`/`exception`/`server_error`); Step 2 (`--step2`) additionally asserts a parseable diagnostic on the fixture's known defect. Per-runner truth comes from a new optional `onRunnerResult` sink threaded through `dispatchForFile`→`runGroup` (fires per executed runner with its exact `RunnerResult` incl. `failureKind`) exposed via `dispatchLintDetailed` — no duplication of dispatch's selection/gating. Opt-in/nightly (installs + spawns real tools), never a per-PR gate; not shipped in the npm tarball. Already surfaced a real wiring gap: `markdownlint` is registered (priority 30) and installs, but the markdown write-dispatch group is `["spellcheck","vale"]`, so it never runs on markdown writes.
64
+
65
+ - **Deterministic auto-install registry-consistency guard (refs #209, layer 1)** — the live install→run net for every supported tool is expensive and environment-dependent (deferred to layer 2); this catches the cheap-to-catch class per-PR. A new `tests/clients/installer/tool-registry-consistency.test.ts` exports the previously-private `TOOLS` array and locks the **install contract** that `installTool` silently depends on: each `npm` entry declares `packageName`+`binaryName`, each `pip`/`gem` entry declares `packageName`, each `github` entry declares an `owner/repo` + `assetMatch` + `binaryName` and no `packageName` — a half-wired entry compiles fine today but just `return false`s at install time, so it "looks registered" while never installing. It also asserts ids are globally unique, `checkCommand`/`binaryName` are clean executable tokens, and every `github` tool's `assetMatch` is total/safe (never throws across the platform×arch matrix incl. unsupported platforms, resolves at least one combo, rejects freebsd/sunos/aix). **Fixed a coverage drift it surfaced:** `GITHUB_TOOLS` (the curated list the asset-matrix value-test iterates) had drifted to 9 of the 14 actual `github`-strategy tools, leaving `hadolint`, `gitleaks`, `taplo`, and `vale` asset selection **completely untested** — they're now in `GITHUB_TOOLS` (so the full matrix test covers them), and a bidirectional sync assertion keeps the list ≡ "github tools with full cross-platform coverage" going forward (`swiftlint` is intentionally excluded — no Windows asset).
66
+
5
67
  ## [3.8.51] - 2026-06-14
6
68
 
7
69
  ### Added
@@ -389,7 +389,7 @@ export function formatLatencyReport(report) {
389
389
  * Groups themselves are run in parallel by dispatchForFile, so this
390
390
  * function must NOT mutate shared state.
391
391
  */
392
- async function runGroup(ctx, group, registry) {
392
+ async function runGroup(ctx, group, registry, onRunnerResult) {
393
393
  const diagnostics = [];
394
394
  const latencies = [];
395
395
  let hadBlocker = false;
@@ -458,6 +458,7 @@ async function runGroup(ctx, group, registry) {
458
458
  continue;
459
459
  }
460
460
  const result = await runRunner(ctx, runner, semantic);
461
+ onRunnerResult?.(runnerId, result);
461
462
  const runnerEnd = Date.now();
462
463
  const duration = runnerEnd - runnerStart;
463
464
  latencies.push({
@@ -508,7 +509,7 @@ async function runGroup(ctx, group, registry) {
508
509
  return { diagnostics, latencies, hadBlocker };
509
510
  }
510
511
  // --- Main Dispatch Function ---
511
- export async function dispatchForFile(ctx, groups, registry) {
512
+ export async function dispatchForFile(ctx, groups, registry, onRunnerResult) {
512
513
  const _overallStart = Date.now();
513
514
  if (ctx.fileRole === "generated") {
514
515
  return {
@@ -543,7 +544,7 @@ export async function dispatchForFile(ctx, groups, registry) {
543
544
  // each other's results. Within each group, mode:"fallback" semantics are
544
545
  // preserved (sequential first-success). Results are merged in original
545
546
  // group order so output is deterministic.
546
- const groupResults = await Promise.all(groups.map((group) => runGroup(ctx, group, registry)));
547
+ const groupResults = await Promise.all(groups.map((group) => runGroup(ctx, group, registry, onRunnerResult)));
547
548
  // Count baseline warnings before filtering (for delta count display)
548
549
  const relativeKey = path.relative(ctx.cwd, ctx.filePath).replace(/\\/g, "/");
549
550
  const baselineAbsKey = `session.baseline.${normalizeMapKey(ctx.filePath)}`;
@@ -1030,6 +1030,44 @@ export async function dispatchLintWithResult(filePath, cwd, pi, modifiedRanges,
1030
1030
  }
1031
1031
  return result;
1032
1032
  }
1033
+ /**
1034
+ * Same real dispatch path as {@link dispatchLintWithResult} (real context, real
1035
+ * file-kind→runner selection, real `run()` → spawn → tool), but also returns
1036
+ * each runner's exact `RunnerResult` (status + `failureKind` + diagnostics) via
1037
+ * the `onRunnerResult` sink. The live tool-smoke harness (#209) uses this to
1038
+ * assert each supported tool spawned and exited cleanly without re-implementing
1039
+ * dispatch's selection/gating. Defaults to `blockingOnly: false` so every
1040
+ * applicable runner (not just blocking ones) executes.
1041
+ */
1042
+ export async function dispatchLintDetailed(filePath, cwd, pi, options) {
1043
+ const empty = {
1044
+ diagnostics: [],
1045
+ blockers: [],
1046
+ warnings: [],
1047
+ baselineWarningCount: 0,
1048
+ fixed: [],
1049
+ resolvedCount: 0,
1050
+ output: "",
1051
+ blockerOutput: "",
1052
+ hasBlockers: false,
1053
+ };
1054
+ const ctx = createDispatchContext(filePath, cwd, pi, sessionFacts, options?.blockingOnly ?? false, options?.modifiedRanges);
1055
+ sessionFacts.clearFileFactsFor(ctx.filePath);
1056
+ const kind = ctx.kind;
1057
+ if (!kind)
1058
+ return { result: empty, runners: [] };
1059
+ const groups = withSemgrepGroup(kind, getDispatchGroupsForKind(kind, pi), ctx);
1060
+ if (groups.length === 0)
1061
+ return { result: empty, runners: [] };
1062
+ const runners = [];
1063
+ const sink = (runnerId, result) => {
1064
+ runners.push({ runnerId, result });
1065
+ };
1066
+ await runProviders(ctx);
1067
+ const result = await dispatchForFile(ctx, groups, sessionRunnerRegistry, sink);
1068
+ trackSessionSlopStats(ctx, result.diagnostics);
1069
+ return { result, runners };
1070
+ }
1033
1071
  /**
1034
1072
  * Check if a file should be processed by the dispatcher
1035
1073
  * based on the file kind
@@ -95,9 +95,10 @@ export const LANGUAGE_CAPABILITY_MATRIX = {
95
95
  },
96
96
  shell: {
97
97
  name: "Shell Script Linting",
98
- capabilities: ["lint", "security"],
98
+ capabilities: ["lint", "security", "format"],
99
99
  writeGroups: [
100
100
  primary("shell"),
101
+ { mode: "all", runnerIds: ["shfmt"], filterKinds: ["shell"] },
101
102
  { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["shell"] },
102
103
  ],
103
104
  },
@@ -109,7 +110,10 @@ export const LANGUAGE_CAPABILITY_MATRIX = {
109
110
  markdown: {
110
111
  name: "Markdown Processing",
111
112
  capabilities: ["docs", "format", "lint"],
112
- writeGroups: [primary("markdown")],
113
+ writeGroups: [
114
+ primary("markdown"),
115
+ { mode: "all", runnerIds: ["markdownlint"], filterKinds: ["markdown"] },
116
+ ],
113
117
  },
114
118
  css: {
115
119
  name: "CSS Processing",
@@ -80,7 +80,7 @@ const DETEKT_FIXABLE_RULES = new Set([
80
80
  "UnusedPrivateClass",
81
81
  "RedundantVisibilityModifierRule",
82
82
  ]);
83
- function findDetektConfig(cwd) {
83
+ export function findDetektConfig(cwd) {
84
84
  for (const candidate of DETEKT_CONFIG_CANDIDATES) {
85
85
  const full = path.join(cwd, candidate);
86
86
  if (fs.existsSync(full))
@@ -12,17 +12,48 @@ const elixirc = createAvailabilityChecker("elixirc", ".bat");
12
12
  function hasMixExs(cwd) {
13
13
  return fs.existsSync(path.join(cwd, "mix.exs"));
14
14
  }
15
- function parseElixirOutput(raw, filePath) {
15
+ // Elixir 1.16+ emits diagnostics in a multi-line "code snippet" format where
16
+ // the file:line:col lives on a trailing `└─ path:line:col` line, several lines
17
+ // after the `error:`/`warning:` header:
18
+ //
19
+ // warning: variable "x" is unused
20
+ // │
21
+ // 3 │ x = 1
22
+ // │ ~
23
+ // │
24
+ // └─ lib/foo.ex:3:5: Foo.bar/0
25
+ //
26
+ // Older Elixir put the location on the line immediately after `warning:` and
27
+ // reported compile errors as a single `** (Kind) path:line:col: message` line.
28
+ // We support both so the runner works across toolchain versions.
29
+ const ELIXIR_SNIPPET_LOCATION = /└─\s+(.+?):(\d+)(?::(\d+))?(?::|$)/;
30
+ function parseElixirOutput(raw, filePath, cwd = process.cwd()) {
16
31
  const diagnostics = [];
32
+ const resolvedTarget = path.resolve(cwd, filePath);
17
33
  const lines = raw.split(/\r?\n/);
34
+ // elixirc reports paths RELATIVE to its cwd (e.g. `bad.ex`, not the absolute
35
+ // path we passed), so resolve the reported path against the runner cwd — not
36
+ // process.cwd(). Elixir 1.16+ also normalizes to a lowercase drive letter and
37
+ // forward slashes (`c:/...`), which never string-equals `C:\...` on Windows,
38
+ // so compare case-insensitively there.
39
+ const matchesTarget = (sourcePath) => {
40
+ const resolved = path.resolve(cwd, sourcePath.trim());
41
+ return process.platform === "win32"
42
+ ? resolved.toLowerCase() === resolvedTarget.toLowerCase()
43
+ : resolved === resolvedTarget;
44
+ };
18
45
  for (let index = 0; index < lines.length; index++) {
19
46
  const line = lines[index];
47
+ // Defense-in-depth: cap line length before regex matching. The input is
48
+ // trusted, bounded compiler output and the patterns have no exponential
49
+ // backtracking, but this bounds worst-case work regardless (ReDoS guard).
50
+ if (line.length > 2000)
51
+ continue;
52
+ // Legacy one-line compile error: ** (CompileError) path:line:col: message
20
53
  const syntax = line.match(/^\*\* \(([^)]+)\)\s+(.+?):(\d+):(?:(\d+):)?\s*(.+)$/);
21
54
  if (syntax) {
22
55
  const [, kind, sourcePath, lineStr, colStr, message] = syntax;
23
- const resolvedSource = path.resolve(sourcePath.trim());
24
- const resolvedTarget = path.resolve(filePath);
25
- if (resolvedSource !== resolvedTarget)
56
+ if (!matchesTarget(sourcePath))
26
57
  continue;
27
58
  diagnostics.push({
28
59
  id: `elixir-check-${kind}-${lineStr}-${colStr || "1"}`,
@@ -38,27 +69,59 @@ function parseElixirOutput(raw, filePath) {
38
69
  });
39
70
  continue;
40
71
  }
41
- const warning = line.match(/^warning:\s+(.+)$/);
42
- if (!warning)
72
+ // error:/warning: header — may be bare (legacy) or indented (1.16+).
73
+ const header = line.match(/^\s*(error|warning):\s+(.+)$/);
74
+ if (!header)
75
+ continue;
76
+ const [, severityLabel, message] = header;
77
+ const severity = severityLabel === "error" ? "error" : "warning";
78
+ // New format: scan forward for the `└─ path:line:col` snippet footer.
79
+ let located = false;
80
+ for (let lookahead = index + 1; lookahead < lines.length && lookahead <= index + 12; lookahead++) {
81
+ const next = lines[lookahead];
82
+ // A blank gap or another header ends this diagnostic block.
83
+ if (/^\s*(error|warning):\s+/.test(next))
84
+ break;
85
+ const snippet = next.match(ELIXIR_SNIPPET_LOCATION);
86
+ if (snippet) {
87
+ const [, sourcePath, lineStr, colStr] = snippet;
88
+ if (matchesTarget(sourcePath)) {
89
+ diagnostics.push({
90
+ id: `elixir-check-${severity}-${lineStr}-${colStr || "1"}`,
91
+ message: severity === "error" ? `[error] ${message.trim()}` : message.trim(),
92
+ filePath,
93
+ line: Number.parseInt(lineStr, 10) || 1,
94
+ column: Number.parseInt(colStr || "1", 10) || 1,
95
+ severity,
96
+ semantic: severity === "error" ? "blocking" : "warning",
97
+ tool: "elixir-check",
98
+ rule: severity === "error" ? "error" : "warning",
99
+ fixable: false,
100
+ });
101
+ }
102
+ located = true;
103
+ break;
104
+ }
105
+ }
106
+ if (located)
43
107
  continue;
108
+ // Legacy format: location on the immediately following line.
44
109
  const location = lines[index + 1]?.match(/^\s+(.+?):(\d+):(?:(\d+):)?$/);
45
110
  if (!location)
46
111
  continue;
47
112
  const [, sourcePath, lineStr, colStr] = location;
48
- const resolvedSource = path.resolve(sourcePath.trim());
49
- const resolvedTarget = path.resolve(filePath);
50
- if (resolvedSource !== resolvedTarget)
113
+ if (!matchesTarget(sourcePath))
51
114
  continue;
52
115
  diagnostics.push({
53
- id: `elixir-check-warning-${lineStr}-${colStr || "1"}`,
54
- message: warning[1].trim(),
116
+ id: `elixir-check-${severity}-${lineStr}-${colStr || "1"}`,
117
+ message: severity === "error" ? `[error] ${message.trim()}` : message.trim(),
55
118
  filePath,
56
119
  line: Number.parseInt(lineStr, 10) || 1,
57
120
  column: Number.parseInt(colStr || "1", 10) || 1,
58
- severity: "warning",
59
- semantic: "warning",
121
+ severity,
122
+ semantic: severity === "error" ? "blocking" : "warning",
60
123
  tool: "elixir-check",
61
- rule: "warning",
124
+ rule: severity === "error" ? "error" : "warning",
62
125
  fixable: false,
63
126
  });
64
127
  }
@@ -102,7 +165,7 @@ const elixirCheckRunner = {
102
165
  return { status: "skipped", diagnostics: [], semantic: "none" };
103
166
  }
104
167
  const raw = `${result.stderr || ""}\n${result.stdout || ""}`;
105
- const diagnostics = parseElixirOutput(raw, ctx.filePath);
168
+ const diagnostics = parseElixirOutput(raw, ctx.filePath, cwd);
106
169
  if (diagnostics.length === 0) {
107
170
  if (result.status && result.status !== 0) {
108
171
  return {
@@ -134,3 +197,4 @@ const elixirCheckRunner = {
134
197
  },
135
198
  };
136
199
  export default elixirCheckRunner;
200
+ export { parseElixirOutput };
@@ -41,14 +41,22 @@ const MARKDOWNLINT_FIXABLE_RULES = new Set([
41
41
  "MD053",
42
42
  "MD058",
43
43
  ]);
44
- // markdownlint-cli output: path/to/file.md:10:3 MD013/line-length Line length
44
+ // markdownlint-cli2 output: `path:line[:col] [error|warning] MD###/name[/name…] message`
45
+ // Two things the original parser missed (→ silent 0 diagnostics, #212):
46
+ // 1. cli2 emits a severity token (`error`/`warning`) between the col and the
47
+ // rule code — older markdownlint-cli did not.
48
+ // 2. some rules carry MULTIPLE slash-separated names (e.g.
49
+ // `MD041/first-line-heading/first-line-h1`).
50
+ // The severity token is optional so older/relative-path output still parses.
45
51
  function parseMarkdownlintOutput(raw, filePath) {
46
52
  const diagnostics = [];
47
53
  for (const line of raw.split(/\r?\n/)) {
48
54
  if (!line.trim())
49
55
  continue;
50
- // Format: filePath:line[:col] ruleCode/ruleName message
51
- const match = line.match(/^.*?:(\d+)(?::(\d+))?\s+(MD\d+\/[\w-]+)\s+(.+)$/);
56
+ // Rule code is MD### followed by one or more slash-joined names. Use a
57
+ // single char class (`[\w/-]+`) rather than a nested quantifier
58
+ // (`(?:/[\w-]+)+`) so there's no super-linear backtracking (S5852).
59
+ const match = line.match(/^.*?:(\d+)(?::(\d+))?\s+(?:error|warning)?\s*(MD\d+\/[\w/-]+)\s+(.+)$/);
52
60
  if (!match)
53
61
  continue;
54
62
  const [, lineNum, col, ruleCode, message] = match;
@@ -133,9 +133,11 @@ const shellcheckRunner = {
133
133
  // Check for config file
134
134
  const configPath = findShellcheckConfig(ctx.cwd);
135
135
  if (!configPath) {
136
- // No config file, use default settings
137
- // Exclude "style" and "info" by default to reduce noise
138
- args.push("--severity", "warning");
136
+ // No config file: surface `info`-level findings (e.g. SC2086
137
+ // double-quote-to-prevent-globbing a high-value, commonly-relevant
138
+ // check that was previously dropped, #213) while still excluding pure
139
+ // `style` rules to limit noise. Projects opt into style via .shellcheckrc.
140
+ args.push("--severity", "info");
139
141
  }
140
142
  args.push(ctx.filePath);
141
143
  const result = await safeSpawnAsync(cmd, args, { timeout: 15000 });
@@ -1,8 +1,26 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { ensureTool } from "../../installer/index.js";
2
4
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
5
  import { PRIORITY } from "../priorities.js";
4
6
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
5
7
  const shfmt = createAvailabilityChecker("shfmt", ".exe");
8
+ // shfmt's only config source is .editorconfig. We treat its presence as the
9
+ // opt-in for the (non-error) format-diff warning, so out of the box shfmt only
10
+ // reports genuine parse errors instead of nagging every unformatted shell file
11
+ // against shfmt's built-in defaults (#211).
12
+ function hasEditorConfig(cwd) {
13
+ let current = path.resolve(cwd);
14
+ while (true) {
15
+ if (fs.existsSync(path.join(current, ".editorconfig")))
16
+ return true;
17
+ const parent = path.dirname(current);
18
+ if (parent === current)
19
+ break;
20
+ current = parent;
21
+ }
22
+ return false;
23
+ }
6
24
  /**
7
25
  * shfmt runner — checks shell script formatting.
8
26
  * Reports files that differ from shfmt's canonical output as a single warning.
@@ -58,7 +76,13 @@ const shfmtRunner = {
58
76
  ];
59
77
  return { status: "failed", diagnostics, semantic: "blocking" };
60
78
  }
61
- // Needs formatting extract first changed line from diff if possible
79
+ // Needs formatting (exit 1). Only warn if the project opted into shfmt via
80
+ // .editorconfig — otherwise this nags on every shell write against shfmt's
81
+ // defaults (#211). Parse errors (above) are always reported.
82
+ if (!hasEditorConfig(cwd)) {
83
+ return { status: "succeeded", diagnostics: [], semantic: "none" };
84
+ }
85
+ // Extract first changed line from diff if possible
62
86
  const diffOutput = result.stdout ?? result.stderr ?? "";
63
87
  let line = 1;
64
88
  const lineMatch = diffOutput.match(/^@@\s+-(\d+)/m);
@@ -64,8 +64,14 @@ export function createVenvFinder(command, windowsExt = "", quoteWindows = false)
64
64
  /**
65
65
  * Create a cached availability checker for a command.
66
66
  * The checker will look for the command in venv first, then global.
67
+ *
68
+ * `versionArgs` defaults to `["--version"]` but some tools reject that flag and
69
+ * expose version under a subcommand instead (e.g. `zig version`, not
70
+ * `zig --version`). Passing the wrong probe makes the runner silently skip on
71
+ * every machine, so toolchains with a non-standard version command must override
72
+ * this.
67
73
  */
68
- export function createAvailabilityChecker(command, windowsExt = "") {
74
+ export function createAvailabilityChecker(command, windowsExt = "", versionArgs = ["--version"]) {
69
75
  const cacheByCwd = new Map();
70
76
  const inFlightByCwd = new Map();
71
77
  const findCommand = createVenvFinder(command, windowsExt, true);
@@ -89,7 +95,7 @@ export function createAvailabilityChecker(command, windowsExt = "") {
89
95
  return existing;
90
96
  const promise = (async () => {
91
97
  const cmd = findCommand(resolvedCwd);
92
- const result = await safeSpawnAsync(cmd, ["--version"], {
98
+ const result = await safeSpawnAsync(cmd, versionArgs, {
93
99
  timeout: 5000,
94
100
  });
95
101
  cache.available = !result.error && result.status === 0;
@@ -2,7 +2,9 @@ import * as path from "node:path";
2
2
  import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
4
4
  import { PRIORITY } from "../priorities.js";
5
- const zig = createAvailabilityChecker("zig", ".exe");
5
+ // zig rejects `--version`; the version subcommand is `zig version`. Using the
6
+ // default probe would make this runner skip on every machine.
7
+ const zig = createAvailabilityChecker("zig", ".exe", ["version"]);
6
8
  function parseZigOutput(raw, filePath) {
7
9
  const diagnostics = [];
8
10
  for (const line of raw.split(/\r?\n/)) {
@@ -12,7 +12,7 @@ import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
13
  import { logLatency } from "./latency-logger.js";
14
14
  import { safeSpawnAsync } from "./safe-spawn.js";
15
- import { getAutoInstallToolIdForFormatter, getFormatterPolicyForFile, getSmartDefaultFormatterName, hasBiomeConfig, hasBlackConfig, hasClangFormatConfig, hasCljfmtConfig, hasCmakeFormatConfig, hasGoogleJavaFormatConfig, hasNearestPackageJsonDependency, hasNearestPackageJsonField, hasOcamlformatConfig, hasOxfmtConfig, hasPhpCsFixerConfig, hasPrettierConfig, hasRubocopConfig, hasRuffConfig, hasSqlfluffConfig, hasStandardrbConfig, hasStyluaConfig, hasVitePlusConfig, } from "./tool-policy.js";
15
+ import { getAutoInstallToolIdForFormatter, getFormatterPolicyForFile, getSmartDefaultFormatterName, hasBiomeConfig, hasBlackConfig, hasClangFormatConfig, hasCljfmtConfig, hasCmakeFormatConfig, hasGoogleJavaFormatConfig, hasKtfmtConfig, hasNearestPackageJsonDependency, hasNearestPackageJsonField, hasOcamlformatConfig, hasOxfmtConfig, hasPhpCsFixerConfig, hasPrettierConfig, hasRubocopConfig, hasRuffConfig, hasSqlfluffConfig, hasStandardrbConfig, hasStyluaConfig, hasVitePlusConfig, } from "./tool-policy.js";
16
16
  const _lazyInstallAttempts = new Set();
17
17
  export async function tryLazyInstallFormatterTool(tool, cwd) {
18
18
  const attemptKey = `${tool}:${cwd}`;
@@ -197,6 +197,9 @@ function hasExplicitFormatterConfig(formatterName, cwd) {
197
197
  case "oxfmt":
198
198
  return (hasOxfmtConfig(cwd) ||
199
199
  hasVitePlusConfig(cwd) ||
200
+ // The published package is `oxfmt`; `@oxc-project/oxfmt` does not
201
+ // exist on npm. Accept both (scoped kept for forward-compat).
202
+ hasNearestPackageJsonDependency(cwd, "oxfmt") ||
200
203
  hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt"));
201
204
  case "ruff":
202
205
  return hasRuffConfig(cwd);
@@ -218,6 +221,8 @@ function hasExplicitFormatterConfig(formatterName, cwd) {
218
221
  return hasOcamlformatConfig(cwd);
219
222
  case "google-java-format":
220
223
  return hasGoogleJavaFormatConfig(cwd);
224
+ case "ktfmt":
225
+ return hasKtfmtConfig(cwd);
221
226
  case "cljfmt":
222
227
  return hasCljfmtConfig(cwd);
223
228
  case "cmake-format":
@@ -355,6 +360,8 @@ export const oxfmtFormatter = {
355
360
  async detect(cwd) {
356
361
  return (hasOxfmtConfig(cwd) ||
357
362
  hasVitePlusConfig(cwd) ||
363
+ // Published package is `oxfmt` (the scoped name does not exist on npm).
364
+ hasNearestPackageJsonDependency(cwd, "oxfmt") ||
358
365
  hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt"));
359
366
  },
360
367
  };
@@ -536,6 +543,25 @@ export const ktlintFormatter = {
536
543
  return Boolean(await getToolPath("ktlint"));
537
544
  },
538
545
  };
546
+ export const ktfmtFormatter = {
547
+ name: "ktfmt",
548
+ // ktfmt formats in place when given a file path (no flag needed).
549
+ command: ["ktfmt", "$FILE"],
550
+ extensions: [".kt", ".kts"],
551
+ async resolveCommand(filePath, _cwd) {
552
+ const inPath = await which("ktfmt");
553
+ if (inPath)
554
+ return [inPath, filePath];
555
+ const { ensureTool } = await import("./installer/index.js");
556
+ const installed = await ensureTool("ktfmt");
557
+ return installed ? [installed, filePath] : null;
558
+ },
559
+ async detect(cwd) {
560
+ // Opt-in only: ktfmt becomes the formatter when the project elects it,
561
+ // otherwise ktlint stays the Kotlin smart-default (#129).
562
+ return hasKtfmtConfig(cwd);
563
+ },
564
+ };
539
565
  export const rubocopFormatter = {
540
566
  name: "rubocop",
541
567
  command: ["rubocop", "-a", "--no-color", "$FILE"],
@@ -612,10 +638,32 @@ export const phpCsFixerFormatter = {
612
638
  };
613
639
  export const csharpierFormatter = {
614
640
  name: "csharpier",
615
- command: ["dotnet", "csharpier", "$FILE"],
641
+ // CSharpier ≥1.0 is a standalone `csharpier format <file>`; the `dotnet
642
+ // csharpier <file>` form was removed (a bare `dotnet csharpier` now errors
643
+ // "a dotnet-prefixed executable with this name could not be found"). Keep the
644
+ // legacy form as a fallback for CSharpier 0.x via resolveCommand.
645
+ command: ["csharpier", "format", "$FILE"],
616
646
  extensions: [".cs"],
647
+ async resolveCommand(filePath, _cwd) {
648
+ if ((await which("csharpier")) !== null) {
649
+ return ["csharpier", "format", filePath];
650
+ }
651
+ // CSharpier 0.x: invoked through the dotnet driver.
652
+ if ((await which("dotnet")) !== null) {
653
+ const legacy = await safeSpawnAsync("dotnet", ["csharpier", "--version"], {
654
+ timeout: 5000,
655
+ });
656
+ if (!legacy.error && legacy.status === 0) {
657
+ return ["dotnet", "csharpier", filePath];
658
+ }
659
+ }
660
+ return null;
661
+ },
617
662
  async detect(_cwd) {
618
- // Check dotnet is available AND csharpier tool is installed
663
+ // CSharpier ≥1.0 standalone binary
664
+ if ((await which("csharpier")) !== null)
665
+ return true;
666
+ // … or the legacy dotnet-driver form (CSharpier 0.x).
619
667
  if ((await which("dotnet")) === null)
620
668
  return false;
621
669
  const result = await safeSpawnAsync("dotnet", ["csharpier", "--version"], {
@@ -758,6 +806,7 @@ const ALL_FORMATTERS = [
758
806
  ocamlformatFormatter,
759
807
  clangFormatFormatter,
760
808
  ktlintFormatter,
809
+ ktfmtFormatter,
761
810
  terraformFormatter,
762
811
  phpCsFixerFormatter,
763
812
  csharpierFormatter,