pi-lens 3.8.43 → 3.8.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +45 -7
  2. package/clients/ast-grep-client.ts +49 -17
  3. package/clients/dependency-checker.ts +82 -39
  4. package/clients/dispatch/plan.ts +5 -0
  5. package/clients/dispatch/runners/biome.ts +1 -1
  6. package/clients/dispatch/runners/cpp-check.ts +20 -11
  7. package/clients/dispatch/runners/dart-analyze.ts +17 -11
  8. package/clients/dispatch/runners/detekt.ts +4 -1
  9. package/clients/dispatch/runners/dotnet-build.ts +93 -8
  10. package/clients/dispatch/runners/fish-indent.ts +83 -0
  11. package/clients/dispatch/runners/gleam-check.ts +4 -2
  12. package/clients/dispatch/runners/go-vet.ts +3 -3
  13. package/clients/dispatch/runners/hadolint.ts +1 -1
  14. package/clients/dispatch/runners/htmlhint.ts +1 -1
  15. package/clients/dispatch/runners/index.ts +2 -0
  16. package/clients/dispatch/runners/javac.ts +99 -94
  17. package/clients/dispatch/runners/ktlint.ts +1 -1
  18. package/clients/dispatch/runners/markdownlint.ts +88 -88
  19. package/clients/dispatch/runners/mypy.ts +88 -88
  20. package/clients/dispatch/runners/php-lint.ts +82 -79
  21. package/clients/dispatch/runners/phpstan.ts +4 -3
  22. package/clients/dispatch/runners/prisma-validate.ts +8 -5
  23. package/clients/dispatch/runners/psscriptanalyzer.ts +22 -3
  24. package/clients/dispatch/runners/pyright.ts +1 -1
  25. package/clients/dispatch/runners/python-slop.ts +2 -3
  26. package/clients/dispatch/runners/semgrep.ts +3 -1
  27. package/clients/dispatch/runners/shellcheck.ts +195 -195
  28. package/clients/dispatch/runners/shfmt.ts +100 -100
  29. package/clients/dispatch/runners/spellcheck.ts +145 -143
  30. package/clients/dispatch/runners/sqlfluff.ts +108 -108
  31. package/clients/dispatch/runners/stylelint.ts +3 -1
  32. package/clients/dispatch/runners/taplo.ts +1 -1
  33. package/clients/dispatch/runners/tflint.ts +1 -1
  34. package/clients/dispatch/runners/utils/runner-helpers.ts +112 -37
  35. package/clients/dispatch/runners/yamllint.ts +92 -92
  36. package/clients/dispatch/runners/zig-check.ts +1 -1
  37. package/clients/file-kinds.ts +7 -1
  38. package/clients/formatters.ts +6 -3
  39. package/clients/installer/index.ts +124 -40
  40. package/clients/jscpd-client.ts +59 -60
  41. package/clients/knip-client.ts +402 -357
  42. package/clients/language-policy.ts +6 -0
  43. package/clients/language-profile.ts +1 -0
  44. package/clients/lsp/client.ts +61 -6
  45. package/clients/lsp/server.ts +40 -0
  46. package/clients/review-graph/builder.ts +9 -2
  47. package/clients/runtime-session.ts +15 -13
  48. package/clients/runtime-turn.ts +29 -17
  49. package/clients/safe-spawn.ts +24 -14
  50. package/clients/sg-runner.ts +86 -39
  51. package/clients/tool-availability.ts +1 -1
  52. package/clients/tool-policy.ts +20 -0
  53. package/clients/tree-sitter-client.ts +79 -4
  54. package/clients/tree-sitter-query-loader.ts +19 -4
  55. package/commands/booboo.ts +36 -10
  56. package/index.ts +3 -0
  57. package/package.json +1 -1
  58. package/rules/tree-sitter-queries/python/python-sql-injection.yml +3 -2
  59. package/rules/tree-sitter-queries/python/return-in-generator.yml +5 -5
  60. package/tools/ast-grep-replace.js +6 -1
  61. package/tools/ast-grep-replace.ts +6 -1
  62. package/tools/ast-grep-search.js +57 -3
  63. package/tools/ast-grep-search.ts +65 -5
  64. package/tools/lsp-diagnostics.js +330 -0
  65. package/tools/lsp-diagnostics.ts +388 -0
  66. package/tools/shared.js +3 -2
  67. package/tools/shared.ts +3 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.44] - 2026-05-13
