pi-lens 3.1.2 → 3.2.0

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 (154) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +16 -12
  3. package/clients/ast-grep-client.js +8 -1
  4. package/clients/ast-grep-client.ts +9 -1
  5. package/clients/biome-client.js +51 -38
  6. package/clients/biome-client.ts +60 -58
  7. package/clients/dependency-checker.js +30 -1
  8. package/clients/dependency-checker.ts +35 -1
  9. package/clients/dispatch/__tests__/runner-registration.test.ts +286 -282
  10. package/clients/dispatch/bus-dispatcher.js +15 -14
  11. package/clients/dispatch/bus-dispatcher.ts +32 -25
  12. package/clients/dispatch/dispatcher.js +18 -25
  13. package/clients/dispatch/dispatcher.test.ts +2 -1
  14. package/clients/dispatch/dispatcher.ts +17 -28
  15. package/clients/dispatch/plan.js +77 -32
  16. package/clients/dispatch/plan.ts +78 -32
  17. package/clients/dispatch/runners/ast-grep-napi.js +36 -376
  18. package/clients/dispatch/runners/ast-grep-napi.ts +60 -433
  19. package/clients/dispatch/runners/index.js +8 -4
  20. package/clients/dispatch/runners/index.ts +8 -4
  21. package/clients/dispatch/runners/lsp.js +65 -0
  22. package/clients/dispatch/runners/lsp.ts +125 -0
  23. package/clients/dispatch/runners/oxlint.js +2 -2
  24. package/clients/dispatch/runners/oxlint.ts +2 -2
  25. package/clients/dispatch/runners/pyright.js +24 -8
  26. package/clients/dispatch/runners/pyright.ts +28 -14
  27. package/clients/dispatch/runners/rust-clippy.js +2 -2
  28. package/clients/dispatch/runners/rust-clippy.ts +2 -4
  29. package/clients/dispatch/runners/tree-sitter.js +14 -2
  30. package/clients/dispatch/runners/tree-sitter.ts +15 -2
  31. package/clients/dispatch/runners/ts-lsp.js +3 -3
  32. package/clients/dispatch/runners/ts-lsp.ts +8 -5
  33. package/clients/dispatch/runners/yaml-rule-parser.js +292 -0
  34. package/clients/dispatch/runners/yaml-rule-parser.ts +338 -0
  35. package/clients/dispatch/types.js +3 -0
  36. package/clients/dispatch/types.ts +3 -0
  37. package/clients/formatters.js +67 -14
  38. package/clients/formatters.ts +68 -15
  39. package/clients/installer/index.js +78 -10
  40. package/clients/installer/index.ts +519 -426
  41. package/clients/jscpd-client.js +28 -0
  42. package/clients/jscpd-client.ts +41 -3
  43. package/clients/knip-client.js +30 -1
  44. package/clients/knip-client.ts +34 -2
  45. package/clients/lsp/__tests__/client.test.ts +64 -41
  46. package/clients/lsp/__tests__/config.test.ts +25 -17
  47. package/clients/lsp/__tests__/launch.test.ts +108 -43
  48. package/clients/lsp/__tests__/service.test.ts +76 -48
  49. package/clients/lsp/client.js +87 -2
  50. package/clients/lsp/client.ts +150 -6
  51. package/clients/lsp/config.js +8 -11
  52. package/clients/lsp/config.ts +24 -21
  53. package/clients/lsp/index.js +69 -0
  54. package/clients/lsp/index.ts +82 -0
  55. package/clients/lsp/interactive-install.js +19 -8
  56. package/clients/lsp/interactive-install.ts +52 -27
  57. package/clients/lsp/launch.js +182 -32
  58. package/clients/lsp/launch.ts +241 -38
  59. package/clients/lsp/path-utils.js +3 -46
  60. package/clients/lsp/path-utils.ts +11 -51
  61. package/clients/lsp/server.js +93 -71
  62. package/clients/lsp/server.ts +173 -131
  63. package/clients/path-utils.js +142 -0
  64. package/clients/path-utils.ts +153 -0
  65. package/clients/ruff-client.js +33 -4
  66. package/clients/ruff-client.ts +44 -13
  67. package/clients/safe-spawn.js +3 -1
  68. package/clients/safe-spawn.ts +3 -1
  69. package/clients/services/effect-integration.js +11 -7
  70. package/clients/services/effect-integration.ts +34 -26
  71. package/clients/sg-runner.js +51 -9
  72. package/clients/sg-runner.ts +58 -15
  73. package/clients/tree-sitter-client.js +12 -0
  74. package/clients/tree-sitter-client.ts +12 -0
  75. package/clients/typescript-client.js +6 -2
  76. package/clients/typescript-client.ts +9 -2
  77. package/commands/booboo.js +2 -4
  78. package/commands/booboo.ts +2 -4
  79. package/index.ts +377 -93
  80. package/package.json +2 -1
  81. package/rules/tree-sitter-queries/tsx/no-nested-links.yml +45 -0
  82. package/rules/tree-sitter-queries/typescript/constructor-super.yml +55 -0
  83. package/rules/tree-sitter-queries/typescript/debugger.yml +1 -1
  84. package/rules/tree-sitter-queries/typescript/no-dupe-class-members.yml +47 -0
  85. package/tsconfig.json +1 -1
  86. package/clients/__tests__/file-time.test.js +0 -216
  87. package/clients/__tests__/format-service.test.js +0 -245
  88. package/clients/__tests__/formatters.test.js +0 -271
  89. package/clients/agent-behavior-client.test.js +0 -94
  90. package/clients/ast-grep-client.test.js +0 -129
  91. package/clients/ast-grep-client.test.ts +0 -155
  92. package/clients/biome-client.test.js +0 -144
  93. package/clients/cache-manager.test.js +0 -197
  94. package/clients/complexity-client.test.js +0 -234
  95. package/clients/dependency-checker.test.js +0 -60
  96. package/clients/dispatch/__tests__/autofix-integration.test.js +0 -245
  97. package/clients/dispatch/__tests__/runner-registration.test.js +0 -236
  98. package/clients/dispatch/dispatcher.edge.test.js +0 -82
  99. package/clients/dispatch/dispatcher.format.test.js +0 -46
  100. package/clients/dispatch/dispatcher.inline.test.js +0 -74
  101. package/clients/dispatch/dispatcher.test.js +0 -115
  102. package/clients/dispatch/runners/architect.test.js +0 -138
  103. package/clients/dispatch/runners/ast-grep-napi.test.js +0 -106
  104. package/clients/dispatch/runners/oxlint.test.js +0 -230
  105. package/clients/dispatch/runners/pyright.test.js +0 -98
  106. package/clients/dispatch/runners/python-slop.test.js +0 -203
  107. package/clients/dispatch/runners/scan_codebase.test.js +0 -89
  108. package/clients/dispatch/runners/shellcheck.test.js +0 -98
  109. package/clients/dispatch/runners/spellcheck.test.js +0 -158
  110. package/clients/dispatch/runners/ts-slop.test.js +0 -180
  111. package/clients/dispatch/runners/ts-slop.test.ts +0 -230
  112. package/clients/dogfood.test.js +0 -201
  113. package/clients/file-kinds.test.js +0 -169
  114. package/clients/go-client.test.js +0 -127
  115. package/clients/jscpd-client.test.js +0 -127
  116. package/clients/knip-client.test.js +0 -112
  117. package/clients/lsp/__tests__/client.test.js +0 -325
  118. package/clients/lsp/__tests__/config.test.js +0 -166
  119. package/clients/lsp/__tests__/error-recovery.test.js +0 -213
  120. package/clients/lsp/__tests__/integration.test.js +0 -127
  121. package/clients/lsp/__tests__/launch.test.js +0 -260
  122. package/clients/lsp/__tests__/server.test.js +0 -259
  123. package/clients/lsp/__tests__/service.test.js +0 -417
  124. package/clients/metrics-client.test.js +0 -141
  125. package/clients/ruff-client.test.js +0 -132
  126. package/clients/rust-client.test.js +0 -108
  127. package/clients/sanitize.test.js +0 -177
  128. package/clients/secrets-scanner.test.js +0 -100
  129. package/clients/services/__tests__/effect-integration.test.js +0 -86
  130. package/clients/test-runner-client.test.js +0 -192
  131. package/clients/todo-scanner.test.js +0 -301
  132. package/clients/type-coverage-client.test.js +0 -105
  133. package/clients/typescript-client.codefix.test.js +0 -157
  134. package/clients/typescript-client.test.js +0 -105
  135. package/commands/clients/ast-grep-client.js +0 -250
  136. package/commands/clients/ast-grep-parser.js +0 -86
  137. package/commands/clients/ast-grep-rule-manager.js +0 -91
  138. package/commands/clients/ast-grep-types.js +0 -9
  139. package/commands/clients/biome-client.js +0 -380
  140. package/commands/clients/complexity-client.js +0 -667
  141. package/commands/clients/file-kinds.js +0 -177
  142. package/commands/clients/file-utils.js +0 -40
  143. package/commands/clients/jscpd-client.js +0 -169
  144. package/commands/clients/knip-client.js +0 -211
  145. package/commands/clients/ruff-client.js +0 -297
  146. package/commands/clients/safe-spawn.js +0 -88
  147. package/commands/clients/scan-utils.js +0 -83
  148. package/commands/clients/sg-runner.js +0 -190
  149. package/commands/clients/types.js +0 -11
  150. package/commands/clients/typescript-client.js +0 -505
  151. package/commands/rate.test.js +0 -119
  152. package/rules/ast-grep-rules/rules/no-dangerously-set-inner-html.yml +0 -13
  153. package/rules/ast-grep-rules/rules/no-debugger.yml +0 -12
  154. package/rules/ast-grep-rules/rules/no-eval.yml +0 -13
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Path utilities for pi-lens
3
+ *
4
+ * Handles cross-platform path normalization, particularly
5
+ * Windows case-insensitivity issues when using paths as Map keys.
6
+ *
7
+ * Approach (inspired by OpenCode's Filesystem.normalizePath):
8
+ * - On Windows: try realpathSync.native() for canonical casing
9
+ * - Falls back to lowercase for files that don't exist yet
10
+ * - On non-Windows: return path as-is (case-sensitive filesystem)
11
+ * - Always convert backslashes to forward slashes for Map key consistency
12
+ */
13
+ import { existsSync, realpathSync } from "node:fs";
14
+ import { dirname, win32 } from "node:path";
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ /**
17
+ * Detect if a path is a Windows path (has drive letter or UNC prefix).
18
+ */
19
+ function isWindowsPath(filePath) {
20
+ return /^[A-Za-z]:/.test(filePath) || filePath.startsWith("\\\\");
21
+ }
22
+ /**
23
+ * Normalize a file path for consistent Map key usage.
24
+ *
25
+ * On Windows:
26
+ * - If the file exists: uses realpathSync.native() to get the canonical
27
+ * filesystem path (actual casing, resolved symlinks)
28
+ * - If the file doesn't exist: resolves the path and lowercases
29
+ * (needed for new files where we haven't written yet)
30
+ *
31
+ * On non-Windows: returns path as-is (case-sensitive filesystem).
32
+ *
33
+ * Always converts backslashes to forward slashes for consistent Map keys.
34
+ */
35
+ export function normalizeFilePath(filePath) {
36
+ // Convert backslashes to forward slashes first
37
+ const normalized = filePath.replace(/\\/g, "/");
38
+ if (process.platform !== "win32" && !isWindowsPath(normalized)) {
39
+ return normalized;
40
+ }
41
+ // Windows: try realpathSync.native() for canonical casing
42
+ // This resolves symlinks and returns the actual filesystem casing
43
+ try {
44
+ const canonical = realpathSync.native(filePath);
45
+ return canonical.replace(/\\/g, "/");
46
+ }
47
+ catch {
48
+ // File doesn't exist yet (new file) — resolve path and lowercase
49
+ // We need to walk up the directory tree to find the nearest existing
50
+ // parent, resolve its casing, then append the non-existent parts
51
+ try {
52
+ return resolveNonExisting(filePath);
53
+ }
54
+ catch {
55
+ // Last resort: just lowercase the resolved path
56
+ const resolved = win32.normalize(win32.resolve(filePath));
57
+ return resolved.replace(/\\/g, "/").toLowerCase();
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Resolve a non-existing path by finding the nearest existing parent,
63
+ * getting its canonical casing, then appending the non-existent parts lowercased.
64
+ *
65
+ * Example: C:\Users\Foo\newdir\file.ts
66
+ * - C:\Users\Foo exists → realpathSync gives C:\Users\Foo
67
+ * - newdir\file.ts doesn't exist → lowercased
68
+ * - Result: C:/Users/Foo/newdir/file.ts
69
+ */
70
+ function resolveNonExisting(filePath) {
71
+ const resolved = win32.resolve(filePath);
72
+ let current = resolved;
73
+ const nonExistentParts = [];
74
+ // Walk up until we find an existing directory
75
+ while (true) {
76
+ if (existsSync(current)) {
77
+ // Found existing ancestor — get its canonical casing
78
+ const canonical = realpathSync.native(current);
79
+ if (nonExistentParts.length === 0) {
80
+ return canonical.replace(/\\/g, "/");
81
+ }
82
+ // Append non-existent parts (lowercased for consistency)
83
+ const tail = nonExistentParts.reverse().join("/").toLowerCase();
84
+ const base = canonical.replace(/\\/g, "/");
85
+ return base.endsWith("/") ? base + tail : `${base}/${tail}`;
86
+ }
87
+ const parent = dirname(current);
88
+ if (parent === current) {
89
+ // Reached filesystem root without finding existing dir
90
+ // Fall back to full lowercase
91
+ throw new Error("No existing parent found");
92
+ }
93
+ nonExistentParts.push(win32.basename(current));
94
+ current = parent;
95
+ }
96
+ }
97
+ /**
98
+ * Convert a file:// URI to a normalized path.
99
+ * Handles URL decoding and Windows drive letter normalization.
100
+ */
101
+ export function uriToPath(uri) {
102
+ try {
103
+ const filePath = fileURLToPath(uri);
104
+ return normalizeFilePath(filePath);
105
+ }
106
+ catch {
107
+ // Not a valid file:// URI, treat as plain path
108
+ return normalizeFilePath(uri);
109
+ }
110
+ }
111
+ /**
112
+ * Convert a path to a file:// URI.
113
+ * Does NOT normalize the path - URIs preserve original casing.
114
+ */
115
+ export function pathToUri(filePath) {
116
+ return pathToFileURL(filePath).href;
117
+ }
118
+ /**
119
+ * Normalize a Map key lookup for file paths.
120
+ * Use this when getting/setting values in Maps that use file paths as keys.
121
+ */
122
+ export function normalizeMapKey(filePath) {
123
+ return normalizeFilePath(filePath);
124
+ }
125
+ /**
126
+ * Compare two file paths for equality, handling Windows case-insensitivity
127
+ * and mixed separators (backslash vs forward slash).
128
+ */
129
+ export function pathsEqual(a, b) {
130
+ return normalizeFilePath(a) === normalizeFilePath(b);
131
+ }
132
+ /**
133
+ * Check if `child` is under `parent` directory.
134
+ * Separator-agnostic and case-insensitive on Windows.
135
+ */
136
+ export function isUnderDir(child, parent) {
137
+ const normChild = normalizeFilePath(child);
138
+ const normParent = normalizeFilePath(parent);
139
+ // Ensure parent ends with / for prefix matching
140
+ const parentPrefix = normParent.endsWith("/") ? normParent : `${normParent}/`;
141
+ return normChild === normParent || normChild.startsWith(parentPrefix);
142
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Path utilities for pi-lens
3
+ *
4
+ * Handles cross-platform path normalization, particularly
5
+ * Windows case-insensitivity issues when using paths as Map keys.
6
+ *
7
+ * Approach (inspired by OpenCode's Filesystem.normalizePath):
8
+ * - On Windows: try realpathSync.native() for canonical casing
9
+ * - Falls back to lowercase for files that don't exist yet
10
+ * - On non-Windows: return path as-is (case-sensitive filesystem)
11
+ * - Always convert backslashes to forward slashes for Map key consistency
12
+ */
13
+
14
+ import { existsSync, realpathSync } from "node:fs";
15
+ import { dirname, win32 } from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ /**
19
+ * Detect if a path is a Windows path (has drive letter or UNC prefix).
20
+ */
21
+ function isWindowsPath(filePath: string): boolean {
22
+ return /^[A-Za-z]:/.test(filePath) || filePath.startsWith("\\\\");
23
+ }
24
+
25
+ /**
26
+ * Normalize a file path for consistent Map key usage.
27
+ *
28
+ * On Windows:
29
+ * - If the file exists: uses realpathSync.native() to get the canonical
30
+ * filesystem path (actual casing, resolved symlinks)
31
+ * - If the file doesn't exist: resolves the path and lowercases
32
+ * (needed for new files where we haven't written yet)
33
+ *
34
+ * On non-Windows: returns path as-is (case-sensitive filesystem).
35
+ *
36
+ * Always converts backslashes to forward slashes for consistent Map keys.
37
+ */
38
+ export function normalizeFilePath(filePath: string): string {
39
+ // Convert backslashes to forward slashes first
40
+ const normalized = filePath.replace(/\\/g, "/");
41
+
42
+ if (process.platform !== "win32" && !isWindowsPath(normalized)) {
43
+ return normalized;
44
+ }
45
+
46
+ // Windows: try realpathSync.native() for canonical casing
47
+ // This resolves symlinks and returns the actual filesystem casing
48
+ try {
49
+ const canonical = realpathSync.native(filePath);
50
+ return canonical.replace(/\\/g, "/");
51
+ } catch {
52
+ // File doesn't exist yet (new file) — resolve path and lowercase
53
+ // We need to walk up the directory tree to find the nearest existing
54
+ // parent, resolve its casing, then append the non-existent parts
55
+ try {
56
+ return resolveNonExisting(filePath);
57
+ } catch {
58
+ // Last resort: just lowercase the resolved path
59
+ const resolved = win32.normalize(win32.resolve(filePath));
60
+ return resolved.replace(/\\/g, "/").toLowerCase();
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Resolve a non-existing path by finding the nearest existing parent,
67
+ * getting its canonical casing, then appending the non-existent parts lowercased.
68
+ *
69
+ * Example: C:\Users\Foo\newdir\file.ts
70
+ * - C:\Users\Foo exists → realpathSync gives C:\Users\Foo
71
+ * - newdir\file.ts doesn't exist → lowercased
72
+ * - Result: C:/Users/Foo/newdir/file.ts
73
+ */
74
+ function resolveNonExisting(filePath: string): string {
75
+ const resolved = win32.resolve(filePath);
76
+ let current = resolved;
77
+ const nonExistentParts: string[] = [];
78
+
79
+ // Walk up until we find an existing directory
80
+ while (true) {
81
+ if (existsSync(current)) {
82
+ // Found existing ancestor — get its canonical casing
83
+ const canonical = realpathSync.native(current);
84
+ if (nonExistentParts.length === 0) {
85
+ return canonical.replace(/\\/g, "/");
86
+ }
87
+ // Append non-existent parts (lowercased for consistency)
88
+ const tail = nonExistentParts.reverse().join("/").toLowerCase();
89
+ const base = canonical.replace(/\\/g, "/");
90
+ return base.endsWith("/") ? base + tail : `${base}/${tail}`;
91
+ }
92
+
93
+ const parent = dirname(current);
94
+ if (parent === current) {
95
+ // Reached filesystem root without finding existing dir
96
+ // Fall back to full lowercase
97
+ throw new Error("No existing parent found");
98
+ }
99
+
100
+ nonExistentParts.push(win32.basename(current));
101
+ current = parent;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Convert a file:// URI to a normalized path.
107
+ * Handles URL decoding and Windows drive letter normalization.
108
+ */
109
+ export function uriToPath(uri: string): string {
110
+ try {
111
+ const filePath = fileURLToPath(uri);
112
+ return normalizeFilePath(filePath);
113
+ } catch {
114
+ // Not a valid file:// URI, treat as plain path
115
+ return normalizeFilePath(uri);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Convert a path to a file:// URI.
121
+ * Does NOT normalize the path - URIs preserve original casing.
122
+ */
123
+ export function pathToUri(filePath: string): string {
124
+ return pathToFileURL(filePath).href;
125
+ }
126
+
127
+ /**
128
+ * Normalize a Map key lookup for file paths.
129
+ * Use this when getting/setting values in Maps that use file paths as keys.
130
+ */
131
+ export function normalizeMapKey(filePath: string): string {
132
+ return normalizeFilePath(filePath);
133
+ }
134
+
135
+ /**
136
+ * Compare two file paths for equality, handling Windows case-insensitivity
137
+ * and mixed separators (backslash vs forward slash).
138
+ */
139
+ export function pathsEqual(a: string, b: string): boolean {
140
+ return normalizeFilePath(a) === normalizeFilePath(b);
141
+ }
142
+
143
+ /**
144
+ * Check if `child` is under `parent` directory.
145
+ * Separator-agnostic and case-insensitive on Windows.
146
+ */
147
+ export function isUnderDir(child: string, parent: string): boolean {
148
+ const normChild = normalizeFilePath(child);
149
+ const normParent = normalizeFilePath(parent);
150
+ // Ensure parent ends with / for prefix matching
151
+ const parentPrefix = normParent.endsWith("/") ? normParent : `${normParent}/`;
152
+ return normChild === normParent || normChild.startsWith(parentPrefix);
153
+ }
@@ -20,7 +20,36 @@ export class RuffClient {
20
20
  : () => { };
21
21
  }
22
22
  /**
23
- * Check if ruff CLI is available
23
+ * Check if ruff CLI is available, auto-install if not
24
+ */
25
+ async ensureAvailable() {
26
+ // Fast path: already checked
27
+ if (this.ruffAvailable !== null)
28
+ return this.ruffAvailable;
29
+ // Check if available in PATH
30
+ const result = safeSpawn("ruff", ["--version"], {
31
+ timeout: 5000,
32
+ });
33
+ this.ruffAvailable = !result.error && result.status === 0;
34
+ if (this.ruffAvailable) {
35
+ this.log(`Ruff found: ${result.stdout.trim()}`);
36
+ return true;
37
+ }
38
+ // Auto-install via pi-lens installer
39
+ this.log("Ruff not found, attempting auto-install...");
40
+ const { ensureTool } = await import("./installer/index.js");
41
+ const installedPath = await ensureTool("ruff");
42
+ if (installedPath) {
43
+ this.log(`Ruff auto-installed: ${installedPath}`);
44
+ this.ruffAvailable = true;
45
+ return true;
46
+ }
47
+ this.log("Ruff auto-install failed");
48
+ return false;
49
+ }
50
+ /**
51
+ * Check if ruff CLI is available (legacy sync method)
52
+ * Prefer ensureAvailable() for auto-install behavior
24
53
  */
25
54
  isAvailable() {
26
55
  if (this.ruffAvailable !== null)
@@ -166,8 +195,8 @@ export class RuffClient {
166
195
  }
167
196
  // Filter to existing Python files
168
197
  const validFiles = filePaths
169
- .map(f => path.resolve(f))
170
- .filter(f => fs.existsSync(f) && f.endsWith(".py"));
198
+ .map((f) => path.resolve(f))
199
+ .filter((f) => fs.existsSync(f) && f.endsWith(".py"));
171
200
  if (validFiles.length === 0) {
172
201
  return { success: true, fixed: 0, changed: 0 };
173
202
  }
@@ -176,7 +205,7 @@ export class RuffClient {
176
205
  let totalFixable = 0;
177
206
  for (const file of validFiles) {
178
207
  const diags = this.checkFile(file);
179
- totalFixable += diags.filter(d => d.fixable).length;
208
+ totalFixable += diags.filter((d) => d.fixable).length;
180
209
  }
181
210
  // Run ruff once on all files - much faster than per file
182
211
  const result = safeSpawn("ruff", ["check", "--fix", ...validFiles], {
@@ -8,7 +8,6 @@
8
8
  * Docs: https://docs.astral.sh/ruff/
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 { isFileKind } from "./file-kinds.js";
@@ -51,7 +50,41 @@ export class RuffClient {
51
50
  }
52
51
 
53
52
  /**
54
- * Check if ruff CLI is available
53
+ * Check if ruff CLI is available, auto-install if not
54
+ */
55
+ async ensureAvailable(): Promise<boolean> {
56
+ // Fast path: already checked
57
+ if (this.ruffAvailable !== null) return this.ruffAvailable;
58
+
59
+ // Check if available in PATH
60
+ const result = safeSpawn("ruff", ["--version"], {
61
+ timeout: 5000,
62
+ });
63
+ this.ruffAvailable = !result.error && result.status === 0;
64
+
65
+ if (this.ruffAvailable) {
66
+ this.log(`Ruff found: ${result.stdout.trim()}`);
67
+ return true;
68
+ }
69
+
70
+ // Auto-install via pi-lens installer
71
+ this.log("Ruff not found, attempting auto-install...");
72
+ const { ensureTool } = await import("./installer/index.js");
73
+ const installedPath = await ensureTool("ruff");
74
+
75
+ if (installedPath) {
76
+ this.log(`Ruff auto-installed: ${installedPath}`);
77
+ this.ruffAvailable = true;
78
+ return true;
79
+ }
80
+
81
+ this.log("Ruff auto-install failed");
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Check if ruff CLI is available (legacy sync method)
87
+ * Prefer ensureAvailable() for auto-install behavior
55
88
  */
56
89
  isAvailable(): boolean {
57
90
  if (this.ruffAvailable !== null) return this.ruffAvailable;
@@ -230,8 +263,8 @@ export class RuffClient {
230
263
 
231
264
  // Filter to existing Python files
232
265
  const validFiles = filePaths
233
- .map(f => path.resolve(f))
234
- .filter(f => fs.existsSync(f) && f.endsWith(".py"));
266
+ .map((f) => path.resolve(f))
267
+ .filter((f) => fs.existsSync(f) && f.endsWith(".py"));
235
268
 
236
269
  if (validFiles.length === 0) {
237
270
  return { success: true, fixed: 0, changed: 0 };
@@ -242,17 +275,13 @@ export class RuffClient {
242
275
  let totalFixable = 0;
243
276
  for (const file of validFiles) {
244
277
  const diags = this.checkFile(file);
245
- totalFixable += diags.filter(d => d.fixable).length;
278
+ totalFixable += diags.filter((d) => d.fixable).length;
246
279
  }
247
280
 
248
281
  // Run ruff once on all files - much faster than per file
249
- const result = safeSpawn(
250
- "ruff",
251
- ["check", "--fix", ...validFiles],
252
- {
253
- timeout: 60000, // Longer timeout for batch
254
- },
255
- );
282
+ const result = safeSpawn("ruff", ["check", "--fix", ...validFiles], {
283
+ timeout: 60000, // Longer timeout for batch
284
+ });
256
285
 
257
286
  if (result.error) {
258
287
  return {
@@ -263,7 +292,9 @@ export class RuffClient {
263
292
  };
264
293
  }
265
294
 
266
- this.log(`Fixed ${totalFixable} issue(s) in ${validFiles.length} file(s)`);
295
+ this.log(
296
+ `Fixed ${totalFixable} issue(s) in ${validFiles.length} file(s)`,
297
+ );
267
298
 
268
299
  return { success: true, fixed: totalFixable, changed: validFiles.length };
269
300
  } catch (err: any) {
@@ -48,11 +48,13 @@ export async function safeSpawnAsync(command, args, options) {
48
48
  let timedOut = false;
49
49
  let killed = false;
50
50
  // Spawn the process (non-blocking)
51
+ // On Windows, use shell mode for .cmd files (like pyright, biome)
52
+ const isWindows = process.platform === "win32";
51
53
  const child = spawn(command, args, {
52
54
  cwd: options?.cwd,
53
55
  env: { ...process.env, ...options?.env },
54
56
  windowsHide: true,
55
- shell: false,
57
+ shell: isWindows,
56
58
  });
57
59
  // Handle abort signal
58
60
  const onAbort = () => {
@@ -72,11 +72,13 @@ export async function safeSpawnAsync(
72
72
  let killed = false;
73
73
 
74
74
  // Spawn the process (non-blocking)
75
+ // On Windows, use shell mode for .cmd files (like pyright, biome)
76
+ const isWindows = process.platform === "win32";
75
77
  const child = spawn(command, args, {
76
78
  cwd: options?.cwd,
77
79
  env: { ...process.env, ...options?.env },
78
80
  windowsHide: true,
79
- shell: false,
81
+ shell: isWindows,
80
82
  });
81
83
 
82
84
  // Handle abort signal
@@ -9,9 +9,9 @@
9
9
  * - Graceful error recovery
10
10
  * - Bus event integration
11
11
  */
12
- import { runRunnersConcurrent, executeEffect, } from "./runner-service.js";
13
- import { DiagnosticFound, RunnerStarted, RunnerCompleted, FileModified, } from "../bus/events.js";
12
+ import { DiagnosticFound, FileModified, RunnerCompleted, RunnerStarted, } from "../bus/events.js";
14
13
  import { formatDiagnostics } from "../dispatch/utils/format-utils.js";
14
+ import { executeEffect, runRunnersConcurrent, } from "./runner-service.js";
15
15
  // Import runners to register them in the dispatcher
16
16
  import "../dispatch/runners/index.js";
17
17
  // --- Core Functions ---
@@ -20,11 +20,10 @@ import "../dispatch/runners/index.js";
20
20
  */
21
21
  async function runGroupConcurrent(ctx, group) {
22
22
  const { getRunner, getRunnersForKind } = await import("../dispatch/dispatcher.js");
23
- const startTime = Date.now();
23
+ const _startTime = Date.now();
24
24
  // Get runner definitions
25
25
  const runnerDefs = group.filterKinds
26
- ? group.runnerIds
27
- .filter((id) => {
26
+ ? group.runnerIds.filter((id) => {
28
27
  const runner = getRunner(id);
29
28
  return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
30
29
  })
@@ -59,7 +58,11 @@ async function runGroupConcurrent(ctx, group) {
59
58
  filePath: ctx.filePath,
60
59
  line: d.line,
61
60
  column: d.column,
62
- severity: d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
61
+ severity: d.severity === "error"
62
+ ? "error"
63
+ : d.severity === "warning"
64
+ ? "warning"
65
+ : "info",
63
66
  semantic: d.semantic ?? result.semantic ?? "warning",
64
67
  tool: runnerId,
65
68
  rule: d.rule,
@@ -182,7 +185,8 @@ export async function dispatchWithEffect(ctx, groups) {
182
185
  export async function dispatchLintWithEffect(filePath, cwd, pi) {
183
186
  const { createDispatchContext } = await import("../dispatch/dispatcher.js");
184
187
  const { TOOL_PLANS } = await import("../dispatch/plan.js");
185
- const ctx = createDispatchContext(filePath, cwd, pi);
188
+ // blockingOnly=true: post-write dispatch only reports blocking errors (same as standard dispatchLint)
189
+ const ctx = createDispatchContext(filePath, cwd, pi, undefined, true);
186
190
  const kind = ctx.kind;
187
191
  if (!kind)
188
192
  return "";
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Effect-TS Integration for pi-lens Dispatch
3
- *
3
+ *
4
4
  * Bridges the Effect service layer with the existing dispatch system.
5
- *
5
+ *
6
6
  * This provides:
7
7
  * - Concurrent runner execution with Effect.all
8
8
  * - Timeout handling for slow runners
@@ -11,21 +11,19 @@
11
11
  */
12
12
 
13
13
  import {
14
- runRunnersConcurrent,
15
- executeEffect,
16
- formatError,
17
- type RunnerResult,
18
- type ConcurrentRunnerResult,
19
- } from "./runner-service.js";
20
-
21
- import {
14
+ type Diagnostic,
22
15
  DiagnosticFound,
23
- RunnerStarted,
24
- RunnerCompleted,
25
16
  FileModified,
26
- type Diagnostic,
17
+ RunnerCompleted,
18
+ RunnerStarted,
27
19
  } from "../bus/events.js";
28
- import { formatDiagnostic, formatDiagnostics } from "../dispatch/utils/format-utils.js";
20
+ import { formatDiagnostics } from "../dispatch/utils/format-utils.js";
21
+ import {
22
+ type ConcurrentRunnerResult,
23
+ executeEffect,
24
+ type RunnerResult,
25
+ runRunnersConcurrent,
26
+ } from "./runner-service.js";
29
27
  // Import runners to register them in the dispatcher
30
28
  import "../dispatch/runners/index.js";
31
29
 
@@ -54,16 +52,17 @@ async function runGroupConcurrent(
54
52
  ctx: DispatchContext,
55
53
  group: RunnerGroup,
56
54
  ): Promise<{ results: ConcurrentRunnerResult[]; diagnostics: Diagnostic[] }> {
57
- const { getRunner, getRunnersForKind } = await import("../dispatch/dispatcher.js");
58
- const startTime = Date.now();
55
+ const { getRunner, getRunnersForKind } = await import(
56
+ "../dispatch/dispatcher.js"
57
+ );
58
+ const _startTime = Date.now();
59
59
 
60
60
  // Get runner definitions
61
61
  const runnerDefs = group.filterKinds
62
- ? group.runnerIds
63
- .filter((id) => {
64
- const runner = getRunner(id);
65
- return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
66
- })
62
+ ? group.runnerIds.filter((id) => {
63
+ const runner = getRunner(id);
64
+ return runner && ctx.kind && group.filterKinds?.includes(ctx.kind);
65
+ })
67
66
  : group.runnerIds;
68
67
 
69
68
  const runners = runnerDefs
@@ -76,7 +75,10 @@ async function runGroupConcurrent(
76
75
  }
77
76
 
78
77
  // Build the single runner execution function
79
- const runSingle = async (filePath: string, runnerId: string): Promise<RunnerResult> => {
78
+ const runSingle = async (
79
+ filePath: string,
80
+ runnerId: string,
81
+ ): Promise<RunnerResult> => {
80
82
  const runner = getRunner(runnerId);
81
83
  if (!runner) {
82
84
  return { diagnostics: [], durationMs: 0 };
@@ -101,7 +103,12 @@ async function runGroupConcurrent(
101
103
  filePath: ctx.filePath,
102
104
  line: d.line,
103
105
  column: d.column,
104
- severity: d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
106
+ severity:
107
+ d.severity === "error"
108
+ ? "error"
109
+ : d.severity === "warning"
110
+ ? "warning"
111
+ : "info",
105
112
  semantic: d.semantic ?? result.semantic ?? "warning",
106
113
  tool: runnerId,
107
114
  rule: d.rule,
@@ -150,7 +157,7 @@ async function runGroupConcurrent(
150
157
  // Run all runners concurrently using Effect
151
158
  const runnerIds = runners.map((r) => r.id);
152
159
  const concurrentResults = await executeEffect(
153
- runRunnersConcurrent(ctx.filePath, runnerIds, runSingle, 30_000)
160
+ runRunnersConcurrent(ctx.filePath, runnerIds, runSingle, 30_000),
154
161
  );
155
162
 
156
163
  // Collect all diagnostics
@@ -165,7 +172,7 @@ async function runGroupConcurrent(
165
172
  severity: d.severity,
166
173
  semantic: d.semantic ?? group.semantic ?? "warning",
167
174
  tool: result.runnerId,
168
- }))
175
+ })),
169
176
  );
170
177
  }
171
178
  }
@@ -255,7 +262,8 @@ export async function dispatchLintWithEffect(
255
262
  const { createDispatchContext } = await import("../dispatch/dispatcher.js");
256
263
  const { TOOL_PLANS } = await import("../dispatch/plan.js");
257
264
 
258
- const ctx = createDispatchContext(filePath, cwd, pi);
265
+ // blockingOnly=true: post-write dispatch only reports blocking errors (same as standard dispatchLint)
266
+ const ctx = createDispatchContext(filePath, cwd, pi, undefined, true);
259
267
 
260
268
  const kind = ctx.kind;
261
269
  if (!kind) return "";