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.
@@ -472,8 +472,10 @@ export const TOOLS = [
472
472
  installStrategy: "github",
473
473
  binaryName: "ktlint",
474
474
  github: {
475
- // ktlint ships one universal binary "ktlint" for Linux/macOS (GraalVM native)
476
- // and "ktlint.bat" for Windows (requires Java). No arm64-specific asset.
475
+ // ktlint ships a self-executable `ktlint` (a JAR with a shell preamble)
476
+ // for Linux/macOS, plus a `ktlint.bat` wrapper for Windows that runs
477
+ // `java -jar %~dp0ktlint`. On Windows BOTH files are needed: the .bat AND
478
+ // the `ktlint` jar it wraps (#218). No arm64-specific asset.
477
479
  repo: "pinterest/ktlint",
478
480
  assetMatch: (platform, _arch) => {
479
481
  if (platform === "linux")
@@ -484,6 +486,24 @@ export const TOOLS = [
484
486
  return "ktlint.bat";
485
487
  return undefined;
486
488
  },
489
+ extraAssets: (platform) => (platform === "win32" ? ["ktlint"] : []),
490
+ },
491
+ },
492
+ {
493
+ // ktfmt (Meta's opinionated Kotlin formatter) ships only as a Maven-Central
494
+ // fat JAR — no native binary, no npm package — so it uses the maven strategy
495
+ // (#129). Run via a `java -jar` launcher; requires a JRE.
496
+ id: "ktfmt",
497
+ name: "ktfmt",
498
+ checkCommand: "ktfmt",
499
+ checkArgs: ["--version"],
500
+ installStrategy: "maven",
501
+ binaryName: "ktfmt",
502
+ maven: {
503
+ groupId: "com.facebook",
504
+ artifactId: "ktfmt",
505
+ version: "0.63",
506
+ classifier: "with-dependencies",
487
507
  },
488
508
  },
489
509
  {
@@ -959,12 +979,13 @@ export async function getAllToolStatuses() {
959
979
  continue;
960
980
  }
961
981
  }
962
- // 4. Check GitHub releases (~/.pi-lens/bin/)
963
- if (tool.installStrategy === "github") {
982
+ // 4. Check managed bin (~/.pi-lens/bin/) — github releases + maven launchers
983
+ if (tool.installStrategy === "github" || tool.installStrategy === "maven") {
964
984
  const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
965
985
  if (githubPath) {
966
986
  status.installed = true;
967
- status.source = "github-release";
987
+ status.source =
988
+ tool.installStrategy === "maven" ? "maven-jar" : "github-release";
968
989
  status.path = githubPath;
969
990
  statuses.push(status);
970
991
  continue;
@@ -1048,11 +1069,11 @@ export async function getToolPath(toolId) {
1048
1069
  catch {
1049
1070
  // fall through to global checks
1050
1071
  }
1051
- // For github-strategy tools, prefer managed install (~/.pi-lens/bin/) over PATH.
1052
- // Managed installs are known-good binaries that pi-lens downloaded as a fallback
1053
- // when a PATH-resolved tool was broken or missing. Checking before PATH ensures
1054
- // force-reinstall flows find the newly downloaded binary.
1055
- if (tool.installStrategy === "github") {
1072
+ // For github/maven tools, prefer the managed install (~/.pi-lens/bin/) over
1073
+ // PATH. Managed installs are known-good binaries/launchers pi-lens downloaded
1074
+ // as a fallback when a PATH-resolved tool was broken or missing. Checking
1075
+ // before PATH ensures force-reinstall flows find the newly downloaded binary.
1076
+ if (tool.installStrategy === "github" || tool.installStrategy === "maven") {
1056
1077
  const githubPath = await findGitHubToolPath(tool.binaryName || tool.id);
1057
1078
  if (githubPath)
1058
1079
  return githubPath;
@@ -1447,6 +1468,27 @@ async function installGitHubTool(tool) {
1447
1468
  logSessionStart(`github-install ${tool.id}: install failed: ${err.message}`);
1448
1469
  return undefined;
1449
1470
  }
1471
+ // Download any sibling assets the primary wrapper depends on (e.g. ktlint's
1472
+ // `ktlint` jar next to `ktlint.bat`, #218). Matched by EXACT name and written
1473
+ // as bare files into the same dir; a missing one fails the install.
1474
+ for (const extraName of spec.extraAssets?.(platform, arch) ?? []) {
1475
+ const extraAsset = releaseJson.assets.find((a) => a.name === extraName);
1476
+ if (!extraAsset) {
1477
+ logSessionStart(`github-install ${tool.id}: required extra asset "${extraName}" not found`);
1478
+ return undefined;
1479
+ }
1480
+ try {
1481
+ const extraBuffer = await httpsGet(extraAsset.browser_download_url);
1482
+ await fs.writeFile(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, {
1483
+ mode: 0o755,
1484
+ });
1485
+ logSessionStart(`github-install ${tool.id}: installed extra asset ${extraName} (${extraBuffer.length} bytes)`);
1486
+ }
1487
+ catch (err) {
1488
+ logSessionStart(`github-install ${tool.id}: extra asset ${extraName} download failed: ${err.message}`);
1489
+ return undefined;
1490
+ }
1491
+ }
1450
1492
  debugLog(`[github] installed ${tool.name} → ${destPath}`);
1451
1493
  logSessionStart(`github-install ${tool.id}: installed → ${destPath}`);
1452
1494
  return destPath;
@@ -1482,6 +1524,62 @@ const NEEDS_POSTINSTALL = new Set([
1482
1524
  "esbuild",
1483
1525
  "intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
1484
1526
  ]);
1527
+ const MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2";
1528
+ /**
1529
+ * Install a Maven-distributed runnable fat JAR: download it into the managed bin
1530
+ * and write a `java -jar` launcher next to it (so it resolves like any managed
1531
+ * binary via findGitHubToolPath). Requires a JRE — gated on `java` availability.
1532
+ */
1533
+ async function installMavenTool(tool) {
1534
+ const spec = tool.maven;
1535
+ if (!spec)
1536
+ return undefined;
1537
+ const binaryName = tool.binaryName ?? tool.id;
1538
+ const isWindows = process.platform === "win32";
1539
+ if (!(await isCommandAvailable("java", ["-version"]))) {
1540
+ logSessionStart(`maven-install ${tool.id}: java not found — a JAR tool can't run without a JRE`);
1541
+ return undefined;
1542
+ }
1543
+ // Strip trailing slashes without a regex (the `\/+$` form trips ReDoS
1544
+ // scanners — S5852 — even though the input is a trusted constant/registry
1545
+ // value). A plain loop is unambiguously linear.
1546
+ let base = spec.repoBaseUrl ?? MAVEN_CENTRAL_BASE;
1547
+ while (base.endsWith("/"))
1548
+ base = base.slice(0, -1);
1549
+ const groupPath = spec.groupId.replace(/\./g, "/");
1550
+ const jarFile = `${spec.artifactId}-${spec.version}${spec.classifier ? `-${spec.classifier}` : ""}.jar`;
1551
+ const url = `${base}/${groupPath}/${spec.artifactId}/${spec.version}/${jarFile}`;
1552
+ logSessionStart(`maven-install ${tool.id}: downloading ${url}`);
1553
+ let jarBuffer;
1554
+ try {
1555
+ jarBuffer = await httpsGet(url);
1556
+ }
1557
+ catch (err) {
1558
+ logSessionStart(`maven-install ${tool.id}: download failed: ${err.message}`);
1559
+ return undefined;
1560
+ }
1561
+ try {
1562
+ await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
1563
+ const jarPath = path.join(GITHUB_BIN_DIR, `${tool.id}.jar`);
1564
+ await fs.writeFile(jarPath, jarBuffer);
1565
+ // Launcher so the tool resolves as a normal command in the managed bin.
1566
+ const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
1567
+ const launcherPath = path.join(GITHUB_BIN_DIR, launcherName);
1568
+ if (isWindows) {
1569
+ await fs.writeFile(launcherPath, `@echo off\r\njava -jar "%~dp0${tool.id}.jar" %*\r\n`);
1570
+ }
1571
+ else {
1572
+ await fs.writeFile(launcherPath, `#!/bin/sh\nexec java -jar "$(dirname "$0")/${tool.id}.jar" "$@"\n`, { mode: 0o755 });
1573
+ }
1574
+ logSessionStart(`maven-install ${tool.id}: installed → ${launcherPath} (${jarBuffer.length} bytes)`);
1575
+ debugLog(`[maven] installed ${tool.name} → ${launcherPath}`);
1576
+ return launcherPath;
1577
+ }
1578
+ catch (err) {
1579
+ logSessionStart(`maven-install ${tool.id}: install failed: ${err.message}`);
1580
+ return undefined;
1581
+ }
1582
+ }
1485
1583
  async function installNpmTool(packageName, binaryName) {
1486
1584
  try {
1487
1585
  // Ensure tools directory exists
@@ -1798,6 +1896,14 @@ export async function installTool(toolId) {
1798
1896
  logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
1799
1897
  return ok;
1800
1898
  }
1899
+ case "maven": {
1900
+ if (!tool.maven)
1901
+ return false;
1902
+ const mavenPath = await installMavenTool(tool);
1903
+ const ok = mavenPath !== undefined;
1904
+ logSessionStart(`auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
1905
+ return ok;
1906
+ }
1801
1907
  default:
1802
1908
  logSessionStart(`auto-install ${tool.id}: unsupported strategy`);
1803
1909
  return false;
@@ -82,7 +82,7 @@ const PRIMARY_DISPATCH_GROUPS = {
82
82
  filterKinds: ["rust"],
83
83
  },
84
84
  ruby: {
85
- mode: "fallback",
85
+ mode: "all",
86
86
  runnerIds: ["lsp", "rubocop"],
87
87
  filterKinds: ["ruby"],
88
88
  },
@@ -109,12 +109,12 @@ const PRIMARY_DISPATCH_GROUPS = {
109
109
  filterKinds: ["markdown"],
110
110
  },
111
111
  css: {
112
- mode: "fallback",
112
+ mode: "all",
113
113
  runnerIds: ["lsp", "stylelint"],
114
114
  filterKinds: ["css"],
115
115
  },
116
116
  yaml: {
117
- mode: "fallback",
117
+ mode: "all",
118
118
  runnerIds: ["lsp", "yamllint"],
119
119
  filterKinds: ["yaml"],
120
120
  },
@@ -124,12 +124,12 @@ const PRIMARY_DISPATCH_GROUPS = {
124
124
  filterKinds: ["sql"],
125
125
  },
126
126
  html: {
127
- mode: "fallback",
127
+ mode: "all",
128
128
  runnerIds: ["lsp", "htmlhint"],
129
129
  filterKinds: ["html"],
130
130
  },
131
131
  docker: {
132
- mode: "fallback",
132
+ mode: "all",
133
133
  runnerIds: ["lsp", "hadolint"],
134
134
  filterKinds: ["docker"],
135
135
  },
@@ -160,7 +160,7 @@ const PRIMARY_DISPATCH_GROUPS = {
160
160
  filterKinds: ["java"],
161
161
  },
162
162
  kotlin: {
163
- mode: "fallback",
163
+ mode: "all",
164
164
  runnerIds: ["lsp", "ktlint"],
165
165
  filterKinds: ["kotlin"],
166
166
  },
@@ -196,7 +196,7 @@ const PRIMARY_DISPATCH_GROUPS = {
196
196
  },
197
197
  nix: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["nix"] },
198
198
  toml: {
199
- mode: "fallback",
199
+ mode: "all",
200
200
  runnerIds: ["lsp", "taplo"],
201
201
  filterKinds: ["toml"],
202
202
  },
@@ -12,7 +12,9 @@ import { EventEmitter } from "node:events";
12
12
  import { access } from "node:fs/promises";
13
13
  import { pathToFileURL } from "node:url";
14
14
  import { logLatency } from "../latency-logger.js";
15
- import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js";
15
+ // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
16
+ // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
17
+ import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node";
16
18
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
17
19
  import { getStrategy } from "./server-strategies.js";
18
20
  // --- Constants ---
@@ -71,7 +71,7 @@ function logSessionStart(message) {
71
71
  // best-effort logging
72
72
  });
73
73
  }
74
- async function resolveAndLaunch(spec, allowInstall) {
74
+ export async function resolveAndLaunch(spec, allowInstall) {
75
75
  const toolLabel = spec.managedToolId ??
76
76
  spec.candidates[spec.candidates.length - 1] ??
77
77
  "unknown";
@@ -82,6 +82,12 @@ async function resolveAndLaunch(spec, allowInstall) {
82
82
  lastRuntimeFailure = err instanceof Error ? err : new Error(message);
83
83
  }
84
84
  };
85
+ // A candidate that fails while a LATER candidate (or managed install)
86
+ // succeeds is just fallback, not a failure — logging each immediately floods
87
+ // the logs with scary "candidate failed / npm shim failed / Run npm install"
88
+ // lines that read as smells even though the launch succeeded. Collect them and
89
+ // surface only if ALL direct candidates fail.
90
+ const candidateFailures = [];
85
91
  // Step 1 & 2 — try all explicit candidates (includes bare command = PATH lookup)
86
92
  for (const [index, command] of spec.candidates.entries()) {
87
93
  logLatency({
@@ -120,23 +126,29 @@ async function resolveAndLaunch(spec, allowInstall) {
120
126
  }
121
127
  catch (err) {
122
128
  const message = err instanceof Error ? err.message : String(err);
123
- logLatency({
124
- type: "phase",
125
- phase: "lsp_launch_candidate_failed",
126
- filePath: spec.cwd,
127
- durationMs: 0,
128
- metadata: {
129
- tool: toolLabel,
130
- command,
131
- index,
132
- error: message,
133
- },
134
- });
135
- logSessionStart(`lsp launch candidate failed tool=${toolLabel} idx=${index} command=${command} error=${message}`);
136
- trackRuntimeFailure(err);
129
+ // Defer logging: only a failure if no later candidate/install succeeds.
130
+ candidateFailures.push({ index, command, message, err });
137
131
  // try next
138
132
  }
139
133
  }
134
+ // All direct candidates failed (a successful one returns above). Surface the
135
+ // deferred failures now so the all-failed case stays fully diagnosable.
136
+ for (const failure of candidateFailures) {
137
+ logLatency({
138
+ type: "phase",
139
+ phase: "lsp_launch_candidate_failed",
140
+ filePath: spec.cwd,
141
+ durationMs: 0,
142
+ metadata: {
143
+ tool: toolLabel,
144
+ command: failure.command,
145
+ index: failure.index,
146
+ error: failure.message,
147
+ },
148
+ });
149
+ logSessionStart(`lsp launch candidate failed tool=${toolLabel} idx=${failure.index} command=${failure.command} error=${failure.message}`);
150
+ trackRuntimeFailure(failure.err);
151
+ }
140
152
  if (!canInstall(allowInstall)) {
141
153
  logSessionStart(`lsp launch install blocked tool=${toolLabel} cwd=${spec.cwd} allowInstall=${allowInstall !== false} globalDisabled=${isLspInstallDisabled()}`);
142
154
  logLatency({
@@ -21,7 +21,8 @@ import { getDiagnosticLogger } from "./diagnostic-logger.js";
21
21
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
22
22
  import { computeCascadeForFile, dispatchLintWithResult, } from "./dispatch/integration.js";
23
23
  import { toRunnerDisplayPath } from "./dispatch/runner-context.js";
24
- import { resolveCommandArgsWithInstallFallback, resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./dispatch/runners/utils/runner-helpers.js";
24
+ import { createAvailabilityChecker, resolveAvailableOrInstall, resolveCommandArgsWithInstallFallback, resolveToolCommand, resolveToolCommandWithInstallFallback, } from "./dispatch/runners/utils/runner-helpers.js";
25
+ import { findDetektConfig } from "./dispatch/runners/detekt.js";
25
26
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
26
27
  import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
27
28
  import { logLatency } from "./latency-logger.js";
@@ -31,7 +32,7 @@ import { clearGraphCache } from "./review-graph/builder.js";
31
32
  import { RUNTIME_CONFIG } from "./runtime-config.js";
32
33
  import { safeSpawnAsync } from "./safe-spawn.js";
33
34
  import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
34
- import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasEslintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
35
+ import { getAutofixPolicyForFile, getPreferredAutofixTools, getRubocopCommand, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
35
36
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
36
37
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
37
38
  const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
@@ -173,20 +174,26 @@ async function tryEslintFix(filePath, cwd) {
173
174
  if (dry.status === 2)
174
175
  return 0;
175
176
  let fixableCount = 0;
177
+ let anyDryRunOutput = false;
176
178
  try {
177
179
  const results = JSON.parse(dry.stdout);
178
180
  fixableCount = results.reduce((sum, r) => sum + (r.fixableErrorCount ?? 0) + (r.fixableWarningCount ?? 0), 0);
181
+ // `--fix-dry-run` reports the POST-fix state: when every problem is
182
+ // auto-fixable, `messages`/`fixableErrorCount` are 0 and the fixed source
183
+ // lands in the `output` field instead. Keying on `fixableErrorCount` alone
184
+ // therefore misses the common "all fixable" case and never applies fixes.
185
+ anyDryRunOutput = results.some((r) => typeof r.output === "string");
179
186
  }
180
187
  catch {
181
188
  /* treat as zero fixable on error */
182
189
  }
183
- if (fixableCount === 0)
190
+ if (fixableCount === 0 && !anyDryRunOutput)
184
191
  return 0;
185
192
  // Apply the fixes
186
193
  const fix = await safeSpawnAsync(cmd, ["--fix", "--no-error-on-unmatched-pattern", ...configArgs, filePath], { timeout: 30000, cwd });
187
194
  if (fix.status === 2)
188
195
  return 0;
189
- return fixableCount;
196
+ return fixableCount > 0 ? fixableCount : anyDryRunOutput ? 1 : 0;
190
197
  }
191
198
  async function tryStylelintFix(filePath, cwd) {
192
199
  const cmd = await resolveToolCommandWithInstallFallback(cwd, "stylelint");
@@ -216,6 +223,56 @@ async function tryKtlintFix(filePath, cwd) {
216
223
  return 0;
217
224
  return detectFileChangedAfterCommand(filePath, cmd, ["-F", filePath], cwd, [1]);
218
225
  }
226
+ // golangci-lint/detekt/ktfmt have no TOOL_COMMAND_SPEC; resolve via availability
227
+ // checkers like their runners do.
228
+ const golangciAutofixChecker = createAvailabilityChecker("golangci-lint", ".exe");
229
+ const detektAutofixChecker = createAvailabilityChecker("detekt", ".bat");
230
+ const ktfmtAutofixChecker = createAvailabilityChecker("ktfmt", ".bat");
231
+ async function tryKtfmtFix(filePath, cwd) {
232
+ // Config-first: the autofix policy only reaches here when the project opted
233
+ // into ktfmt, so resolveAvailableOrInstall honors that gate. ktfmt writes the
234
+ // formatted file in place and exits 0; treat any byte change as the fix.
235
+ const cmd = await resolveAvailableOrInstall(ktfmtAutofixChecker, "ktfmt", cwd);
236
+ if (!cmd)
237
+ return 0;
238
+ const absPath = path.resolve(cwd, filePath);
239
+ return detectFileChangedAfterCommand(filePath, cmd, [absPath], cwd, [0]);
240
+ }
241
+ async function tryGolangciLintFix(filePath, cwd) {
242
+ // Config-first: the autofix policy only reaches here when a .golangci.* config
243
+ // exists. resolveAvailableOrInstall honors that gate (won't auto-install a
244
+ // config-first tool). golangci-lint exits non-zero when issues remain after
245
+ // --fix, so allow its issue-found codes.
246
+ const cmd = await resolveAvailableOrInstall(golangciAutofixChecker, "golangci-lint", cwd);
247
+ if (!cmd)
248
+ return 0;
249
+ return detectFileChangedAfterCommand(filePath, cmd, ["run", "--fix", filePath], cwd, [1, 7]);
250
+ }
251
+ async function tryDetektFix(filePath, cwd) {
252
+ const configPath = findDetektConfig(cwd);
253
+ if (!configPath)
254
+ return 0;
255
+ if (!(await detektAutofixChecker.isAvailableAsync(cwd)))
256
+ return 0;
257
+ const cmd = detektAutofixChecker.getCommand(cwd);
258
+ if (!cmd)
259
+ return 0;
260
+ const absPath = path.resolve(cwd, filePath);
261
+ return detectFileChangedAfterCommand(filePath, cmd, ["--auto-correct", "--input", absPath, "--config", configPath], cwd, [1, 2]);
262
+ }
263
+ async function tryMarkdownlintFix(filePath, cwd) {
264
+ const cmd = await resolveToolCommandWithInstallFallback(cwd, "markdownlint");
265
+ if (!cmd)
266
+ return 0;
267
+ // markdownlint-cli2 --fix exits non-zero when unfixable violations remain.
268
+ return detectFileChangedAfterCommand(filePath, cmd, ["--fix", filePath], cwd, [1]);
269
+ }
270
+ async function tryOxlintFix(filePath, cwd) {
271
+ const cmd = await resolveToolCommandWithInstallFallback(cwd, "oxlint");
272
+ if (!cmd)
273
+ return 0;
274
+ return detectFileChangedAfterCommand(filePath, cmd, ["--fix", filePath], cwd, [1]);
275
+ }
219
276
  async function tryRustClippyFix(filePath) {
220
277
  const check = await safeSpawnAsync("cargo", ["--version"], { timeout: 5000 });
221
278
  if (check.error || check.status !== 0)
@@ -248,7 +305,7 @@ async function tryDartFix(filePath) {
248
305
  return diffProjectSnapshot(pubspecDir, before);
249
306
  }
250
307
  // --- Pipeline phase helpers ---
251
- async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
308
+ export async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
252
309
  const { biomeClient, ruffClient, fixedThisTurn } = deps;
253
310
  const noAutofix = getFlag("no-autofix");
254
311
  let fixedCount = 0;
@@ -285,6 +342,10 @@ async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
285
342
  hasSqlfluffConfig: hasSqlfluffConfig(cwd),
286
343
  hasRubocopConfig: hasRubocopConfig(cwd),
287
344
  hasBiomeConfig: hasBiomeConfig(cwd),
345
+ hasGolangciConfig: hasGolangciConfig(cwd),
346
+ hasDetektConfig: hasDetektConfig(cwd),
347
+ hasKtfmtConfig: hasKtfmtConfig(cwd),
348
+ hasOxlintConfig: hasOxlintConfig(cwd),
288
349
  };
289
350
  const autofixPolicy = getAutofixPolicyForFile(filePath, autofixContext);
290
351
  const preferredAutofixTools = autofixPolicy?.safe
@@ -438,6 +499,67 @@ async function runAutofix(filePath, cwd, getFlag, dbg, deps) {
438
499
  dbg(`autofix: dart fix changed ${dartChangedFiles.length} file(s) from ${filePath}`);
439
500
  needsContentRefresh = true;
440
501
  }
502
+ continue;
503
+ }
504
+ if (toolName === "golangci-lint") {
505
+ const fixed = await tryGolangciLintFix(filePath, cwd);
506
+ if (fixed > 0) {
507
+ fixedCount += fixed;
508
+ autofixTools.push(`golangci-lint:${fixed}`);
509
+ fixedThisTurn.add(filePath);
510
+ markTargetChanged();
511
+ dbg(`autofix: golangci-lint fixed ${filePath}`);
512
+ needsContentRefresh = true;
513
+ }
514
+ continue;
515
+ }
516
+ if (toolName === "detekt") {
517
+ const fixed = await tryDetektFix(filePath, cwd);
518
+ if (fixed > 0) {
519
+ fixedCount += fixed;
520
+ autofixTools.push(`detekt:${fixed}`);
521
+ fixedThisTurn.add(filePath);
522
+ markTargetChanged();
523
+ dbg(`autofix: detekt --auto-correct fixed ${filePath}`);
524
+ needsContentRefresh = true;
525
+ }
526
+ continue;
527
+ }
528
+ if (toolName === "ktfmt") {
529
+ const fixed = await tryKtfmtFix(filePath, cwd);
530
+ if (fixed > 0) {
531
+ fixedCount += fixed;
532
+ autofixTools.push(`ktfmt:${fixed}`);
533
+ fixedThisTurn.add(filePath);
534
+ markTargetChanged();
535
+ dbg(`autofix: ktfmt formatted ${filePath}`);
536
+ needsContentRefresh = true;
537
+ }
538
+ continue;
539
+ }
540
+ if (toolName === "markdownlint") {
541
+ const fixed = await tryMarkdownlintFix(filePath, cwd);
542
+ if (fixed > 0) {
543
+ fixedCount += fixed;
544
+ autofixTools.push(`markdownlint:${fixed}`);
545
+ fixedThisTurn.add(filePath);
546
+ markTargetChanged();
547
+ dbg(`autofix: markdownlint --fix fixed ${filePath}`);
548
+ needsContentRefresh = true;
549
+ }
550
+ continue;
551
+ }
552
+ if (toolName === "oxlint") {
553
+ const fixed = await tryOxlintFix(filePath, cwd);
554
+ if (fixed > 0) {
555
+ fixedCount += fixed;
556
+ autofixTools.push(`oxlint:${fixed}`);
557
+ fixedThisTurn.add(filePath);
558
+ markTargetChanged();
559
+ dbg(`autofix: oxlint --fix fixed ${filePath}`);
560
+ needsContentRefresh = true;
561
+ }
562
+ continue;
441
563
  }
442
564
  }
443
565
  if (attemptedTools.length > 0 && autofixTools.length === 0) {
@@ -47,6 +47,22 @@ function cmdEscapeArg(arg) {
47
47
  return arg;
48
48
  return `"${arg.replace(/"/g, '""')}"`;
49
49
  }
50
+ /**
51
+ * Build the cmd.exe command string used for Windows `shell:true` spawning.
52
+ *
53
+ * The COMMAND must be escaped the same way as the args — escaping only the args
54
+ * (the bug behind #214) means a tool whose resolved path contains a space (e.g.
55
+ * `C:\Program Files\Go\bin\go.exe`) makes cmd.exe parse `C:\Program` as the
56
+ * command and fail with "'C:\Program' is not recognized". `cmdEscapeArg` is a
57
+ * no-op for space-free commands, so this is safe for the npm/.pi-lens tool paths
58
+ * that already worked. The `chcp 65001` prefix forces the UTF-8 code page (so
59
+ * tool output isn't mangled by the system code page) and, as a side benefit,
60
+ * keeps the (possibly quoted) command off the front of the line, avoiding
61
+ * cmd.exe's `/s` outer-quote-stripping quirk.
62
+ */
63
+ export function buildWindowsShellCommand(command, args) {
64
+ return `chcp 65001 >nul 2>&1 && ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
65
+ }
50
66
  // ============================================================================
51
67
  // ASYNC VERSION (Recommended - Non-blocking)
52
68
  // ============================================================================
@@ -93,7 +109,7 @@ export async function safeSpawnAsync(command, args, options) {
93
109
  // etc.) have their bytes decoded as the system code page (CP850/CP1252/
94
110
  // CP936/CP932), producing garbled characters in stderr error messages.
95
111
  const spawnCmd = isWindows
96
- ? `chcp 65001 >nul 2>&1 && ${[command, ...args.map(cmdEscapeArg)].join(" ")}`
112
+ ? buildWindowsShellCommand(command, args)
97
113
  : command;
98
114
  const spawnArgs = isWindows ? [] : args;
99
115
  const child = spawn(spawnCmd, spawnArgs, {