8
+
9
+ ### Added
10
+
11
+ - **`fish` FileKind with `fish_indent` formatter runner** — `.fish` files are now a first-class `"fish"` kind rather than being bucketed under `"shell"`. A new `fish-indent` runner wraps `fish_indent --check` (fish ≥ 3.6), reporting a formatting warning with a `fish_indent -w` fix hint on exit 1 and a blocking parse-error diagnostic when stderr is non-empty. Formatter and linter policy entries added for `.fish` in `tool-policy.ts`; fish dispatch group `[lsp, fish-indent]` wired in `language-policy.ts`. Closes #74.
12
+
13
+ ### Fixed
14
+
15
+ - **Linux `sg` command no longer breaks `ast_grep_search` / `ast_grep_replace`** — ast-grep resolution now prefers the canonical `ast-grep` binary and only accepts `sg` when `--version` proves it is ast-grep, avoiding the util-linux `/usr/bin/sg` group-switch command. The installer, probe cache, tool availability, sync runner helpers, and Python slop scan now share the corrected command shape and `npx --no -- ast-grep` fallback. Closes #75.
16
+ - **`return-in-generator` no longer flags normal `async def` coroutine returns** — added a Python tree-sitter post-filter that keeps only synchronous functions containing `yield`, skips `async def`, and rejects non-generator functions. Added regression tests for valued generator returns, coroutine returns, and normal functions. Closes #76.
17
+ - **`python-sql-injection` no longer flags safe SQLAlchemy expression execution** — the rule now captures the call receiver and the post-filter skips likely SQLAlchemy ORM session receivers (`session.execute(stmt)`) plus expression-builder calls such as `conn.execute(select(...).where(...))`, while still flagging raw `cursor.execute(sql)` and composed SQL strings. Closes #77.
18
+ - **Formatter tests no longer depend on a real global Ruff install** — the Ruff global fallback test now uses an isolated PATH shim, making it deterministic on machines without Ruff installed.
19
+
20
+ - **`psscriptanalyzer` runner could hang indefinitely** — `spawnPs` had no timeout; if `pwsh` or `Invoke-ScriptAnalyzer` stalled on a large file the turn would block forever. Added a 30s timeout with SIGTERM → 1s → SIGKILL escalation. `shell: false` means `child.pid` is the actual `pwsh` process so `child.kill()` hits the right target directly (no `taskkill` needed).
21
+
22
+ - **`turn_end` hangs ~40–50s on Windows when knip times out** — `safeSpawnAsync` used `child.kill("SIGTERM/SIGKILL")` to terminate timed-out processes. On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper; killing it orphans the actual subprocess (e.g. knip/npx node process) which then runs unsupervised until it naturally exits. Replaced with `taskkill /F /T /PID` on Windows, which kills the full process tree rooted at `cmd.exe`, matching the approach already used in `lsp/client.ts`.
23
+
24
+ - **`fish` missing from `LANGUAGE_CAPABILITY_MATRIX` and `LintRunnerName`** — adding the `"fish"` FileKind required two exhaustiveness fixes: a `fish` entry in `plan.ts`'s `Record<FileKind, CapabilityMatrixEntry>` and `"fish-indent"` in the `LintRunnerName` union in `tool-policy.ts`; both caused build/type-check failures on CI.
25
+ - **shellcheck and shfmt no longer fire on `.fish` files** — `.fish` was classified as `"shell"`, causing both runners (which use `appliesTo: ["shell"]`) to process fish scripts with `--shell bash`, producing false-positive SC1073/SC1064 parse errors. Moving `.fish` to the new `"fish"` kind fixes the routing with no special-case logic in either runner. Closes #74.
26
+
27
+ - **`lsp_diagnostics` tool** — proactive LSP error checking for files and directories. The agent can now run `lsp_diagnostics({ filePath: "src/" })` before builds to catch issues without making edits. Directory mode walks the tree (skipping node_modules/.git/target), auto-detects the language extension, opens each file in the LSP client, and aggregates diagnostics. Supports severity filtering (`error`/`warning`/`information`/`hint`/`all`), caps at 50 files and 200 diagnostics. Returns structured details with `totalDiagnostics`, `truncated`, and per-diagnostic `file`/`line`/`severity`/`message`/`source`/`code`. Adapted from `code-yeongyu/pi-lsp-client`.
28
+ - **LSP process stderr capture and health check** — the LSP client now maintains a rolling 100-line stderr buffer from server startup through shutdown. Three new client methods exposed: `processExited()` (true if the server process died), `recentStderr(n)` (last N lines for diagnostics), and `checkAlive()` (pre-request health check returning error string with exit code + stderr tail if dead). Previously, stderr was only captured during initialization and discarded afterward.
29
+ - **SIGTERM → 1.5s → SIGKILL escalation in `killProcessTree`** — on Unix, process cleanup now sends SIGTERM first, waits 1.5 seconds, then sends SIGKILL if the process is still alive. Prevents zombie server processes that survive a standard kill. Windows already uses `taskkill /F /T` (force kill tree).
30
+ - **LSP force-reinstall when PATH-resolved tool is broken** — when an LSP server's PATH candidate fails to launch (e.g. broken symlink, missing runtime, corrupted binary) AND the managed install returns the same broken PATH entry, pi-lens now clears the probe cache, downloads a managed copy from the registry (npm/GitHub/pip), and retries the launch. Previously, broken PATH tools triggered exponential backoff and were permanently disabled after 5 failures. The retry only fires when the `ensureTool` path is a bare command name (no `/` or `\` separators) — absolute paths from prior managed installs are not force-reinstalled to avoid redundant download loops. `ensureTool` gained an optional `forceReinstall` flag that bypasses both the in-memory `resolvedPathCache` and the persistent probe cache.
31
+ - **`getToolPath` prefers managed installs over PATH for github-strategy tools** — github-strategy tools (`rust-analyzer`, `shellcheck`, `shfmt`, `golangci-lint`) now check `~/.pi-lens/bin/` before falling through to PATH lookup. This ensures force-reinstall flows find the newly downloaded binary, and pi-lens-managed copies take priority over potentially stale or broken PATH entries. Non-github tools (npm, pip) are unaffected.
32
+ - **Pattern hints for `ast_grep_search` zero-match results** — when a search returns no matches, the tool now appends a hint suggesting likely pattern mistakes: regex misuse (`\w`, `\d`, `[a-z]`, `.*`, `.+`, `|` alternation), language-specific mistakes (Python trailing colons, incomplete JS/Go/Rust function patterns). Adapted from `code-yeongyu/pi-ast-grep`.
33
+ - **Truncation metadata in ast-grep tool results** — `SgResult` now carries `totalMatches` and `truncated` fields, threaded through `SgRunner` → `AstGrepClient` → both `ast_grep_search` and `ast_grep_replace` tool `details`. The agent can now distinguish "50 shown of 500 total" from "50 total".
34
+
35
+ ### Changed
36
+
37
+ - **Runner process execution is async/non-blocking across hook paths** — jscpd scans, Madge dependency checks, formatter execution, and dispatch runners that previously used sync `safeSpawn()` now use `safeSpawnAsync()` in write/session/turn hooks. Added in-flight guards for jscpd and Madge project/file scans, async availability checks in runner helpers, and Knip availability dedupe + project-root bail before install/probe.
38
+ - **`isCommandAvailable` replaced `which`/`where` spawn with PATH walk + `statSync` size validation** — instead of spawning `which`/`where` (~50 ms + timeout risk), the installer now walks `$PATH` entries synchronously and checks `statSync(path).isFile() && stat.size > 0` for each candidate. This catches broken symlinks (stat throws `ENOENT` or returns size 0) at ~μs per candidate with zero process spawns. On Windows, `.exe`, `.cmd`, and `.bat` extensions are probed.
39
+
40
+ ### Fixed
41
+
42
+ - **SonarCloud security hotspots resolved** — replaced the .NET build diagnostic regex with a linear manual parser to avoid ReDoS risk (S5852), and switched jscpd temporary directory creation from a `Math.random()` suffix to `fs.mkdtempSync()` to avoid weak PRNG use (S2245).
43
+ - **ast-grep tool language list aligned with ast-grep CLI** — dropped phantom `dart` and `sql` (not supported by ast-grep binary), added missing `bash`, `nix`, `solidity`. The `LANGUAGES` constant in `tools/shared.ts` now matches ast-grep v0.41's official 25-language list.
44
+ - **Graph-cache test: disk cache leaked across test runs** — `buildOrUpdateGraph` persists to `cwd/.pi-lens/cache/review-graph.json`. All tests used hardcoded `"/cwd"`, causing the first test run's disk cache to contaminate subsequent runs. Switched to `fs.mkdtempSync` temp directories with `afterEach` cleanup.
45
+ - **Disabled tree-sitter rules leaked into production** — `parseQueryFile` uses the YAML's `language:` field over the directory name, so rules in `typescript-disabled/` with `language: typescript` were loaded as active TypeScript rules and appeared in the diagnostics widget. Added `!d.name.endsWith("-disabled")` filter to `loadQueries` directory enumeration.
46
+
7
47
  ## [3.8.43] - 2026-05-10
