pi-lens 3.8.52 → 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 +50 -0
- package/dist/clients/dispatch/plan.js +6 -2
- package/dist/clients/dispatch/runners/detekt.js +1 -1
- package/dist/clients/dispatch/runners/elixir-check.js +79 -15
- package/dist/clients/dispatch/runners/markdownlint.js +11 -3
- package/dist/clients/dispatch/runners/shellcheck.js +5 -3
- package/dist/clients/dispatch/runners/shfmt.js +25 -1
- package/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
- package/dist/clients/dispatch/runners/zig-check.js +3 -1
- package/dist/clients/formatters.js +52 -3
- package/dist/clients/installer/index.js +116 -10
- package/dist/clients/language-policy.js +7 -7
- package/dist/clients/lsp/client.js +3 -1
- package/dist/clients/lsp/server.js +27 -15
- package/dist/clients/pipeline.js +127 -5
- package/dist/clients/safe-spawn.js +17 -1
- package/dist/clients/tool-policy.js +146 -9
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
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
|
+
|
|
5
55
|
## [3.8.52] - 2026-06-14
|
|
6
56
|
|
|
7
57
|
### Fixed
|
|
@@ -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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
message:
|
|
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
|
|
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-
|
|
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
|
-
//
|
|
51
|
-
|
|
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
|
|
137
|
-
//
|
|
138
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|