@oh-my-pi/pi-coding-agent 17.1.7 → 17.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 (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -0,0 +1,996 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { $which, isRecord, ptree, sanitizeText } from "@oh-my-pi/pi-utils";
4
+ import * as git from "../utils/git";
5
+ import { type CleanseParserKind, parseCleanseDiagnostics } from "./parsers";
6
+ import type { CleanseCheckResult, CleanseDiagnostic, CleanseDiagnosticReport, SkippedCleanseCheck } from "./types";
7
+
8
+ const IGNORED_DIRECTORIES: Record<string, true> = {
9
+ ".git": true,
10
+ ".hg": true,
11
+ ".svn": true,
12
+ ".terraform": true,
13
+ ".venv": true,
14
+ build: true,
15
+ coverage: true,
16
+ dist: true,
17
+ node_modules: true,
18
+ target: true,
19
+ vendor: true,
20
+ };
21
+ const MAX_FAILURE_OUTPUT = 12_000;
22
+ const SHELLCHECK_BATCH_SIZE = 200;
23
+
24
+ interface CheckerPlan {
25
+ id: string;
26
+ label: string;
27
+ language: string;
28
+ cwd: string;
29
+ executable: string;
30
+ args: string[];
31
+ parser: CleanseParserKind;
32
+ command: string;
33
+ mutates: boolean;
34
+ }
35
+
36
+ interface PlanRequest {
37
+ id: string;
38
+ label: string;
39
+ language: string;
40
+ root: string;
41
+ binaries: string[];
42
+ args: string[];
43
+ parser: CleanseParserKind;
44
+ executable?: string;
45
+ mutates?: boolean;
46
+ }
47
+
48
+ interface DiscoveryState {
49
+ projectCwd: string;
50
+ files: string[];
51
+ plans: CheckerPlan[];
52
+ skipped: SkippedCleanseCheck[];
53
+ includeTests: boolean;
54
+ }
55
+
56
+ /** Optional checker families enabled for a cleanse run. */
57
+ export interface CleanseDiagnosticSuiteOptions {
58
+ includeTests?: boolean;
59
+ }
60
+
61
+ /** Re-runnable checker set discovered from one project snapshot. */
62
+ export interface CleanseDiagnosticSuite {
63
+ readonly checkCount: number;
64
+ readonly skipped: readonly SkippedCleanseCheck[];
65
+ run(signal?: AbortSignal): Promise<CleanseDiagnosticReport>;
66
+ }
67
+
68
+ /** Discover configured language checkers without installing missing tools. */
69
+ export async function discoverCleanseDiagnosticSuite(
70
+ projectCwd: string,
71
+ options: CleanseDiagnosticSuiteOptions = {},
72
+ ): Promise<CleanseDiagnosticSuite> {
73
+ const absoluteCwd = path.resolve(projectCwd);
74
+ const resolvedCwd = await fs.realpath(absoluteCwd).catch(() => absoluteCwd);
75
+ const state: DiscoveryState = {
76
+ projectCwd: resolvedCwd,
77
+ files: await listProjectFiles(resolvedCwd),
78
+ plans: [],
79
+ skipped: [],
80
+ includeTests: options.includeTests === true,
81
+ };
82
+ await discoverRust(state);
83
+ discoverGo(state);
84
+ discoverPython(state);
85
+ await discoverJavaScript(state);
86
+ discoverRuby(state);
87
+ discoverPhp(state);
88
+ discoverSwift(state);
89
+ discoverDart(state);
90
+ discoverElixir(state);
91
+ discoverShell(state);
92
+ discoverHaskell(state);
93
+ discoverTerraform(state);
94
+ discoverLua(state);
95
+ discoverCpp(state);
96
+ discoverDotnet(state);
97
+ discoverZig(state);
98
+ discoverJvm(state);
99
+
100
+ const allowedFiles = new Set(state.files);
101
+ return {
102
+ checkCount: state.plans.length,
103
+ skipped: state.skipped,
104
+ async run(signal?: AbortSignal): Promise<CleanseDiagnosticReport> {
105
+ const mutatingChecks: CleanseCheckResult[] = [];
106
+ for (const plan of state.plans) {
107
+ if (plan.mutates) mutatingChecks.push(await runChecker(plan, resolvedCwd, allowedFiles, signal));
108
+ }
109
+ const parallelChecks = await Promise.all(
110
+ state.plans.filter(plan => !plan.mutates).map(plan => runChecker(plan, resolvedCwd, allowedFiles, signal)),
111
+ );
112
+ const checks = [...mutatingChecks, ...parallelChecks];
113
+ return {
114
+ checks,
115
+ diagnostics: deduplicateProjectDiagnostics(checks.flatMap(check => check.diagnostics)),
116
+ skipped: [...state.skipped],
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ async function listProjectFiles(cwd: string): Promise<string[]> {
123
+ try {
124
+ const [tracked, untracked] = await Promise.all([git.ls.files(cwd), git.ls.untracked(cwd)]);
125
+ return normalizeFiles([...tracked, ...untracked]);
126
+ } catch {
127
+ const files: string[] = [];
128
+ for await (const entry of new Bun.Glob("**/*").scan({ cwd, absolute: false, dot: true, onlyFiles: true })) {
129
+ const normalized = entry.split(path.sep).join("/");
130
+ if (hasIgnoredDirectory(normalized)) continue;
131
+ files.push(normalized);
132
+ }
133
+ return normalizeFiles(files);
134
+ }
135
+ }
136
+
137
+ function normalizeFiles(files: string[]): string[] {
138
+ const seen = new Set<string>();
139
+ const normalized: string[] = [];
140
+ for (const file of files) {
141
+ const value = file.replaceAll("\\", "/").replace(/^\.\//, "");
142
+ if (!value || hasIgnoredDirectory(value) || seen.has(value)) continue;
143
+ seen.add(value);
144
+ normalized.push(value);
145
+ }
146
+ return normalized.sort();
147
+ }
148
+ function hasIgnoredDirectory(file: string): boolean {
149
+ for (const segment of file.split("/")) {
150
+ if (IGNORED_DIRECTORIES[segment] || segment.startsWith("bazel-")) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ function markerRoots(files: readonly string[], matches: (file: string, basename: string) => boolean): string[] {
156
+ const roots: string[] = [];
157
+ for (const file of files) {
158
+ if (!matches(file, path.posix.basename(file))) continue;
159
+ roots.push(path.posix.dirname(file));
160
+ }
161
+ return topmostRoots(roots);
162
+ }
163
+
164
+ function topmostRoots(roots: readonly string[]): string[] {
165
+ const sorted = [...new Set(roots)].sort(
166
+ (left, right) =>
167
+ (left === "." ? 0 : left.split("/").length) - (right === "." ? 0 : right.split("/").length) ||
168
+ left.localeCompare(right),
169
+ );
170
+ const selected: string[] = [];
171
+ for (const root of sorted) {
172
+ if (selected.some(parent => root === parent || (parent === "." ? true : root.startsWith(`${parent}/`)))) continue;
173
+ selected.push(root);
174
+ }
175
+ return selected;
176
+ }
177
+
178
+ function containsExtension(files: readonly string[], extensions: readonly string[]): boolean {
179
+ return files.some(file => extensions.some(extension => file.toLowerCase().endsWith(extension)));
180
+ }
181
+
182
+ function rootsForBasenames(files: readonly string[], basenames: Record<string, true>): string[] {
183
+ return markerRoots(files, (_file, basename) => basenames[basename] === true);
184
+ }
185
+
186
+ function rootsForPattern(files: readonly string[], pattern: RegExp): string[] {
187
+ return markerRoots(files, file => pattern.test(file));
188
+ }
189
+
190
+ function resolveBinary(state: DiscoveryState, root: string, names: readonly string[]): string | undefined {
191
+ const cwd = path.resolve(state.projectCwd, root);
192
+ const searchDirectories: string[] = [];
193
+ let current = cwd;
194
+ while (true) {
195
+ searchDirectories.push(
196
+ path.join(current, "node_modules", ".bin"),
197
+ path.join(current, ".venv", process.platform === "win32" ? "Scripts" : "bin"),
198
+ path.join(current, "venv", process.platform === "win32" ? "Scripts" : "bin"),
199
+ path.join(current, "vendor", "bin"),
200
+ );
201
+ if (current === state.projectCwd) break;
202
+ const parent = path.dirname(current);
203
+ if (parent === current || (!parent.startsWith(`${state.projectCwd}${path.sep}`) && parent !== state.projectCwd))
204
+ break;
205
+ current = parent;
206
+ }
207
+ const searchPath = [...new Set(searchDirectories), Bun.env.PATH ?? ""].filter(Boolean).join(path.delimiter);
208
+ for (const name of names) {
209
+ const executable = $which(name, { cwd, PATH: searchPath });
210
+ if (executable) return executable;
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ function addPlan(state: DiscoveryState, request: PlanRequest): void {
216
+ const cwd = path.resolve(state.projectCwd, request.root);
217
+ const executable = request.executable ?? resolveBinary(state, request.root, request.binaries);
218
+ const rootLabel = request.root === "." ? "." : request.root;
219
+ if (!executable) {
220
+ state.skipped.push({
221
+ label: `${request.label} (${rootLabel})`,
222
+ language: request.language,
223
+ reason: `executable not found: ${request.binaries.join(" or ")}`,
224
+ });
225
+ return;
226
+ }
227
+ const displayExecutable = path.basename(executable);
228
+ state.plans.push({
229
+ id: `${request.id}:${request.root}`,
230
+ label: `${request.label} (${rootLabel})`,
231
+ language: request.language,
232
+ cwd,
233
+ executable,
234
+ args: request.args,
235
+ parser: request.parser,
236
+ command: formatCommand([displayExecutable, ...request.args]),
237
+ mutates: request.mutates ?? false,
238
+ });
239
+ }
240
+
241
+ function formatCommand(argv: readonly string[]): string {
242
+ return argv.map(arg => (/^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : JSON.stringify(arg))).join(" ");
243
+ }
244
+
245
+ async function discoverRust(state: DiscoveryState): Promise<void> {
246
+ for (const root of rootsForBasenames(state.files, { "Cargo.toml": true })) {
247
+ const cargo = resolveBinary(state, root, ["cargo"]);
248
+ if (!cargo) {
249
+ addPlan(state, {
250
+ id: "clippy",
251
+ label: "cargo clippy",
252
+ language: "Rust",
253
+ root,
254
+ binaries: ["cargo"],
255
+ args: [],
256
+ parser: "rust",
257
+ });
258
+ continue;
259
+ }
260
+ let packages: string[];
261
+ try {
262
+ packages = await cargoWorkspacePackages(state, root, cargo);
263
+ } catch (error) {
264
+ state.skipped.push({
265
+ label: `cargo clippy (${root})`,
266
+ language: "Rust",
267
+ reason: `cargo metadata failed: ${error instanceof Error ? error.message : String(error)}`,
268
+ });
269
+ continue;
270
+ }
271
+ if (packages.length === 0) {
272
+ state.skipped.push({
273
+ label: `cargo clippy (${root})`,
274
+ language: "Rust",
275
+ reason: "no first-party workspace packages found",
276
+ });
277
+ continue;
278
+ }
279
+ const packageArgs = packages.flatMap(packageName => ["--package", packageName]);
280
+ addPlan(state, {
281
+ id: "clippy",
282
+ label: "cargo clippy",
283
+ language: "Rust",
284
+ root,
285
+ binaries: ["cargo"],
286
+ executable: cargo,
287
+ args: [
288
+ "clippy",
289
+ "--fix",
290
+ "--allow-dirty",
291
+ "--all-targets",
292
+ "--no-deps",
293
+ "--allow-staged",
294
+ "--broken-code",
295
+ "--allow-no-vcs",
296
+ ...packageArgs,
297
+ "--message-format=json",
298
+ "--",
299
+ "-D",
300
+ "warnings",
301
+ ],
302
+ parser: "rust",
303
+ mutates: true,
304
+ });
305
+ if (state.includeTests) {
306
+ addPlan(state, {
307
+ id: "cargo-test",
308
+ label: "cargo test",
309
+ language: "Rust",
310
+ root,
311
+ binaries: ["cargo"],
312
+ executable: cargo,
313
+ args: ["test", "--no-fail-fast", "--all-targets", ...packageArgs, "--message-format=json"],
314
+ parser: "rust-test",
315
+ });
316
+ }
317
+ }
318
+ }
319
+
320
+ async function cargoWorkspacePackages(state: DiscoveryState, root: string, cargo: string): Promise<string[]> {
321
+ const manifest = path.resolve(state.projectCwd, root, "Cargo.toml");
322
+ const result = await ptree.exec(
323
+ [cargo, "metadata", "--no-deps", "--format-version=1", "--manifest-path", manifest],
324
+ {
325
+ cwd: path.resolve(state.projectCwd, root),
326
+ stderr: "full",
327
+ allowNonZero: true,
328
+ },
329
+ );
330
+ if (!result.ok) {
331
+ throw new Error(
332
+ sanitizeText(result.stderr || result.stdout)
333
+ .replace(/\s+/g, " ")
334
+ .trim(),
335
+ );
336
+ }
337
+ let parsed: unknown;
338
+ try {
339
+ parsed = JSON.parse(result.stdout);
340
+ } catch (error) {
341
+ throw new Error(`invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
342
+ }
343
+ if (!isRecord(parsed)) throw new Error("metadata root is not an object");
344
+ const workspaceMembers = new Set<string>();
345
+ if (Array.isArray(parsed.workspace_members)) {
346
+ for (const member of parsed.workspace_members) {
347
+ if (typeof member === "string") workspaceMembers.add(member);
348
+ }
349
+ }
350
+ const allowedFiles = new Set(state.files);
351
+ const packages: string[] = [];
352
+ if (!Array.isArray(parsed.packages)) return packages;
353
+ for (const value of parsed.packages) {
354
+ if (!isRecord(value) || typeof value.id !== "string" || !workspaceMembers.has(value.id)) continue;
355
+ if (typeof value.name !== "string" || typeof value.manifest_path !== "string") continue;
356
+ const relativeManifest = path.relative(state.projectCwd, value.manifest_path).split(path.sep).join("/");
357
+ if (allowedFiles.has(relativeManifest)) packages.push(value.name);
358
+ }
359
+ return [...new Set(packages)].sort();
360
+ }
361
+
362
+ function discoverGo(state: DiscoveryState): void {
363
+ const workRoots = rootsForBasenames(state.files, { "go.work": true });
364
+ const roots = workRoots.length > 0 ? workRoots : rootsForBasenames(state.files, { "go.mod": true });
365
+ for (const root of roots) {
366
+ addPlan(state, {
367
+ id: "go-vet",
368
+ label: "go vet",
369
+ language: "Go",
370
+ root,
371
+ binaries: ["go"],
372
+ args: ["vet", "-json", "./..."],
373
+ parser: "go",
374
+ });
375
+ if (state.includeTests) {
376
+ addPlan(state, {
377
+ id: "go-test",
378
+ label: "go test",
379
+ language: "Go",
380
+ root,
381
+ binaries: ["go"],
382
+ args: ["test", "-json", "./..."],
383
+ parser: "go-test",
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ function discoverPython(state: DiscoveryState): void {
390
+ if (!containsExtension(state.files, [".py", ".pyi"])) return;
391
+ const roots = rootsForBasenames(state.files, {
392
+ ".ruff.toml": true,
393
+ "pyproject.toml": true,
394
+ "ruff.toml": true,
395
+ "setup.cfg": true,
396
+ "tox.ini": true,
397
+ });
398
+ for (const root of roots.length > 0 ? roots : ["."]) {
399
+ addPlan(state, {
400
+ id: "ruff",
401
+ label: "ruff",
402
+ language: "Python",
403
+ root,
404
+ binaries: ["ruff"],
405
+ args: ["check", "--output-format=json", "."],
406
+ parser: "ruff",
407
+ });
408
+ if (state.includeTests) {
409
+ addPlan(state, {
410
+ id: "pytest",
411
+ label: "pytest",
412
+ language: "Python",
413
+ root,
414
+ binaries: ["pytest"],
415
+ args: ["-q"],
416
+ parser: "generic",
417
+ });
418
+ }
419
+ }
420
+ for (const root of rootsForBasenames(state.files, { "pyrightconfig.json": true })) {
421
+ addPlan(state, {
422
+ id: "pyright",
423
+ label: "pyright",
424
+ language: "Python",
425
+ root,
426
+ binaries: ["pyright"],
427
+ args: ["--outputjson"],
428
+ parser: "pyright",
429
+ });
430
+ }
431
+ }
432
+
433
+ async function discoverJavaScript(state: DiscoveryState): Promise<void> {
434
+ if (!containsExtension(state.files, [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"])) return;
435
+ for (const root of rootsForBasenames(state.files, { "biome.json": true, "biome.jsonc": true })) {
436
+ addPlan(state, {
437
+ id: "biome",
438
+ label: "biome check",
439
+ language: "JavaScript/TypeScript",
440
+ root,
441
+ binaries: ["biome"],
442
+ args: ["check", ".", "--reporter=json"],
443
+ parser: "biome",
444
+ });
445
+ }
446
+ const eslintConfig =
447
+ /(?:^|\/)(?:eslint\.config\.(?:js|mjs|cjs|ts|mts|cts)|\.eslintrc(?:\.(?:js|cjs|json|yaml|yml))?)$/;
448
+ for (const root of rootsForPattern(state.files, eslintConfig)) {
449
+ addPlan(state, {
450
+ id: "eslint",
451
+ label: "eslint",
452
+ language: "JavaScript/TypeScript",
453
+ root,
454
+ binaries: ["eslint"],
455
+ args: [".", "--format=json"],
456
+ parser: "eslint",
457
+ });
458
+ }
459
+ for (const root of rootsForBasenames(state.files, { "tsconfig.json": true })) {
460
+ addPlan(state, {
461
+ id: "typescript",
462
+ label: "TypeScript",
463
+ language: "TypeScript",
464
+ root,
465
+ binaries: ["tsgo", "tsc"],
466
+ args: ["--noEmit", "--pretty", "false"],
467
+ parser: "generic",
468
+ });
469
+ }
470
+ if (state.includeTests) await discoverPackageTests(state);
471
+ }
472
+
473
+ async function discoverPackageTests(state: DiscoveryState): Promise<void> {
474
+ const candidates: Array<{ root: string; manager: string }> = [];
475
+ for (const file of state.files) {
476
+ if (path.posix.basename(file) !== "package.json") continue;
477
+ let manifest: unknown;
478
+ try {
479
+ manifest = await Bun.file(path.resolve(state.projectCwd, file)).json();
480
+ } catch {
481
+ continue;
482
+ }
483
+ if (!isRecord(manifest) || !isRecord(manifest.scripts)) continue;
484
+ const testScript = manifest.scripts.test;
485
+ if (
486
+ typeof testScript !== "string" ||
487
+ !testScript.trim() ||
488
+ testScript.toLowerCase().includes("no test specified")
489
+ ) {
490
+ continue;
491
+ }
492
+ const root = path.posix.dirname(file);
493
+ let manager =
494
+ typeof manifest.packageManager === "string" ? manifest.packageManager.split("@", 1)[0].toLowerCase() : "";
495
+ const prefix = root === "." ? "" : `${root}/`;
496
+ if (!["bun", "npm", "pnpm", "yarn"].includes(manager)) {
497
+ if (state.files.includes(`${prefix}bun.lock`) || state.files.includes(`${prefix}bun.lockb`)) manager = "bun";
498
+ else if (state.files.includes(`${prefix}pnpm-lock.yaml`)) manager = "pnpm";
499
+ else if (state.files.includes(`${prefix}yarn.lock`)) manager = "yarn";
500
+ else manager = "npm";
501
+ }
502
+ candidates.push({ root, manager });
503
+ }
504
+ const selectedRoots = new Set(topmostRoots(candidates.map(candidate => candidate.root)));
505
+ for (const candidate of candidates) {
506
+ if (!selectedRoots.has(candidate.root)) continue;
507
+ addPlan(state, {
508
+ id: `${candidate.manager}-test`,
509
+ label: `${candidate.manager} test`,
510
+ language: "JavaScript/TypeScript",
511
+ root: candidate.root,
512
+ binaries: [candidate.manager],
513
+ args: ["run", "test"],
514
+ parser: "generic",
515
+ });
516
+ }
517
+ }
518
+
519
+ function discoverRuby(state: DiscoveryState): void {
520
+ if (!containsExtension(state.files, [".rb", ".rake"])) return;
521
+ for (const root of rootsForBasenames(state.files, { ".rubocop.yml": true, ".rubocop.yaml": true })) {
522
+ addPlan(state, {
523
+ id: "rubocop",
524
+ label: "rubocop",
525
+ language: "Ruby",
526
+ root,
527
+ binaries: ["rubocop"],
528
+ args: ["--format", "json", "--force-exclusion"],
529
+ parser: "rubocop",
530
+ });
531
+ }
532
+ if (state.includeTests) {
533
+ for (const root of rootsForPattern(state.files, /(?:^|\/)(?:\.rspec|Gemfile)$/)) {
534
+ addPlan(state, {
535
+ id: "rspec",
536
+ label: "RSpec",
537
+ language: "Ruby",
538
+ root,
539
+ binaries: ["rspec"],
540
+ args: ["--format", "progress"],
541
+ parser: "generic",
542
+ });
543
+ }
544
+ }
545
+ }
546
+
547
+ function discoverPhp(state: DiscoveryState): void {
548
+ if (!containsExtension(state.files, [".php"])) return;
549
+ for (const root of rootsForPattern(state.files, /(?:^|\/)phpstan\.neon(?:\.dist)?$/)) {
550
+ addPlan(state, {
551
+ id: "phpstan",
552
+ label: "PHPStan",
553
+ language: "PHP",
554
+ root,
555
+ binaries: ["phpstan"],
556
+ args: ["analyse", "--error-format=json", "--no-progress"],
557
+ parser: "phpstan",
558
+ });
559
+ }
560
+ for (const root of rootsForPattern(state.files, /(?:^|\/)psalm(?:\.xml|\.xml\.dist)$/)) {
561
+ addPlan(state, {
562
+ id: "psalm",
563
+ label: "Psalm",
564
+ language: "PHP",
565
+ root,
566
+ binaries: ["psalm"],
567
+ args: ["--output-format=json", "--no-progress"],
568
+ parser: "psalm",
569
+ });
570
+ }
571
+ if (state.includeTests) {
572
+ for (const root of rootsForPattern(state.files, /(?:^|\/)phpunit\.xml(?:\.dist)?$/)) {
573
+ addPlan(state, {
574
+ id: "phpunit",
575
+ label: "PHPUnit",
576
+ language: "PHP",
577
+ root,
578
+ binaries: ["phpunit"],
579
+ args: [],
580
+ parser: "generic",
581
+ });
582
+ }
583
+ }
584
+ }
585
+
586
+ function discoverSwift(state: DiscoveryState): void {
587
+ if (!containsExtension(state.files, [".swift"])) return;
588
+ const configured = rootsForBasenames(state.files, { ".swiftlint.yml": true });
589
+ const roots = configured.length > 0 ? configured : rootsForBasenames(state.files, { "Package.swift": true });
590
+ for (const root of roots) {
591
+ addPlan(state, {
592
+ id: "swiftlint",
593
+ label: "SwiftLint",
594
+ language: "Swift",
595
+ root,
596
+ binaries: ["swiftlint"],
597
+ args: ["lint", "--reporter", "json", "--quiet"],
598
+ parser: "swiftlint",
599
+ });
600
+ if (state.includeTests) {
601
+ addPlan(state, {
602
+ id: "swift-test",
603
+ label: "swift test",
604
+ language: "Swift",
605
+ root,
606
+ binaries: ["swift"],
607
+ args: ["test"],
608
+ parser: "generic",
609
+ });
610
+ }
611
+ }
612
+ }
613
+
614
+ function discoverDart(state: DiscoveryState): void {
615
+ if (!containsExtension(state.files, [".dart"])) return;
616
+ for (const root of rootsForBasenames(state.files, { "pubspec.yaml": true })) {
617
+ addPlan(state, {
618
+ id: "dart-analyze",
619
+ label: "dart analyze",
620
+ language: "Dart",
621
+ root,
622
+ binaries: ["dart"],
623
+ args: ["analyze", "--format", "machine"],
624
+ parser: "dart",
625
+ });
626
+ if (state.includeTests) {
627
+ addPlan(state, {
628
+ id: "dart-test",
629
+ label: "dart test",
630
+ language: "Dart",
631
+ root,
632
+ binaries: ["dart"],
633
+ args: ["test", "--reporter", "json"],
634
+ parser: "generic",
635
+ });
636
+ }
637
+ }
638
+ }
639
+
640
+ function discoverElixir(state: DiscoveryState): void {
641
+ if (!containsExtension(state.files, [".ex", ".exs"])) return;
642
+ for (const root of rootsForBasenames(state.files, { ".credo.exs": true })) {
643
+ addPlan(state, {
644
+ id: "credo",
645
+ label: "Credo",
646
+ language: "Elixir",
647
+ root,
648
+ binaries: ["mix"],
649
+ args: ["credo", "--format=json"],
650
+ parser: "credo",
651
+ });
652
+ }
653
+ if (state.includeTests) {
654
+ for (const root of rootsForBasenames(state.files, { "mix.exs": true })) {
655
+ addPlan(state, {
656
+ id: "mix-test",
657
+ label: "mix test",
658
+ language: "Elixir",
659
+ root,
660
+ binaries: ["mix"],
661
+ args: ["test"],
662
+ parser: "generic",
663
+ });
664
+ }
665
+ }
666
+ }
667
+
668
+ function discoverShell(state: DiscoveryState): void {
669
+ const shellFiles = state.files.filter(file => /\.(?:sh|bash|ksh|zsh)$/.test(file));
670
+ if (shellFiles.length === 0) return;
671
+ for (let index = 0; index < shellFiles.length; index += SHELLCHECK_BATCH_SIZE) {
672
+ const batch = shellFiles.slice(index, index + SHELLCHECK_BATCH_SIZE);
673
+ addPlan(state, {
674
+ id: `shellcheck-${Math.floor(index / SHELLCHECK_BATCH_SIZE) + 1}`,
675
+ label: "ShellCheck",
676
+ language: "Shell",
677
+ root: ".",
678
+ binaries: ["shellcheck"],
679
+ args: ["--format=json1", ...batch],
680
+ parser: "shellcheck",
681
+ });
682
+ }
683
+ }
684
+
685
+ function discoverHaskell(state: DiscoveryState): void {
686
+ if (!containsExtension(state.files, [".hs", ".lhs"])) return;
687
+ const roots = rootsForPattern(state.files, /(?:^|\/)(?:\.hlint\.yaml|stack\.yaml|cabal\.project|[^/]+\.cabal)$/);
688
+ for (const root of roots.length > 0 ? roots : ["."]) {
689
+ addPlan(state, {
690
+ id: "hlint",
691
+ label: "HLint",
692
+ language: "Haskell",
693
+ root,
694
+ binaries: ["hlint"],
695
+ args: ["--json", "."],
696
+ parser: "hlint",
697
+ });
698
+ if (state.includeTests) {
699
+ const prefix = root === "." ? "" : `${root}/`;
700
+ const useStack = state.files.includes(`${prefix}stack.yaml`);
701
+ addPlan(state, {
702
+ id: useStack ? "stack-test" : "cabal-test",
703
+ label: useStack ? "stack test" : "cabal test",
704
+ language: "Haskell",
705
+ root,
706
+ binaries: [useStack ? "stack" : "cabal"],
707
+ args: useStack ? ["test"] : ["test", "all"],
708
+ parser: "generic",
709
+ });
710
+ }
711
+ }
712
+ }
713
+
714
+ function discoverTerraform(state: DiscoveryState): void {
715
+ if (!containsExtension(state.files, [".tf"])) return;
716
+ const configured = rootsForBasenames(state.files, { ".tflint.hcl": true });
717
+ const roots = configured.length > 0 ? configured : rootsForPattern(state.files, /\.tf$/);
718
+ for (const root of roots) {
719
+ addPlan(state, {
720
+ id: "tflint",
721
+ label: "TFLint",
722
+ language: "Terraform",
723
+ root,
724
+ binaries: ["tflint"],
725
+ args: ["--format=json", "--recursive"],
726
+ parser: "tflint",
727
+ });
728
+ addPlan(state, {
729
+ id: "terraform-validate",
730
+ label: "terraform validate",
731
+ language: "Terraform",
732
+ root,
733
+ binaries: ["terraform"],
734
+ args: ["validate", "-json"],
735
+ parser: "terraform",
736
+ });
737
+ }
738
+ }
739
+
740
+ function discoverLua(state: DiscoveryState): void {
741
+ if (!containsExtension(state.files, [".lua"])) return;
742
+ const configured = rootsForBasenames(state.files, { ".luacheckrc": true });
743
+ for (const root of configured.length > 0 ? configured : ["."]) {
744
+ addPlan(state, {
745
+ id: "luacheck",
746
+ label: "Luacheck",
747
+ language: "Lua",
748
+ root,
749
+ binaries: ["luacheck"],
750
+ args: ["."],
751
+ parser: "generic",
752
+ });
753
+ }
754
+ if (state.includeTests && state.files.some(file => /(?:^|\/)(?:spec|test)\/.*_spec\.lua$/.test(file))) {
755
+ addPlan(state, {
756
+ id: "busted",
757
+ label: "Busted",
758
+ language: "Lua",
759
+ root: ".",
760
+ binaries: ["busted"],
761
+ args: [],
762
+ parser: "generic",
763
+ });
764
+ }
765
+ }
766
+
767
+ function discoverCpp(state: DiscoveryState): void {
768
+ if (!containsExtension(state.files, [".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"])) return;
769
+ for (const root of rootsForBasenames(state.files, { "compile_commands.json": true })) {
770
+ addPlan(state, {
771
+ id: "clang-tidy",
772
+ label: "clang-tidy",
773
+ language: "C/C++",
774
+ root: ".",
775
+ binaries: ["run-clang-tidy", "run-clang-tidy.py"],
776
+ args: ["-quiet", "-p", path.resolve(state.projectCwd, root)],
777
+ parser: "generic",
778
+ });
779
+ }
780
+ }
781
+
782
+ function discoverDotnet(state: DiscoveryState): void {
783
+ if (!containsExtension(state.files, [".cs", ".fs", ".vb"])) return;
784
+ const solutions = rootsForPattern(state.files, /\.(?:sln|slnx)$/i);
785
+ const roots = solutions.length > 0 ? solutions : rootsForPattern(state.files, /\.(?:csproj|fsproj|vbproj)$/i);
786
+ for (const root of roots) {
787
+ addPlan(state, {
788
+ id: "dotnet-build",
789
+ label: "dotnet build",
790
+ language: ".NET",
791
+ root,
792
+ binaries: ["dotnet"],
793
+ args: ["build", "--no-restore", "--nologo", "--verbosity", "quiet"],
794
+ parser: "generic",
795
+ });
796
+ if (state.includeTests) {
797
+ addPlan(state, {
798
+ id: "dotnet-test",
799
+ label: "dotnet test",
800
+ language: ".NET",
801
+ root,
802
+ binaries: ["dotnet"],
803
+ args: ["test", "--no-restore", "--nologo", "--verbosity", "quiet"],
804
+ parser: "generic",
805
+ });
806
+ }
807
+ }
808
+ }
809
+
810
+ function discoverZig(state: DiscoveryState): void {
811
+ if (!containsExtension(state.files, [".zig"])) return;
812
+ for (const root of rootsForBasenames(state.files, { "build.zig": true })) {
813
+ addPlan(state, {
814
+ id: "zig-build",
815
+ label: "zig build",
816
+ language: "Zig",
817
+ root,
818
+ binaries: ["zig"],
819
+ args: ["build"],
820
+ parser: "generic",
821
+ });
822
+ if (state.includeTests) {
823
+ addPlan(state, {
824
+ id: "zig-test",
825
+ label: "zig build test",
826
+ language: "Zig",
827
+ root,
828
+ binaries: ["zig"],
829
+ args: ["build", "test"],
830
+ parser: "generic",
831
+ });
832
+ }
833
+ }
834
+ }
835
+
836
+ function discoverJvm(state: DiscoveryState): void {
837
+ if (!containsExtension(state.files, [".java", ".kt", ".kts", ".scala"])) return;
838
+ const gradleRoots = rootsForBasenames(state.files, {
839
+ "build.gradle": true,
840
+ "build.gradle.kts": true,
841
+ "settings.gradle": true,
842
+ "settings.gradle.kts": true,
843
+ });
844
+ for (const root of gradleRoots) {
845
+ const unixWrapper = path.posix.join(root, "gradlew");
846
+ const windowsWrapper = path.posix.join(root, "gradlew.bat");
847
+ const wrapper = state.files.includes(unixWrapper)
848
+ ? path.resolve(state.projectCwd, unixWrapper)
849
+ : state.files.includes(windowsWrapper)
850
+ ? path.resolve(state.projectCwd, windowsWrapper)
851
+ : undefined;
852
+ addPlan(state, {
853
+ id: "gradle-check",
854
+ label: "Gradle check",
855
+ language: "JVM",
856
+ root,
857
+ binaries: ["gradle"],
858
+ executable: wrapper,
859
+ args: ["check", "-x", "test", "--console=plain", "--no-daemon"],
860
+ parser: "generic",
861
+ });
862
+ if (state.includeTests) {
863
+ addPlan(state, {
864
+ id: "gradle-test",
865
+ label: "Gradle test",
866
+ language: "JVM",
867
+ root,
868
+ binaries: ["gradle"],
869
+ executable: wrapper,
870
+ args: ["test", "--console=plain", "--no-daemon"],
871
+ parser: "generic",
872
+ });
873
+ }
874
+ }
875
+ for (const root of rootsForBasenames(state.files, { "pom.xml": true })) {
876
+ const unixWrapper = path.posix.join(root, "mvnw");
877
+ const windowsWrapper = path.posix.join(root, "mvnw.cmd");
878
+ const wrapper = state.files.includes(unixWrapper)
879
+ ? path.resolve(state.projectCwd, unixWrapper)
880
+ : state.files.includes(windowsWrapper)
881
+ ? path.resolve(state.projectCwd, windowsWrapper)
882
+ : undefined;
883
+ addPlan(state, {
884
+ id: "maven-verify",
885
+ label: "Maven verify",
886
+ language: "JVM",
887
+ root,
888
+ binaries: ["mvn"],
889
+ executable: wrapper,
890
+ args: ["-q", "-DskipTests", "-DskipITs", "verify"],
891
+ parser: "generic",
892
+ });
893
+ if (state.includeTests) {
894
+ addPlan(state, {
895
+ id: "maven-test",
896
+ label: "Maven test",
897
+ language: "JVM",
898
+ root,
899
+ binaries: ["mvn"],
900
+ executable: wrapper,
901
+ args: ["-q", "test"],
902
+ parser: "generic",
903
+ });
904
+ }
905
+ }
906
+ }
907
+
908
+ async function runChecker(
909
+ plan: CheckerPlan,
910
+ projectCwd: string,
911
+ allowedFiles: ReadonlySet<string>,
912
+ signal?: AbortSignal,
913
+ ): Promise<CleanseCheckResult> {
914
+ try {
915
+ const result = await ptree.exec([plan.executable, ...plan.args], {
916
+ cwd: plan.cwd,
917
+ signal,
918
+ stderr: "full",
919
+ allowNonZero: true,
920
+ allowAbort: false,
921
+ });
922
+ const parsedDiagnostics = parseCleanseDiagnostics(plan.parser, {
923
+ checker: plan.label,
924
+ projectCwd,
925
+ checkerCwd: plan.cwd,
926
+ stdout: result.stdout,
927
+ stderr: result.stderr,
928
+ });
929
+ const diagnostics = parsedDiagnostics.filter(
930
+ diagnostic => diagnostic.file === undefined || allowedFiles.has(diagnostic.file),
931
+ );
932
+ if (!result.ok && parsedDiagnostics.length === 0) {
933
+ diagnostics.push(checkerFailureDiagnostic(plan, result.exitCode, result.stdout, result.stderr));
934
+ }
935
+ return {
936
+ id: plan.id,
937
+ label: plan.label,
938
+ language: plan.language,
939
+ cwd: path.relative(projectCwd, plan.cwd) || ".",
940
+ command: plan.command,
941
+ exitCode: result.exitCode,
942
+ diagnostics,
943
+ };
944
+ } catch (error) {
945
+ if (signal?.aborted) throw error;
946
+ return {
947
+ id: plan.id,
948
+ label: plan.label,
949
+ language: plan.language,
950
+ cwd: path.relative(projectCwd, plan.cwd) || ".",
951
+ command: plan.command,
952
+ exitCode: null,
953
+ diagnostics: [
954
+ checkerFailureDiagnostic(plan, null, "", error instanceof Error ? error.message : String(error)),
955
+ ],
956
+ };
957
+ }
958
+ }
959
+
960
+ function checkerFailureDiagnostic(
961
+ plan: CheckerPlan,
962
+ exitCode: number | null,
963
+ stdout: string,
964
+ stderr: string,
965
+ ): CleanseDiagnostic {
966
+ const output = sanitizeText([stderr, stdout].filter(Boolean).join("\n")).trim();
967
+ const suffix = output ? `: ${output.slice(0, MAX_FAILURE_OUTPUT)}` : "";
968
+ return {
969
+ checker: plan.label,
970
+ severity: "error",
971
+ message: `${plan.label} failed${exitCode === null ? "" : ` with exit code ${exitCode}`}${suffix}`,
972
+ };
973
+ }
974
+
975
+ function deduplicateProjectDiagnostics(diagnostics: CleanseDiagnostic[]): CleanseDiagnostic[] {
976
+ const seen = new Set<string>();
977
+ const unique: CleanseDiagnostic[] = [];
978
+ for (const diagnostic of diagnostics) {
979
+ const key = [
980
+ diagnostic.file ?? "",
981
+ diagnostic.line ?? "",
982
+ diagnostic.column ?? "",
983
+ diagnostic.code ?? "",
984
+ diagnostic.message,
985
+ ].join("\u0000");
986
+ if (seen.has(key)) continue;
987
+ seen.add(key);
988
+ unique.push(diagnostic);
989
+ }
990
+ return unique.sort(
991
+ (left, right) =>
992
+ (left.file ?? "").localeCompare(right.file ?? "") ||
993
+ (left.line ?? 0) - (right.line ?? 0) ||
994
+ left.message.localeCompare(right.message),
995
+ );
996
+ }