8
48
 
9
49
  ### Added
@@ -33,11 +73,11 @@ All notable changes to pi-lens will be documented in this file.
33
73
  - **Severity alignment for 3 existing TS tree-sitter blocking rules** — `ts-command-injection`, `ts-ssrf`, `unsafe-regex` had `inline_tier: blocking` but `severity: warning`, producing `semantic: "warning"` which is never shown inline. Fixed to `severity: error` → `semantic: "blocking"` → actually surfaces to the agent.
34
74
  - **Fixed `inline_tier: error` typo** on `ts-hallucinated-react-import` and `python-hallucinated-import` (→ `blocking`).
35
75
  - **13 new high-confidence blocking promotions across 5 languages** (all `severity: error`, `inline_tier: blocking`):
36
- - *TypeScript:* `ts-weak-hash` (`createHash("md5"/"sha1")` — confidence: high)
37
- - *Python:* `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`
38
- - *Go:* `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash`
39
- - *Ruby:* `ruby-weak-hash`
40
- - *Rust:* `rust-lock-held-across-await`
76
+ - _TypeScript:_ `ts-weak-hash` (`createHash("md5"/"sha1")` — confidence: high)
77
+ - _Python:_ `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`
78
+ - _Go:_ `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash`
79
+ - _Ruby:_ `ruby-weak-hash`
80
+ - _Rust:_ `rust-lock-held-across-await`
41
81
  - **4 new blocking tree-sitter rules (SonarCloud BLOCKER equivalents)**:
