agent-gauntlet 0.1.9 → 0.1.11

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 (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -2
  3. package/src/cli-adapters/claude.ts +139 -108
  4. package/src/cli-adapters/codex.ts +141 -117
  5. package/src/cli-adapters/cursor.ts +152 -0
  6. package/src/cli-adapters/gemini.ts +171 -139
  7. package/src/cli-adapters/github-copilot.ts +153 -0
  8. package/src/cli-adapters/index.ts +77 -48
  9. package/src/commands/check.test.ts +24 -20
  10. package/src/commands/check.ts +65 -59
  11. package/src/commands/detect.test.ts +38 -32
  12. package/src/commands/detect.ts +74 -61
  13. package/src/commands/health.test.ts +67 -53
  14. package/src/commands/health.ts +167 -145
  15. package/src/commands/help.test.ts +37 -37
  16. package/src/commands/help.ts +30 -22
  17. package/src/commands/index.ts +9 -9
  18. package/src/commands/init.test.ts +118 -107
  19. package/src/commands/init.ts +515 -417
  20. package/src/commands/list.test.ts +87 -70
  21. package/src/commands/list.ts +28 -24
  22. package/src/commands/rerun.ts +142 -119
  23. package/src/commands/review.test.ts +26 -20
  24. package/src/commands/review.ts +65 -59
  25. package/src/commands/run.test.ts +22 -20
  26. package/src/commands/run.ts +64 -58
  27. package/src/commands/shared.ts +44 -35
  28. package/src/config/loader.test.ts +112 -90
  29. package/src/config/loader.ts +132 -123
  30. package/src/config/schema.ts +49 -47
  31. package/src/config/types.ts +15 -13
  32. package/src/config/validator.ts +521 -454
  33. package/src/core/change-detector.ts +122 -104
  34. package/src/core/entry-point.test.ts +60 -62
  35. package/src/core/entry-point.ts +76 -67
  36. package/src/core/job.ts +69 -59
  37. package/src/core/runner.ts +261 -221
  38. package/src/gates/check.ts +78 -69
  39. package/src/gates/result.ts +7 -6
  40. package/src/gates/review.test.ts +188 -0
  41. package/src/gates/review.ts +717 -506
  42. package/src/index.ts +16 -15
  43. package/src/output/console.ts +253 -198
  44. package/src/output/logger.ts +65 -51
  45. package/src/templates/run_gauntlet.template.md +18 -0
  46. package/src/utils/diff-parser.ts +64 -62
  47. package/src/utils/log-parser.ts +227 -206
  48. package/src/utils/sanitizer.ts +1 -1
package/src/core/job.ts CHANGED
@@ -1,74 +1,84 @@
1
- import { ExpandedEntryPoint } from './entry-point.js';
2
- import { LoadedConfig, CheckGateConfig, ReviewGateConfig, ReviewPromptFrontmatter } from '../config/types.js';
1
+ import type {
2
+ CheckGateConfig,
3
+ LoadedConfig,
4
+ ReviewGateConfig,
5
+ ReviewPromptFrontmatter,
6
+ } from "../config/types.js";
7
+ import type { ExpandedEntryPoint } from "./entry-point.js";
3
8
 
4
- export type JobType = 'check' | 'review';
9
+ export type JobType = "check" | "review";
5
10
 
6
11
  export interface Job {
7
- id: string; // unique id for logging/tracking
8
- type: JobType;
9
- name: string;
10
- entryPoint: string;
11
- gateConfig: CheckGateConfig | (ReviewGateConfig & ReviewPromptFrontmatter);
12
- workingDirectory: string;
12
+ id: string; // unique id for logging/tracking
13
+ type: JobType;
14
+ name: string;
15
+ entryPoint: string;
16
+ gateConfig: CheckGateConfig | (ReviewGateConfig & ReviewPromptFrontmatter);
17
+ workingDirectory: string;
13
18
  }
14
19
 
15
20
  export class JobGenerator {
16
- constructor(private config: LoadedConfig) {}
21
+ constructor(private config: LoadedConfig) {}
17
22
 
18
- generateJobs(expandedEntryPoints: ExpandedEntryPoint[]): Job[] {
19
- const jobs: Job[] = [];
20
- const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
23
+ generateJobs(expandedEntryPoints: ExpandedEntryPoint[]): Job[] {
24
+ const jobs: Job[] = [];
25
+ const isCI =
26
+ process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
21
27
 
22
- for (const ep of expandedEntryPoints) {
23
- // 1. Process Checks
24
- if (ep.config.checks) {
25
- for (const checkName of ep.config.checks) {
26
- const checkConfig = this.config.checks[checkName];
27
- if (!checkConfig) {
28
- console.warn(`Warning: Check gate '${checkName}' configured in entry point '${ep.path}' but not found in checks definitions.`);
29
- continue;
30
- }
28
+ for (const ep of expandedEntryPoints) {
29
+ // 1. Process Checks
30
+ if (ep.config.checks) {
31
+ for (const checkName of ep.config.checks) {
32
+ const checkConfig = this.config.checks[checkName];
33
+ if (!checkConfig) {
34
+ console.warn(
35
+ `Warning: Check gate '${checkName}' configured in entry point '${ep.path}' but not found in checks definitions.`,
36
+ );
37
+ continue;
38
+ }
31
39
 
32
- // Filter based on environment
33
- if (isCI && !checkConfig.run_in_ci) continue;
34
- if (!isCI && !checkConfig.run_locally) continue;
40
+ // Filter based on environment
41
+ if (isCI && !checkConfig.run_in_ci) continue;
42
+ if (!isCI && !checkConfig.run_locally) continue;
35
43
 
36
- jobs.push({
37
- id: `check:${ep.path}:${checkName}`,
38
- type: 'check',
39
- name: checkName,
40
- entryPoint: ep.path,
41
- gateConfig: checkConfig,
42
- workingDirectory: checkConfig.working_directory || ep.path
43
- });
44
- }
45
- }
44
+ jobs.push({
45
+ id: `check:${ep.path}:${checkName}`,
46
+ type: "check",
47
+ name: checkName,
48
+ entryPoint: ep.path,
49
+ gateConfig: checkConfig,
50
+ workingDirectory: checkConfig.working_directory || ep.path,
51
+ });
52
+ }
53
+ }
46
54
 
47
- // 2. Process Reviews
48
- if (ep.config.reviews) {
49
- for (const reviewName of ep.config.reviews) {
50
- const reviewConfig = this.config.reviews[reviewName];
51
- if (!reviewConfig) {
52
- console.warn(`Warning: Review gate '${reviewName}' configured in entry point '${ep.path}' but not found in reviews definitions.`);
53
- continue;
54
- }
55
+ // 2. Process Reviews
56
+ if (ep.config.reviews) {
57
+ for (const reviewName of ep.config.reviews) {
58
+ const reviewConfig = this.config.reviews[reviewName];
59
+ if (!reviewConfig) {
60
+ console.warn(
61
+ `Warning: Review gate '${reviewName}' configured in entry point '${ep.path}' but not found in reviews definitions.`,
62
+ );
63
+ continue;
64
+ }
55
65
 
56
- // Filter based on environment
57
- if (isCI && !reviewConfig.run_in_ci) continue;
58
- if (!isCI && !reviewConfig.run_locally) continue;
66
+ // Filter based on environment
67
+ if (isCI && !reviewConfig.run_in_ci) continue;
68
+ if (!isCI && !reviewConfig.run_locally) continue;
59
69
 
60
- jobs.push({
61
- id: `review:${ep.path}:${reviewName}`,
62
- type: 'review',
63
- name: reviewName,
64
- entryPoint: ep.path,
65
- gateConfig: reviewConfig,
66
- workingDirectory: ep.path // Reviews always run in context of entry point
67
- });
68
- }
69
- }
70
- }
70
+ jobs.push({
71
+ id: `review:${ep.path}:${reviewName}`,
72
+ type: "review",
73
+ name: reviewName,
74
+ entryPoint: ep.path,
75
+ gateConfig: reviewConfig,
76
+ workingDirectory: ep.path, // Reviews always run in context of entry point
77
+ });
78
+ }
79
+ }
80
+ }
71
81
 
72
- return jobs;
73
- }
82
+ return jobs;
83
+ }
74
84
  }
@@ -1,226 +1,266 @@
1
- import { exec } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import fs from 'node:fs/promises';
4
- import { constants as fsConstants } from 'node:fs';
5
- import path from 'node:path';
6
- import { Job } from './job.js';
7
- import { CheckGateExecutor } from '../gates/check.js';
8
- import { ReviewGateExecutor } from '../gates/review.js';
9
- import { Logger } from '../output/logger.js';
10
- import { ConsoleReporter } from '../output/console.js';
11
- import { GateResult } from '../gates/result.js';
12
- import { LoadedConfig, ReviewGateConfig, ReviewPromptFrontmatter } from '../config/types.js';
13
- import { getAdapter } from '../cli-adapters/index.js';
14
- import { PreviousViolation } from '../utils/log-parser.js';
15
- import { sanitizeJobId } from '../utils/sanitizer.js';
1
+ import { exec } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { getAdapter } from "../cli-adapters/index.js";
7
+ import type {
8
+ LoadedConfig,
9
+ ReviewGateConfig,
10
+ ReviewPromptFrontmatter,
11
+ } from "../config/types.js";
12
+ import { CheckGateExecutor } from "../gates/check.js";
13
+ import type { GateResult } from "../gates/result.js";
14
+ import { ReviewGateExecutor } from "../gates/review.js";
15
+ import type { ConsoleReporter } from "../output/console.js";
16
+ import type { Logger } from "../output/logger.js";
17
+ import type { PreviousViolation } from "../utils/log-parser.js";
18
+ import { sanitizeJobId } from "../utils/sanitizer.js";
19
+ import type { Job } from "./job.js";
16
20
 
17
21
  const execAsync = promisify(exec);
18
22
 
19
23
  export class Runner {
20
- private checkExecutor = new CheckGateExecutor();
21
- private reviewExecutor = new ReviewGateExecutor();
22
- private results: GateResult[] = [];
23
- private shouldStop = false;
24
-
25
- constructor(
26
- private config: LoadedConfig,
27
- private logger: Logger,
28
- private reporter: ConsoleReporter,
29
- private previousFailuresMap?: Map<string, Map<string, PreviousViolation[]>>,
30
- private changeOptions?: { commit?: string; uncommitted?: boolean }
31
- ) {}
32
-
33
- async run(jobs: Job[]): Promise<boolean> {
34
- await this.logger.init();
35
-
36
- const { runnableJobs, preflightResults } = await this.preflight(jobs);
37
- this.results.push(...preflightResults);
38
-
39
- const parallelEnabled = this.config.project.allow_parallel;
40
- const parallelJobs = parallelEnabled ? runnableJobs.filter(j => j.gateConfig.parallel) : [];
41
- const sequentialJobs = parallelEnabled ? runnableJobs.filter(j => !j.gateConfig.parallel) : runnableJobs;
42
-
43
- // Start parallel jobs
44
- const parallelPromises = parallelJobs.map(job => this.executeJob(job));
45
-
46
- // Start sequential jobs
47
- // We run them one by one, but concurrently with the parallel batch
48
- const sequentialPromise = (async () => {
49
- for (const job of sequentialJobs) {
50
- if (this.shouldStop) break;
51
- await this.executeJob(job);
52
- }
53
- })();
54
-
55
- await Promise.all([
56
- ...parallelPromises,
57
- sequentialPromise
58
- ]);
59
-
60
- await this.reporter.printSummary(this.results);
61
-
62
- return this.results.every(r => r.status === 'pass');
63
- }
64
-
65
- private async executeJob(job: Job): Promise<void> {
66
- if (this.shouldStop) return;
67
-
68
- this.reporter.onJobStart(job);
69
- const logPath = this.logger.getLogPath(job.id);
70
- const jobLogger = await this.logger.createJobLogger(job.id);
71
-
72
- let result: GateResult;
73
-
74
- if (job.type === 'check') {
75
- result = await this.checkExecutor.execute(
76
- job.id,
77
- job.gateConfig as any,
78
- job.workingDirectory,
79
- jobLogger
80
- );
81
- } else {
82
- // Use sanitized Job ID for lookup because that's what log-parser uses (based on filenames)
83
- const safeJobId = sanitizeJobId(job.id);
84
- const previousFailures = this.previousFailuresMap?.get(safeJobId);
85
- const loggerFactory = this.logger.createLoggerFactory(job.id);
86
- result = await this.reviewExecutor.execute(
87
- job.id,
88
- job.gateConfig as any,
89
- job.entryPoint,
90
- loggerFactory,
91
- this.config.project.base_branch,
92
- previousFailures,
93
- this.changeOptions,
94
- this.config.project.cli.check_usage_limit
95
- );
96
- }
97
-
98
- result.logPath = logPath;
99
- this.results.push(result);
100
- this.reporter.onJobComplete(job, result);
101
-
102
- // Handle Fail Fast (only for checks, and only when parallel is false)
103
- // fail_fast can only be set on checks when parallel is false (enforced by schema)
104
- if (result.status !== 'pass' && job.type === 'check' && job.gateConfig.fail_fast) {
105
- this.shouldStop = true;
106
- }
107
- }
108
-
109
- private async preflight(jobs: Job[]): Promise<{ runnableJobs: Job[]; preflightResults: GateResult[] }> {
110
- const runnableJobs: Job[] = [];
111
- const preflightResults: GateResult[] = [];
112
- const cliCache = new Map<string, boolean>();
113
-
114
- for (const job of jobs) {
115
- if (this.shouldStop) break;
116
- if (job.type === 'check') {
117
- const commandName = this.getCommandName((job.gateConfig as any).command);
118
- if (!commandName) {
119
- preflightResults.push(await this.recordPreflightFailure(job, 'Unable to parse command'));
120
- if (this.shouldFailFast(job)) this.shouldStop = true;
121
- continue;
122
- }
123
-
124
- const available = await this.commandExists(commandName, job.workingDirectory);
125
- if (!available) {
126
- preflightResults.push(await this.recordPreflightFailure(job, `Missing command: ${commandName}`));
127
- if (this.shouldFailFast(job)) this.shouldStop = true;
128
- continue;
129
- }
130
- } else {
131
- const reviewConfig = job.gateConfig as ReviewGateConfig & ReviewPromptFrontmatter;
132
- const required = reviewConfig.num_reviews ?? 1;
133
- const availableTools: string[] = [];
134
-
135
- for (const toolName of reviewConfig.cli_preference || []) {
136
- if (availableTools.length >= required) break;
137
- const cached = cliCache.get(toolName);
138
- const isAvailable = cached ?? await this.checkAdapter(toolName);
139
- cliCache.set(toolName, isAvailable);
140
- if (isAvailable) availableTools.push(toolName);
141
- }
142
-
143
- if (availableTools.length < required) {
144
- preflightResults.push(
145
- await this.recordPreflightFailure(
146
- job,
147
- `Missing CLI tools: need ${required}, found ${availableTools.length}`
148
- )
149
- );
150
- if (this.shouldFailFast(job)) this.shouldStop = true;
151
- continue;
152
- }
153
- }
154
-
155
- runnableJobs.push(job);
156
- }
157
-
158
- return { runnableJobs, preflightResults };
159
- }
160
-
161
- private async recordPreflightFailure(job: Job, message: string): Promise<GateResult> {
162
- const logPath = this.logger.getLogPath(job.id);
163
- const jobLogger = await this.logger.createJobLogger(job.id);
164
- await jobLogger(`[${new Date().toISOString()}] Health check failed\n${message}\n`);
165
- return {
166
- jobId: job.id,
167
- status: 'error',
168
- duration: 0,
169
- message,
170
- logPath
171
- };
172
- }
173
-
174
- private async checkAdapter(name: string): Promise<boolean> {
175
- const adapter = getAdapter(name);
176
- if (!adapter) return false;
177
- const health = await adapter.checkHealth({
178
- checkUsageLimit: this.config.project.cli.check_usage_limit
179
- });
180
- return health.status === 'healthy';
181
- }
182
-
183
- private getCommandName(command: string): string | null {
184
- const tokens = this.tokenize(command);
185
- for (const token of tokens) {
186
- if (token === 'env') continue;
187
- if (this.isEnvAssignment(token)) continue;
188
- return token;
189
- }
190
- return null;
191
- }
192
-
193
- private tokenize(command: string): string[] {
194
- const matches = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
195
- if (!matches) return [];
196
- return matches.map(token => token.replace(/^['"]|['"]$/g, ''));
197
- }
198
-
199
- private isEnvAssignment(token: string): boolean {
200
- return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
201
- }
202
-
203
- private async commandExists(command: string, cwd: string): Promise<boolean> {
204
- if (command.includes('/') || command.startsWith('.')) {
205
- const resolved = path.isAbsolute(command) ? command : path.join(cwd, command);
206
- try {
207
- await fs.access(resolved, fsConstants.X_OK);
208
- return true;
209
- } catch {
210
- return false;
211
- }
212
- }
213
-
214
- try {
215
- await execAsync(`command -v ${command}`);
216
- return true;
217
- } catch {
218
- return false;
219
- }
220
- }
221
-
222
- private shouldFailFast(job: Job): boolean {
223
- // Only checks can have fail_fast, and only when parallel is false
224
- return Boolean(job.type === 'check' && job.gateConfig.fail_fast);
225
- }
24
+ private checkExecutor = new CheckGateExecutor();
25
+ private reviewExecutor = new ReviewGateExecutor();
26
+ private results: GateResult[] = [];
27
+ private shouldStop = false;
28
+
29
+ constructor(
30
+ private config: LoadedConfig,
31
+ private logger: Logger,
32
+ private reporter: ConsoleReporter,
33
+ private previousFailuresMap?: Map<string, Map<string, PreviousViolation[]>>,
34
+ private changeOptions?: { commit?: string; uncommitted?: boolean },
35
+ ) {}
36
+
37
+ async run(jobs: Job[]): Promise<boolean> {
38
+ await this.logger.init();
39
+
40
+ const { runnableJobs, preflightResults } = await this.preflight(jobs);
41
+ this.results.push(...preflightResults);
42
+
43
+ const parallelEnabled = this.config.project.allow_parallel;
44
+ const parallelJobs = parallelEnabled
45
+ ? runnableJobs.filter((j) => j.gateConfig.parallel)
46
+ : [];
47
+ const sequentialJobs = parallelEnabled
48
+ ? runnableJobs.filter((j) => !j.gateConfig.parallel)
49
+ : runnableJobs;
50
+
51
+ // Start parallel jobs
52
+ const parallelPromises = parallelJobs.map((job) => this.executeJob(job));
53
+
54
+ // Start sequential jobs
55
+ // We run them one by one, but concurrently with the parallel batch
56
+ const sequentialPromise = (async () => {
57
+ for (const job of sequentialJobs) {
58
+ if (this.shouldStop) break;
59
+ await this.executeJob(job);
60
+ }
61
+ })();
62
+
63
+ await Promise.all([...parallelPromises, sequentialPromise]);
64
+
65
+ await this.reporter.printSummary(this.results);
66
+
67
+ return this.results.every((r) => r.status === "pass");
68
+ }
69
+
70
+ private async executeJob(job: Job): Promise<void> {
71
+ if (this.shouldStop) return;
72
+
73
+ this.reporter.onJobStart(job);
74
+
75
+ let result: GateResult;
76
+
77
+ if (job.type === "check") {
78
+ const logPath = this.logger.getLogPath(job.id);
79
+ const jobLogger = await this.logger.createJobLogger(job.id);
80
+ result = await this.checkExecutor.execute(
81
+ job.id,
82
+ job.gateConfig as CheckGateConfig,
83
+ job.workingDirectory,
84
+ jobLogger,
85
+ );
86
+ result.logPath = logPath;
87
+ } else {
88
+ // Use sanitized Job ID for lookup because that's what log-parser uses (based on filenames)
89
+ const safeJobId = sanitizeJobId(job.id);
90
+ const previousFailures = this.previousFailuresMap?.get(safeJobId);
91
+ const loggerFactory = this.logger.createLoggerFactory(job.id);
92
+ result = await this.reviewExecutor.execute(
93
+ job.id,
94
+ job.gateConfig as ReviewGateConfig & ReviewPromptFrontmatter,
95
+ job.entryPoint,
96
+ loggerFactory,
97
+ this.config.project.base_branch,
98
+ previousFailures,
99
+ this.changeOptions,
100
+ this.config.project.cli.check_usage_limit,
101
+ );
102
+ }
103
+
104
+ this.results.push(result);
105
+ this.reporter.onJobComplete(job, result);
106
+
107
+ // Handle Fail Fast (only for checks, and only when parallel is false)
108
+ // fail_fast can only be set on checks when parallel is false (enforced by schema)
109
+ if (
110
+ result.status !== "pass" &&
111
+ job.type === "check" &&
112
+ job.gateConfig.fail_fast
113
+ ) {
114
+ this.shouldStop = true;
115
+ }
116
+ }
117
+
118
+ private async preflight(
119
+ jobs: Job[],
120
+ ): Promise<{ runnableJobs: Job[]; preflightResults: GateResult[] }> {
121
+ const runnableJobs: Job[] = [];
122
+ const preflightResults: GateResult[] = [];
123
+ const cliCache = new Map<string, boolean>();
124
+
125
+ for (const job of jobs) {
126
+ if (this.shouldStop) break;
127
+ if (job.type === "check") {
128
+ const commandName = this.getCommandName(
129
+ (job.gateConfig as CheckGateConfig).command,
130
+ );
131
+ if (!commandName) {
132
+ preflightResults.push(
133
+ await this.recordPreflightFailure(job, "Unable to parse command"),
134
+ );
135
+ if (this.shouldFailFast(job)) this.shouldStop = true;
136
+ continue;
137
+ }
138
+
139
+ const available = await this.commandExists(
140
+ commandName,
141
+ job.workingDirectory,
142
+ );
143
+ if (!available) {
144
+ preflightResults.push(
145
+ await this.recordPreflightFailure(
146
+ job,
147
+ `Missing command: ${commandName}`,
148
+ ),
149
+ );
150
+ if (this.shouldFailFast(job)) this.shouldStop = true;
151
+ continue;
152
+ }
153
+ } else {
154
+ const reviewConfig = job.gateConfig as ReviewGateConfig &
155
+ ReviewPromptFrontmatter;
156
+ const required = reviewConfig.num_reviews ?? 1;
157
+ const availableTools: string[] = [];
158
+
159
+ for (const toolName of reviewConfig.cli_preference || []) {
160
+ if (availableTools.length >= required) break;
161
+ const cached = cliCache.get(toolName);
162
+ const isAvailable = cached ?? (await this.checkAdapter(toolName));
163
+ cliCache.set(toolName, isAvailable);
164
+ if (isAvailable) availableTools.push(toolName);
165
+ }
166
+
167
+ if (availableTools.length < required) {
168
+ preflightResults.push(
169
+ await this.recordPreflightFailure(
170
+ job,
171
+ `Missing CLI tools: need ${required}, found ${availableTools.length}`,
172
+ ),
173
+ );
174
+ if (this.shouldFailFast(job)) this.shouldStop = true;
175
+ continue;
176
+ }
177
+ }
178
+
179
+ runnableJobs.push(job);
180
+ }
181
+
182
+ return { runnableJobs, preflightResults };
183
+ }
184
+
185
+ private async recordPreflightFailure(
186
+ job: Job,
187
+ message: string,
188
+ ): Promise<GateResult> {
189
+ if (job.type === "check") {
190
+ const logPath = this.logger.getLogPath(job.id);
191
+ const jobLogger = await this.logger.createJobLogger(job.id);
192
+ await jobLogger(
193
+ `[${new Date().toISOString()}] Health check failed\n${message}\n`,
194
+ );
195
+ return {
196
+ jobId: job.id,
197
+ status: "error",
198
+ duration: 0,
199
+ message,
200
+ logPath,
201
+ };
202
+ }
203
+
204
+ return {
205
+ jobId: job.id,
206
+ status: "error",
207
+ duration: 0,
208
+ message,
209
+ };
210
+ }
211
+
212
+ private async checkAdapter(name: string): Promise<boolean> {
213
+ const adapter = getAdapter(name);
214
+ if (!adapter) return false;
215
+ const health = await adapter.checkHealth({
216
+ checkUsageLimit: this.config.project.cli.check_usage_limit,
217
+ });
218
+ return health.status === "healthy";
219
+ }
220
+
221
+ private getCommandName(command: string): string | null {
222
+ const tokens = this.tokenize(command);
223
+ for (const token of tokens) {
224
+ if (token === "env") continue;
225
+ if (this.isEnvAssignment(token)) continue;
226
+ return token;
227
+ }
228
+ return null;
229
+ }
230
+
231
+ private tokenize(command: string): string[] {
232
+ const matches = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g);
233
+ if (!matches) return [];
234
+ return matches.map((token) => token.replace(/^['"]|['"]$/g, ""));
235
+ }
236
+
237
+ private isEnvAssignment(token: string): boolean {
238
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
239
+ }
240
+
241
+ private async commandExists(command: string, cwd: string): Promise<boolean> {
242
+ if (command.includes("/") || command.startsWith(".")) {
243
+ const resolved = path.isAbsolute(command)
244
+ ? command
245
+ : path.join(cwd, command);
246
+ try {
247
+ await fs.access(resolved, fsConstants.X_OK);
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ try {
255
+ await execAsync(`command -v ${command}`);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ private shouldFailFast(job: Job): boolean {
263
+ // Only checks can have fail_fast, and only when parallel is false
264
+ return Boolean(job.type === "check" && job.gateConfig.fail_fast);
265
+ }
226
266
  }