agent-gauntlet 0.1.10 → 0.1.12

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