42
82
  - `ts-xss-dom-sink` (S5696) — flags dynamic values assigned to `innerHTML`/`outerHTML` or passed to `document.write()` / `document.writeln()`
43
83
  - `ts-dynamic-require` (S5335) — flags `require()` called with a non-string-literal argument (arbitrary module loading)
@@ -60,8 +100,6 @@ All notable changes to pi-lens will be documented in this file.
60
100
 
61
101
  - **Startup observability** — `checkProbeCache` now logs the reason for each cache miss (`ttl expired`, `gone`, `mtime changed`); the lsp-config fire-and-forget callback logs how many warm files were configured once the config resolves asynchronously.
62
102
 
63
-
64
-
65
103
  ### Added
66
104
 
67
105
  - **Test runner: import-based fallback discovery** — when basename pattern lookup finds no test file for a modified source file (e.g. `cline.test.ts` for `cline-auth.ts`), the runner now scans `tests/`, `__tests__/`, and the source file's own directory for any `*.test.*` file whose content references the source basename in an import path. Fixes the silent `no test file found` for files whose test is named after a module rather than the source file.
@@ -8,7 +8,6 @@
8
8
  * Rules: ./rules/ directory
9
9
  */
10
10
 
11
- import { spawnSync } from "node:child_process";
12
11
  import * as fs from "node:fs";
13
12
  import * as path from "node:path";
14
13
  import { AstGrepParser } from "./ast-grep-parser.js";
@@ -73,7 +72,12 @@ export class AstGrepClient {
73
72
  lang: string,
74
73
  paths: string[],
75
74
  options?: { selector?: string; context?: number },
76
- ): Promise<{ matches: AstGrepMatch[]; error?: string }> {
75
+ ): Promise<{
76
+ matches: AstGrepMatch[];
77
+ totalMatches: number;
78
+ truncated: boolean;
79
+ error?: string;
80
+ }> {
77
81
  const args = ["run", "-p", pattern, "--lang", lang, "--json=compact"];
78
82
  if (options?.selector) {
79
83
  args.push("--selector", options.selector);
@@ -82,7 +86,13 @@ export class AstGrepClient {
82
86
  args.push("--context", String(options.context));
83
87
  }
84
88
  args.push(...paths);
85
- return this.runner.exec(args);
89
+ const result = await this.runner.exec(args);
90
+ return {
91
+ matches: result.matches,
92
+ totalMatches: result.totalMatches,
93
+ truncated: result.truncated,
94
+ error: result.error,
95
+ };
86
96
  }
87
97
 
88
98
  /**
@@ -94,7 +104,13 @@ export class AstGrepClient {
94
104
  lang: string,
95
105
  paths: string[],
96
106
  apply = false,
97
- ): Promise<{ matches: AstGrepMatch[]; applied: boolean; error?: string }> {
107
+ ): Promise<{
108
+ matches: AstGrepMatch[];
109
+ totalMatches: number;
110
+ truncated: boolean;
111
+ applied: boolean;
112
+ error?: string;
113
+ }> {
98
114
  const baseArgs = ["run", "-p", pattern, "-r", rewrite, "--lang", lang];
99
115
 
100
116
  if (!apply) {
@@ -104,7 +120,13 @@ export class AstGrepClient {
104
120
  "--json=compact",
105
121
  ...paths,
106
122
  ]);
107
- return { matches: result.matches, applied: false, error: result.error };
123
+ return {
124
+ matches: result.matches,
125
+ totalMatches: result.totalMatches,
126
+ truncated: result.truncated,
127
+ applied: false,
128
+ error: result.error,
129
+ };
108
130
  }
109
131
 
110
132
  // Apply: --update-all and --json are MUTUALLY EXCLUSIVE in sg.
@@ -117,7 +139,13 @@ export class AstGrepClient {
117
139
  ...paths,
118
140
  ]);
119
141
  if (applyResult.error) {
120
- return { matches: [], applied: false, error: applyResult.error };
142
+ return {
143
+ matches: [],
144
+ totalMatches: 0,
145
+ truncated: false,
146
+ applied: false,
147
+ error: applyResult.error,
148
+ };
121
149
  }
122
150
 
123
151
  // Search for what was changed (pattern no longer matches after rewrite,
@@ -131,7 +159,13 @@ export class AstGrepClient {
131
159
  "--json=compact",
132
160
  ...paths,
133
161
  ]);
134
- return { matches: searchResult.matches, applied: true, error: undefined };
162
+ return {
163
+ matches: searchResult.matches,
164
+ totalMatches: searchResult.totalMatches,
165
+ truncated: searchResult.truncated,
166
+ applied: true,
167
+ error: undefined,
168
+ };
135
169
  }
136
170
 
137
171
  /**
@@ -285,18 +319,16 @@ message: found
285
319
  const configPath = path.join(this.ruleDir, ".sgconfig.yml");
286
320
 
287
321
  try {
288
- const result = spawnSync(
289
- "npx",
290
- ["sg", "scan", "--config", configPath, "--json", absolutePath],
291
- {
292
- encoding: "utf-8",
293
- timeout: 15000,
294
- shell: process.platform === "win32",
295
- },
296
- );
322
+ const result = this.runner.execSync([
323
+ "scan",
324
+ "--config",
325
+ configPath,
326
+ "--json",
327
+ absolutePath,
328
+ ]);
297
329
 
298
330
  // ast-grep exits 1 when it finds issues
299
- const output = result.stdout || result.stderr || "";
331
+ const output = result.output;
300
332
  if (!output.trim()) return [];
301
333
 
302
334
  const parser = new AstGrepParser(
@@ -11,7 +11,7 @@
11
11
 
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
- import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
14
+ import { safeSpawnAsync } from "./safe-spawn.js";
15
15
 
16
16
  // --- Types ---
17
17
 
@@ -38,6 +38,12 @@ interface FileImports {
38
38
 
39
39
  export class DependencyChecker {
40
40
  private available: boolean | null = null;
41
+ private ensureInFlight: Promise<boolean> | null = null;
42
+ private checkInFlight = new Map<string, Promise<DepCheckResult>>();
43
+ private scanInFlight = new Map<
44
+ string,
45
+ Promise<{ circular: CircularDep[]; count: number }>
46
+ >();
41
47
  private log: (msg: string) => void;
42
48
 
43
49
  // Cache: file path -> its imports
@@ -61,7 +67,17 @@ export class DependencyChecker {
61
67
  async ensureAvailable(): Promise<boolean> {
62
68
  // Fast path: already checked
63
69
  if (this.available !== null) return this.available;
70
+ if (this.ensureInFlight) return this.ensureInFlight;
64
71
 
72
+ this.ensureInFlight = this.doEnsureAvailable();
73
+ try {
74
+ return await this.ensureInFlight;
75
+ } finally {
76
+ this.ensureInFlight = null;
77
+ }
78
+ }
79
+
80
+ private async doEnsureAvailable(): Promise<boolean> {
65
81
  // Check if available in PATH
66
82
  const result = await safeSpawnAsync("madge", ["--version"], {
67
83
  timeout: 5000,
@@ -84,29 +100,11 @@ export class DependencyChecker {
84
100
  return true;
85
101
  }
86
102
 
103
+ this.available = false;
87
104
  this.log("Madge auto-install failed");
88
105
  return false;
89
106
  }
90
107
 
91
- /**
92
- * Check if madge is available (legacy sync method)
93
- * Prefer ensureAvailable() for auto-install behavior
94
- */
95
- isAvailable(): boolean {
96
- if (this.available !== null) return this.available;
97
-
98
- const result = safeSpawn("npx", ["madge", "--version"], {
99
- timeout: 5000,
100
- });
101
-
102
- this.available = !result.error && result.status === 0;
103
- if (this.available) {
104
- this.log("Madge available for dependency checking");
105
- }
106
-
107
- return this.available;
108
- }
109
-
110
108
  /**
111
109
  * Check if a file is part of a circular dependency (from cache)
112
110
  */
@@ -210,7 +208,7 @@ export class DependencyChecker {
210
208
  * Quick circular dependency check using DFS on cached graph.
211
209
  * Only re-runs full madge check when imports change.
212
210
  */
213
- checkFile(filePath: string, cwd?: string): DepCheckResult {
211
+ async checkFile(filePath: string, cwd?: string): Promise<DepCheckResult> {
214
212
  const normalized = path.resolve(filePath);
215
213
 
216
214
  // Return early for non-existent files without running availability check
@@ -223,18 +221,9 @@ export class DependencyChecker {
223
221
  };
224
222
  }
225
223
 
226
- if (!this.isAvailable()) {
227
- return {
228
- hasCircular: false,
229
- circular: [],
230
- checked: false,
231
- cacheHit: false,
232
- };
233
- }
234
-
235
- const projectRoot = cwd || process.cwd();
224
+ const projectRoot = path.resolve(cwd || process.cwd());
236
225
 
237
- // Check if imports changed
226
+ // Check if imports changed before probing/installing madge.
238
227
  const importsChanged = this.importsChanged(normalized);
239
228
 
240
229
  if (!importsChanged) {
@@ -249,13 +238,37 @@ export class DependencyChecker {
249
238
  };
250
239
  }
251
240
 
241
+ if (!(await this.ensureAvailable())) {
242
+ return {
243
+ hasCircular: false,
244
+ circular: [],
245
+ checked: false,
246
+ cacheHit: false,
247
+ };
248
+ }
249
+
250
+ const key = `${projectRoot}:${normalized}`;
251
+ const existing = this.checkInFlight.get(key);
252
+ if (existing) return existing;
253
+
254
+ const promise = this.runCheckFile(normalized, projectRoot).finally(() => {
255
+ this.checkInFlight.delete(key);
256
+ });
257
+ this.checkInFlight.set(key, promise);
258
+ return promise;
259
+ }
260
+
261
+ private async runCheckFile(
262
+ normalized: string,
263
+ projectRoot: string,
264
+ ): Promise<DepCheckResult> {
252
265
  this.log(
253
- `Imports changed for ${path.basename(filePath)}, checking dependencies...`,
266
+ `Imports changed for ${path.basename(normalized)}, checking dependencies...`,
254
267
  );
255
268
 
256
269
  // Run madge on the specific file (fast)
257
270
  try {
258
- const result = safeSpawn(
271
+ const result = await safeSpawnAsync(
259
272
  "npx",
260
273
  [
261
274
  "madge",
@@ -271,6 +284,16 @@ export class DependencyChecker {
271
284
  },
272
285
  );
273
286
 
287
+ if (result.error) {
288
+ this.log(`Check error: ${result.error.message}`);
289
+ return {
290
+ hasCircular: false,
291
+ circular: [],
292
+ checked: false,
293
+ cacheHit: false,
294
+ };
295
+ }
296
+
274
297
  const output = result.stdout || "[]";
275
298
  const parsed = JSON.parse(output);
276
299
 
@@ -333,10 +356,12 @@ export class DependencyChecker {
333
356
  /**
334
357
  * Full project scan (for /check-deps command)
335
358
  */
336
- scanProject(cwd?: string): { circular: CircularDep[]; count: number } {
337
- const projectRoot = cwd || process.cwd();
359
+ async scanProject(
360
+ cwd?: string,
361
+ ): Promise<{ circular: CircularDep[]; count: number }> {
362
+ const projectRoot = path.resolve(cwd || process.cwd());
338
363
 
339
- // Return early for non-existent or empty directories
364
+ // Return early for non-existent or empty directories before probing/installing.
340
365
  if (!fs.existsSync(projectRoot)) {
341
366
  return { circular: [], count: 0 };
342
367
  }
@@ -348,12 +373,25 @@ export class DependencyChecker {
348
373
  return { circular: [], count: 0 };
349
374
  }
350
375
 
351
- if (!this.isAvailable()) {
376
+ if (!(await this.ensureAvailable())) {
352
377
  return { circular: [], count: 0 };
353
378
  }
354
379
 
380
+ const existing = this.scanInFlight.get(projectRoot);
381
+ if (existing) return existing;
382
+
383
+ const promise = this.runScanProject(projectRoot).finally(() => {
384
+ this.scanInFlight.delete(projectRoot);
385
+ });
386
+ this.scanInFlight.set(projectRoot, promise);
387
+ return promise;
388
+ }
389
+
390
+ private async runScanProject(
391
+ projectRoot: string,
392
+ ): Promise<{ circular: CircularDep[]; count: number }> {
355
393
  try {
356
- const result = safeSpawn(
394
+ const result = await safeSpawnAsync(
357
395
  "npx",
358
396
  [
359
397
  "madge",
@@ -369,6 +407,11 @@ export class DependencyChecker {
369
407
  },
370
408
  );
371
409
 
410
+ if (result.error) {
411
+ this.log(`Scan error: ${result.error.message}`);
412
+ return { circular: [], count: 0 };
413
+ }
414
+
372
415
  const output = result.stdout || "{}";
373
416
  const data = JSON.parse(output);
374
417
 
@@ -110,6 +110,11 @@ export const LANGUAGE_CAPABILITY_MATRIX: Record<
110
110
  { mode: "all", runnerIds: ["fact-rules"], filterKinds: ["cmake"] },
111
111
  ],
112
112
  },
113
+ fish: {
114
+ name: "Fish Script Formatting",
115
+ capabilities: ["format"],
116
+ writeGroups: [primary("fish")],
117
+ },
113
118
  shell: {
114
119
  name: "Shell Script Linting",
115
120
  capabilities: ["lint", "security"],
@@ -26,7 +26,7 @@ const biomeRunner: RunnerDefinition = {
26
26
  let cmd: string | null = null;
27
27
  let useNpx = false;
28
28
 
29
- if (biome.isAvailable(cwd)) {
29
+ if (await (biome.isAvailableAsync?.(cwd) ?? biome.isAvailable(cwd))) {
30
30
  cmd = biome.getCommand(cwd);
31
31
  }
32
32
 
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { safeSpawn } from "../../safe-spawn.js";
2
+ import { safeSpawnAsync } from "../../safe-spawn.js";
3
3
  import { PRIORITY } from "../priorities.js";
4
4
  import type {
5
5
  Diagnostic,
@@ -12,20 +12,22 @@ type CompilerSpec =
12
12
  | { command: string; args: string[]; flavor: "gcc" | "msvc" }
13
13
  | undefined;
14
14
 
15
- function resolveCompiler(absPath: string): CompilerSpec {
15
+ async function resolveCompiler(absPath: string): Promise<CompilerSpec> {
16
16
  const gccLike: Array<{ command: string; args: string[] }> = [
17
17
  { command: "clang++", args: ["-fsyntax-only", absPath] },
18
18
  { command: "g++", args: ["-fsyntax-only", absPath] },
19
19
  { command: "c++", args: ["-fsyntax-only", absPath] },
20
20
  ];
21
21
  for (const candidate of gccLike) {
22
- const probe = safeSpawn(candidate.command, ["--version"], { timeout: 5000 });
22
+ const probe = await safeSpawnAsync(candidate.command, ["--version"], {
23
+ timeout: 5000,
24
+ });
23
25
  if (!probe.error && probe.status === 0) {
24
26
  return { ...candidate, flavor: "gcc" };
25
27
  }
26
28
  }
27
29
 
28
- const clProbe = safeSpawn("cl", [], { timeout: 5000 });
30
+ const clProbe = await safeSpawnAsync("cl", [], { timeout: 5000 });
29
31
  if (!clProbe.error && clProbe.status !== null) {
30
32
  return {
31
33
  command: "cl",
@@ -49,8 +51,9 @@ function parseGccLikeOutput(raw: string, filePath: string): Diagnostic[] {
49
51
  const resolvedTarget = path.resolve(filePath);
50
52
  if (resolvedSource !== resolvedTarget) continue;
51
53
 
52
- const severity =
53
- severityLabel.toLowerCase().includes("error") ? "error" : "warning";
54
+ const severity = severityLabel.toLowerCase().includes("error")
55
+ ? "error"
56
+ : "warning";
54
57
  diagnostics.push({
55
58
  id: `cpp-check-${severityLabel}-${lineStr}-${colStr || "1"}`,
56
59
  message: message.trim(),
@@ -79,8 +82,9 @@ function parseMsvcOutput(raw: string, filePath: string): Diagnostic[] {
79
82
  const resolvedTarget = path.resolve(filePath);
80
83
  if (resolvedSource !== resolvedTarget) continue;
81
84
 
82
- const severity =
83
- severityLabel.toLowerCase().includes("error") ? "error" : "warning";
85
+ const severity = severityLabel.toLowerCase().includes("error")
86
+ ? "error"
87
+ : "warning";
84
88
  diagnostics.push({
85
89
  id: `cpp-check-${rule}-${lineStr}-${colStr || "1"}`,
86
90
  message: `[${rule}] ${message.trim()}`,
@@ -111,12 +115,12 @@ const cppCheckRunner: RunnerDefinition = {
111
115
  async run(ctx: DispatchContext): Promise<RunnerResult> {
112
116
  const cwd = ctx.cwd || process.cwd();
113
117
  const absPath = path.resolve(cwd, ctx.filePath);
114
- const compiler = resolveCompiler(absPath);
118
+ const compiler = await resolveCompiler(absPath);
115
119
  if (!compiler) {
116
120
  return { status: "skipped", diagnostics: [], semantic: "none" };
117
121
  }
118
122
 
119
- const result = safeSpawn(compiler.command, compiler.args, {
123
+ const result = await safeSpawnAsync(compiler.command, compiler.args, {
120
124
  cwd,
121
125
  timeout: 30000,
122
126
  });
@@ -148,7 +152,12 @@ const cppCheckRunner: RunnerDefinition = {
148
152
  rawOutput: raw,
149
153
  };
150
154
  }
151
- return { status: "succeeded", diagnostics: [], semantic: "none", rawOutput: raw };
155
+ return {
156
+ status: "succeeded",
157
+ diagnostics: [],
158
+ semantic: "none",
159
+ rawOutput: raw,
160
+ };
152
161
  }
153
162
 
154
163
  const hasErrors = diagnostics.some((d) => d.severity === "error");
@@ -21,19 +21,24 @@ function parseDartMachineOutput(raw: string, filePath: string): Diagnostic[] {
21
21
  const parts = line.split("|");
22
22
  if (parts.length < 8) continue;
23
23
 
24
- const [severityStr, , code, file, lineStr, colStr, , ...messageParts] = parts;
24
+ const [severityStr, , code, file, lineStr, colStr, , ...messageParts] =
25
+ parts;
25
26
  const message = messageParts.join("|").trim();
26
27
  const lineNum = parseInt(lineStr, 10);
27
28
  const colNum = parseInt(colStr, 10);
28
29
 
29
30
  // Only include diagnostics for the target file
30
- if (file && !path.resolve(file).endsWith(path.resolve(filePath).replace(/\\/g, "/"))) {
31
+ if (
32
+ file &&
33
+ !path.resolve(file).endsWith(path.resolve(filePath).replace(/\\/g, "/"))
34
+ ) {
31
35
  const resolvedFile = path.resolve(file.trim());
32
36
  const resolvedTarget = path.resolve(filePath);
33
37
  if (resolvedFile !== resolvedTarget) continue;
34
38
  }
35
39
 
36
- const severity = severityStr?.trim().toLowerCase() === "error" ? "error" : "warning";
40
+ const severity =
41
+ severityStr?.trim().toLowerCase() === "error" ? "error" : "warning";
37
42
  diagnostics.push({
38
43
  id: `dart-${code?.trim()}-${lineNum}-${colNum}`,
39
44
  message: `[${code?.trim()}] ${message}`,
@@ -67,21 +72,22 @@ const dartAnalyzeRunner: RunnerDefinition = {
67
72
  async run(ctx: DispatchContext): Promise<RunnerResult> {
68
73
  const cwd = ctx.cwd || process.cwd();
69
74
  const absPath = path.resolve(cwd, ctx.filePath);
70
- const dartAvailable = dart.isAvailable(cwd);
71
- const flutterAvailable = !dartAvailable && flutter.isAvailable(cwd);
75
+ const dartAvailable = await (dart.isAvailableAsync?.(cwd) ??
76
+ dart.isAvailable(cwd));
77
+ const flutterAvailable =
78
+ !dartAvailable &&
79
+ (await (flutter.isAvailableAsync?.(cwd) ?? flutter.isAvailable(cwd)));
72
80
  if (!dartAvailable && !flutterAvailable) {
73
81
  return { status: "skipped", diagnostics: [], semantic: "none" };
74
82
  }
75
- const cmd = dartAvailable ? dart.getCommand(cwd)! : flutter.getCommand(cwd)!;
83
+ const cmd = dartAvailable
84
+ ? dart.getCommand(cwd)!
85
+ : flutter.getCommand(cwd)!;
76
86
  const args = dartAvailable
77
87
  ? ["analyze", "--format=machine", absPath]
78
88
  : ["analyze", "--machine", absPath];
79
89
 
80
- const result = await safeSpawnAsync(
81
- cmd,
82
- args,
83
- { cwd, timeout: 30000 },
84
- );
90
+ const result = await safeSpawnAsync(cmd, args, { cwd, timeout: 30000 });
85
91
 
86
92
  if (result.error && !result.stdout && !result.stderr) {
87
93
  return { status: "skipped", diagnostics: [], semantic: "none" };
@@ -78,7 +78,10 @@ const detektRunner: RunnerDefinition = {
78
78
  return { status: "skipped", diagnostics: [], semantic: "none" };
79
79
  }
80
80
 
81
- const cmd = detekt.isAvailable(cwd) ? detekt.getCommand(cwd) : null;
81
+ const cmd = (await (detekt.isAvailableAsync?.(cwd) ??
82
+ detekt.isAvailable(cwd)))
83
+ ? detekt.getCommand(cwd)
84
+ : null;
82
85
  if (!cmd) return { status: "skipped", diagnostics: [], semantic: "none" };
83
86
 
84
87
  const absPath = path.resolve(cwd, ctx.filePath);