agent-gauntlet 0.1.4
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.
- package/LICENSE +201 -0
- package/README.md +106 -0
- package/package.json +51 -0
- package/src/cli-adapters/claude.ts +114 -0
- package/src/cli-adapters/codex.ts +123 -0
- package/src/cli-adapters/gemini.ts +149 -0
- package/src/cli-adapters/index.ts +79 -0
- package/src/commands/check.test.ts +25 -0
- package/src/commands/check.ts +67 -0
- package/src/commands/detect.test.ts +37 -0
- package/src/commands/detect.ts +69 -0
- package/src/commands/health.test.ts +79 -0
- package/src/commands/health.ts +148 -0
- package/src/commands/help.test.ts +44 -0
- package/src/commands/help.ts +24 -0
- package/src/commands/index.ts +9 -0
- package/src/commands/init.test.ts +105 -0
- package/src/commands/init.ts +330 -0
- package/src/commands/list.test.ts +104 -0
- package/src/commands/list.ts +29 -0
- package/src/commands/rerun.ts +118 -0
- package/src/commands/review.test.ts +25 -0
- package/src/commands/review.ts +67 -0
- package/src/commands/run.test.ts +25 -0
- package/src/commands/run.ts +64 -0
- package/src/commands/shared.ts +10 -0
- package/src/config/loader.test.ts +129 -0
- package/src/config/loader.ts +130 -0
- package/src/config/schema.ts +63 -0
- package/src/config/types.ts +23 -0
- package/src/config/validator.ts +493 -0
- package/src/core/change-detector.ts +112 -0
- package/src/core/entry-point.test.ts +63 -0
- package/src/core/entry-point.ts +80 -0
- package/src/core/job.ts +74 -0
- package/src/core/runner.ts +226 -0
- package/src/gates/check.ts +82 -0
- package/src/gates/result.ts +9 -0
- package/src/gates/review.ts +501 -0
- package/src/index.ts +38 -0
- package/src/output/console.ts +201 -0
- package/src/output/logger.ts +66 -0
- package/src/utils/log-parser.ts +228 -0
- package/src/utils/sanitizer.ts +3 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadConfig } from '../config/loader.js';
|
|
4
|
+
import { ChangeDetector } from '../core/change-detector.js';
|
|
5
|
+
import { EntryPointExpander } from '../core/entry-point.js';
|
|
6
|
+
import { JobGenerator } from '../core/job.js';
|
|
7
|
+
import { Runner } from '../core/runner.js';
|
|
8
|
+
import { Logger } from '../output/logger.js';
|
|
9
|
+
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
import { findPreviousFailures, GateFailures, PreviousViolation } from '../utils/log-parser.js';
|
|
11
|
+
|
|
12
|
+
export function registerRerunCommand(program: Command): void {
|
|
13
|
+
program
|
|
14
|
+
.command('rerun')
|
|
15
|
+
.description('Rerun gates (checks & reviews) with previous failures as context (defaults to uncommitted changes)')
|
|
16
|
+
.option('-g, --gate <name>', 'Run specific gate only')
|
|
17
|
+
.option('-c, --commit <sha>', 'Use diff for a specific commit (overrides default uncommitted mode)')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
try {
|
|
20
|
+
const config = await loadConfig();
|
|
21
|
+
|
|
22
|
+
// Parse previous failures from log files (only for review gates)
|
|
23
|
+
console.log(chalk.dim('Analyzing previous runs...'));
|
|
24
|
+
|
|
25
|
+
// findPreviousFailures handles errors internally and returns empty array on failure
|
|
26
|
+
const previousFailures = await findPreviousFailures(
|
|
27
|
+
config.project.log_dir,
|
|
28
|
+
options.gate
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Create a map: jobId -> (adapterName -> violations)
|
|
32
|
+
const failuresMap = new Map<string, Map<string, PreviousViolation[]>>();
|
|
33
|
+
for (const gateFailure of previousFailures) {
|
|
34
|
+
const adapterMap = new Map<string, PreviousViolation[]>();
|
|
35
|
+
for (const adapterFailure of gateFailure.adapterFailures) {
|
|
36
|
+
adapterMap.set(adapterFailure.adapterName, adapterFailure.violations);
|
|
37
|
+
}
|
|
38
|
+
failuresMap.set(gateFailure.jobId, adapterMap);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (previousFailures.length > 0) {
|
|
42
|
+
const totalViolations = previousFailures.reduce(
|
|
43
|
+
(sum, gf) => sum + gf.adapterFailures.reduce(
|
|
44
|
+
(s, af) => s + af.violations.length, 0
|
|
45
|
+
), 0
|
|
46
|
+
);
|
|
47
|
+
console.log(chalk.yellow(
|
|
48
|
+
`Found ${previousFailures.length} gate(s) with ${totalViolations} previous violation(s)`
|
|
49
|
+
));
|
|
50
|
+
} else {
|
|
51
|
+
console.log(chalk.dim('No previous failures found. Running as normal...'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Detect changes (default to uncommitted unless --commit is specified)
|
|
55
|
+
// Note: Rerun defaults to uncommitted changes for faster iteration loops,
|
|
56
|
+
// unlike 'run' which defaults to base_branch comparison.
|
|
57
|
+
const changeOptions = {
|
|
58
|
+
commit: options.commit,
|
|
59
|
+
uncommitted: !options.commit // Default to uncommitted unless commit is specified
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const changeDetector = new ChangeDetector(
|
|
63
|
+
config.project.base_branch,
|
|
64
|
+
changeOptions
|
|
65
|
+
);
|
|
66
|
+
const expander = new EntryPointExpander();
|
|
67
|
+
const jobGen = new JobGenerator(config);
|
|
68
|
+
|
|
69
|
+
const modeDesc = options.commit
|
|
70
|
+
? `commit ${options.commit}`
|
|
71
|
+
: 'uncommitted changes';
|
|
72
|
+
console.log(chalk.dim(`Detecting changes (${modeDesc})...`));
|
|
73
|
+
|
|
74
|
+
const changes = await changeDetector.getChangedFiles();
|
|
75
|
+
|
|
76
|
+
if (changes.length === 0) {
|
|
77
|
+
console.log(chalk.green('No changes detected.'));
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(chalk.dim(`Found ${changes.length} changed files.`));
|
|
82
|
+
|
|
83
|
+
const entryPoints = await expander.expand(config.project.entry_points, changes);
|
|
84
|
+
let jobs = jobGen.generateJobs(entryPoints);
|
|
85
|
+
|
|
86
|
+
if (options.gate) {
|
|
87
|
+
jobs = jobs.filter(j => j.name === options.gate);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (jobs.length === 0) {
|
|
91
|
+
console.log(chalk.yellow('No applicable gates for these changes.'));
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(chalk.dim(`Running ${jobs.length} gates...`));
|
|
96
|
+
if (previousFailures.length > 0) {
|
|
97
|
+
console.log(chalk.dim('Previous failures will be injected as context for matching reviewers.'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const logger = new Logger(config.project.log_dir);
|
|
101
|
+
const reporter = new ConsoleReporter();
|
|
102
|
+
const runner = new Runner(
|
|
103
|
+
config,
|
|
104
|
+
logger,
|
|
105
|
+
reporter,
|
|
106
|
+
failuresMap, // Pass previous failures map
|
|
107
|
+
changeOptions // Pass change detection options
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const success = await runner.run(jobs);
|
|
111
|
+
process.exit(success ? 0 : 1);
|
|
112
|
+
|
|
113
|
+
} catch (error: any) {
|
|
114
|
+
console.error(chalk.red('Error:'), error.message);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerReviewCommand } from './review.js';
|
|
4
|
+
|
|
5
|
+
describe('Review Command', () => {
|
|
6
|
+
let program: Command;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
program = new Command();
|
|
10
|
+
registerReviewCommand(program);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should register the review command', () => {
|
|
14
|
+
const reviewCmd = program.commands.find(cmd => cmd.name() === 'review');
|
|
15
|
+
expect(reviewCmd).toBeDefined();
|
|
16
|
+
expect(reviewCmd?.description()).toBe('Run only applicable reviews for detected changes');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should have correct options', () => {
|
|
20
|
+
const reviewCmd = program.commands.find(cmd => cmd.name() === 'review');
|
|
21
|
+
expect(reviewCmd?.options.some(opt => opt.long === '--gate')).toBe(true);
|
|
22
|
+
expect(reviewCmd?.options.some(opt => opt.long === '--commit')).toBe(true);
|
|
23
|
+
expect(reviewCmd?.options.some(opt => opt.long === '--uncommitted')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadConfig } from '../config/loader.js';
|
|
4
|
+
import { ChangeDetector } from '../core/change-detector.js';
|
|
5
|
+
import { EntryPointExpander } from '../core/entry-point.js';
|
|
6
|
+
import { JobGenerator } from '../core/job.js';
|
|
7
|
+
import { Runner } from '../core/runner.js';
|
|
8
|
+
import { Logger } from '../output/logger.js';
|
|
9
|
+
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
|
|
11
|
+
export function registerReviewCommand(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command('review')
|
|
14
|
+
.description('Run only applicable reviews for detected changes')
|
|
15
|
+
.option('-g, --gate <name>', 'Run specific review gate only')
|
|
16
|
+
.option('-c, --commit <sha>', 'Use diff for a specific commit')
|
|
17
|
+
.option('-u, --uncommitted', 'Use diff for current uncommitted changes (staged and unstaged)')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
try {
|
|
20
|
+
const config = await loadConfig();
|
|
21
|
+
const changeDetector = new ChangeDetector(config.project.base_branch, {
|
|
22
|
+
commit: options.commit,
|
|
23
|
+
uncommitted: options.uncommitted
|
|
24
|
+
});
|
|
25
|
+
const expander = new EntryPointExpander();
|
|
26
|
+
const jobGen = new JobGenerator(config);
|
|
27
|
+
|
|
28
|
+
console.log(chalk.dim('Detecting changes...'));
|
|
29
|
+
const changes = await changeDetector.getChangedFiles();
|
|
30
|
+
|
|
31
|
+
if (changes.length === 0) {
|
|
32
|
+
console.log(chalk.green('No changes detected.'));
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(chalk.dim(`Found ${changes.length} changed files.`));
|
|
37
|
+
|
|
38
|
+
const entryPoints = await expander.expand(config.project.entry_points, changes);
|
|
39
|
+
let jobs = jobGen.generateJobs(entryPoints);
|
|
40
|
+
|
|
41
|
+
// Filter to only reviews
|
|
42
|
+
jobs = jobs.filter(j => j.type === 'review');
|
|
43
|
+
|
|
44
|
+
if (options.gate) {
|
|
45
|
+
jobs = jobs.filter(j => j.name === options.gate);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (jobs.length === 0) {
|
|
49
|
+
console.log(chalk.yellow('No applicable reviews for these changes.'));
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(chalk.dim(`Running ${jobs.length} review(s)...`));
|
|
54
|
+
|
|
55
|
+
const logger = new Logger(config.project.log_dir);
|
|
56
|
+
const reporter = new ConsoleReporter();
|
|
57
|
+
const runner = new Runner(config, logger, reporter);
|
|
58
|
+
|
|
59
|
+
const success = await runner.run(jobs);
|
|
60
|
+
process.exit(success ? 0 : 1);
|
|
61
|
+
|
|
62
|
+
} catch (error: any) {
|
|
63
|
+
console.error(chalk.red('Error:'), error.message);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerRunCommand } from './run.js';
|
|
4
|
+
|
|
5
|
+
describe('Run Command', () => {
|
|
6
|
+
let program: Command;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
program = new Command();
|
|
10
|
+
registerRunCommand(program);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should register the run command', () => {
|
|
14
|
+
const runCmd = program.commands.find(cmd => cmd.name() === 'run');
|
|
15
|
+
expect(runCmd).toBeDefined();
|
|
16
|
+
expect(runCmd?.description()).toBe('Run gates for detected changes');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should have correct options', () => {
|
|
20
|
+
const runCmd = program.commands.find(cmd => cmd.name() === 'run');
|
|
21
|
+
expect(runCmd?.options.some(opt => opt.long === '--gate')).toBe(true);
|
|
22
|
+
expect(runCmd?.options.some(opt => opt.long === '--commit')).toBe(true);
|
|
23
|
+
expect(runCmd?.options.some(opt => opt.long === '--uncommitted')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadConfig } from '../config/loader.js';
|
|
4
|
+
import { ChangeDetector } from '../core/change-detector.js';
|
|
5
|
+
import { EntryPointExpander } from '../core/entry-point.js';
|
|
6
|
+
import { JobGenerator } from '../core/job.js';
|
|
7
|
+
import { Runner } from '../core/runner.js';
|
|
8
|
+
import { Logger } from '../output/logger.js';
|
|
9
|
+
import { ConsoleReporter } from '../output/console.js';
|
|
10
|
+
|
|
11
|
+
export function registerRunCommand(program: Command): void {
|
|
12
|
+
program
|
|
13
|
+
.command('run')
|
|
14
|
+
.description('Run gates for detected changes')
|
|
15
|
+
.option('-g, --gate <name>', 'Run specific gate only')
|
|
16
|
+
.option('-c, --commit <sha>', 'Use diff for a specific commit')
|
|
17
|
+
.option('-u, --uncommitted', 'Use diff for current uncommitted changes (staged and unstaged)')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
try {
|
|
20
|
+
const config = await loadConfig();
|
|
21
|
+
const changeDetector = new ChangeDetector(config.project.base_branch, {
|
|
22
|
+
commit: options.commit,
|
|
23
|
+
uncommitted: options.uncommitted
|
|
24
|
+
});
|
|
25
|
+
const expander = new EntryPointExpander();
|
|
26
|
+
const jobGen = new JobGenerator(config);
|
|
27
|
+
|
|
28
|
+
console.log(chalk.dim('Detecting changes...'));
|
|
29
|
+
const changes = await changeDetector.getChangedFiles();
|
|
30
|
+
|
|
31
|
+
if (changes.length === 0) {
|
|
32
|
+
console.log(chalk.green('No changes detected.'));
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(chalk.dim(`Found ${changes.length} changed files.`));
|
|
37
|
+
|
|
38
|
+
const entryPoints = await expander.expand(config.project.entry_points, changes);
|
|
39
|
+
let jobs = jobGen.generateJobs(entryPoints);
|
|
40
|
+
|
|
41
|
+
if (options.gate) {
|
|
42
|
+
jobs = jobs.filter(j => j.name === options.gate);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (jobs.length === 0) {
|
|
46
|
+
console.log(chalk.yellow('No applicable gates for these changes.'));
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(chalk.dim(`Running ${jobs.length} gates...`));
|
|
51
|
+
|
|
52
|
+
const logger = new Logger(config.project.log_dir);
|
|
53
|
+
const reporter = new ConsoleReporter();
|
|
54
|
+
const runner = new Runner(config, logger, reporter);
|
|
55
|
+
|
|
56
|
+
const success = await runner.run(jobs);
|
|
57
|
+
process.exit(success ? 0 : 1);
|
|
58
|
+
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
console.error(chalk.red('Error:'), error.message);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadConfig } from './loader.js';
|
|
5
|
+
|
|
6
|
+
const TEST_DIR = path.join(process.cwd(), 'test-env-' + Date.now());
|
|
7
|
+
const GAUNTLET_DIR = path.join(TEST_DIR, '.gauntlet');
|
|
8
|
+
const CHECKS_DIR = path.join(GAUNTLET_DIR, 'checks');
|
|
9
|
+
const REVIEWS_DIR = path.join(GAUNTLET_DIR, 'reviews');
|
|
10
|
+
|
|
11
|
+
describe('Config Loader', () => {
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
// Setup directory structure
|
|
14
|
+
await fs.mkdir(TEST_DIR);
|
|
15
|
+
await fs.mkdir(GAUNTLET_DIR);
|
|
16
|
+
await fs.mkdir(CHECKS_DIR);
|
|
17
|
+
await fs.mkdir(REVIEWS_DIR);
|
|
18
|
+
|
|
19
|
+
// Write config.yml
|
|
20
|
+
await fs.writeFile(path.join(GAUNTLET_DIR, 'config.yml'), `
|
|
21
|
+
base_branch: origin/dev
|
|
22
|
+
log_dir: test_logs
|
|
23
|
+
cli:
|
|
24
|
+
default_preference:
|
|
25
|
+
- claude
|
|
26
|
+
- gemini
|
|
27
|
+
check_usage_limit: false
|
|
28
|
+
entry_points:
|
|
29
|
+
- path: src/
|
|
30
|
+
checks:
|
|
31
|
+
- lint
|
|
32
|
+
reviews:
|
|
33
|
+
- security
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
// Write a check definition
|
|
37
|
+
await fs.writeFile(path.join(CHECKS_DIR, 'lint.yml'), `
|
|
38
|
+
name: lint
|
|
39
|
+
command: npm run lint
|
|
40
|
+
working_directory: .
|
|
41
|
+
`);
|
|
42
|
+
|
|
43
|
+
// Write a review definition
|
|
44
|
+
await fs.writeFile(path.join(REVIEWS_DIR, 'security.md'), `---
|
|
45
|
+
cli_preference:
|
|
46
|
+
- gemini
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
# Security Review
|
|
50
|
+
Check for vulnerabilities.
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
// Write a review definition without preference
|
|
54
|
+
await fs.writeFile(path.join(REVIEWS_DIR, 'style.md'), `---
|
|
55
|
+
num_reviews: 1
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
# Style Review
|
|
59
|
+
Check style.
|
|
60
|
+
`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
// Cleanup
|
|
65
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should load project configuration correctly', async () => {
|
|
69
|
+
const config = await loadConfig(TEST_DIR);
|
|
70
|
+
|
|
71
|
+
expect(config.project.base_branch).toBe('origin/dev');
|
|
72
|
+
expect(config.project.log_dir).toBe('test_logs');
|
|
73
|
+
expect(config.project.entry_points).toHaveLength(1);
|
|
74
|
+
expect(config.project.entry_points[0].path).toBe('src/');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should load check gates correctly', async () => {
|
|
78
|
+
const config = await loadConfig(TEST_DIR);
|
|
79
|
+
|
|
80
|
+
expect(Object.keys(config.checks)).toContain('lint');
|
|
81
|
+
expect(config.checks['lint'].command).toBe('npm run lint');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should load review gates correctly', async () => {
|
|
85
|
+
const config = await loadConfig(TEST_DIR);
|
|
86
|
+
|
|
87
|
+
expect(Object.keys(config.reviews)).toContain('security');
|
|
88
|
+
expect(config.reviews['security'].name).toBe('security');
|
|
89
|
+
expect(config.reviews['security'].cli_preference).toEqual(['gemini']);
|
|
90
|
+
expect(config.reviews['security'].promptContent).toContain('Check for vulnerabilities.');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should merge default cli preference', async () => {
|
|
94
|
+
const config = await loadConfig(TEST_DIR);
|
|
95
|
+
|
|
96
|
+
expect(Object.keys(config.reviews)).toContain('style');
|
|
97
|
+
expect(config.reviews['style'].cli_preference).toEqual(['claude', 'gemini']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should reject check gate with fail_fast when parallel is true', async () => {
|
|
101
|
+
await fs.writeFile(path.join(CHECKS_DIR, 'invalid.yml'), `
|
|
102
|
+
name: invalid
|
|
103
|
+
command: echo test
|
|
104
|
+
parallel: true
|
|
105
|
+
fail_fast: true
|
|
106
|
+
`);
|
|
107
|
+
|
|
108
|
+
await expect(loadConfig(TEST_DIR)).rejects.toThrow(/fail_fast can only be used when parallel is false/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should accept check gate with fail_fast when parallel is false', async () => {
|
|
112
|
+
// Clean up the invalid file first
|
|
113
|
+
try {
|
|
114
|
+
await fs.unlink(path.join(CHECKS_DIR, 'invalid.yml'));
|
|
115
|
+
} catch {}
|
|
116
|
+
|
|
117
|
+
await fs.writeFile(path.join(CHECKS_DIR, 'valid.yml'), `
|
|
118
|
+
name: valid
|
|
119
|
+
command: echo test
|
|
120
|
+
parallel: false
|
|
121
|
+
fail_fast: true
|
|
122
|
+
`);
|
|
123
|
+
|
|
124
|
+
const config = await loadConfig(TEST_DIR);
|
|
125
|
+
expect(config.checks['valid']).toBeDefined();
|
|
126
|
+
expect(config.checks['valid'].fail_fast).toBe(true);
|
|
127
|
+
expect(config.checks['valid'].parallel).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import {
|
|
6
|
+
gauntletConfigSchema,
|
|
7
|
+
checkGateSchema,
|
|
8
|
+
reviewPromptFrontmatterSchema
|
|
9
|
+
} from './schema.js';
|
|
10
|
+
import { LoadedConfig, CheckGateConfig } from './types.js';
|
|
11
|
+
|
|
12
|
+
const GAUNTLET_DIR = '.gauntlet';
|
|
13
|
+
const CONFIG_FILE = 'config.yml';
|
|
14
|
+
const CHECKS_DIR = 'checks';
|
|
15
|
+
const REVIEWS_DIR = 'reviews';
|
|
16
|
+
|
|
17
|
+
export async function loadConfig(rootDir: string = process.cwd()): Promise<LoadedConfig> {
|
|
18
|
+
const gauntletPath = path.join(rootDir, GAUNTLET_DIR);
|
|
19
|
+
const configPath = path.join(gauntletPath, CONFIG_FILE);
|
|
20
|
+
|
|
21
|
+
// 1. Load project config
|
|
22
|
+
if (!(await fileExists(configPath))) {
|
|
23
|
+
throw new Error(`Configuration file not found at ${configPath}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const configContent = await fs.readFile(configPath, 'utf-8');
|
|
27
|
+
const projectConfigRaw = YAML.parse(configContent);
|
|
28
|
+
const projectConfig = gauntletConfigSchema.parse(projectConfigRaw);
|
|
29
|
+
|
|
30
|
+
// 2. Load checks
|
|
31
|
+
const checksPath = path.join(gauntletPath, CHECKS_DIR);
|
|
32
|
+
const checks: Record<string, CheckGateConfig> = {};
|
|
33
|
+
|
|
34
|
+
if (await dirExists(checksPath)) {
|
|
35
|
+
const checkFiles = await fs.readdir(checksPath);
|
|
36
|
+
for (const file of checkFiles) {
|
|
37
|
+
if (file.endsWith('.yml') || file.endsWith('.yaml')) {
|
|
38
|
+
const filePath = path.join(checksPath, file);
|
|
39
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
40
|
+
const raw = YAML.parse(content);
|
|
41
|
+
// Ensure name matches filename if not provided or just use filename as key
|
|
42
|
+
const parsed = checkGateSchema.parse(raw);
|
|
43
|
+
checks[parsed.name] = parsed;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Load reviews (prompts + frontmatter)
|
|
49
|
+
const reviewsPath = path.join(gauntletPath, REVIEWS_DIR);
|
|
50
|
+
const reviews: Record<string, any> = {};
|
|
51
|
+
|
|
52
|
+
if (await dirExists(reviewsPath)) {
|
|
53
|
+
const reviewFiles = await fs.readdir(reviewsPath);
|
|
54
|
+
for (const file of reviewFiles) {
|
|
55
|
+
if (file.endsWith('.md')) {
|
|
56
|
+
const filePath = path.join(reviewsPath, file);
|
|
57
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
58
|
+
const { data: frontmatter, content: promptBody } = matter(content);
|
|
59
|
+
|
|
60
|
+
const parsedFrontmatter = reviewPromptFrontmatterSchema.parse(frontmatter);
|
|
61
|
+
const name = path.basename(file, '.md');
|
|
62
|
+
|
|
63
|
+
reviews[name] = {
|
|
64
|
+
name,
|
|
65
|
+
prompt: file, // Store filename relative to reviews dir
|
|
66
|
+
promptContent: promptBody, // Store the actual prompt content for easy access
|
|
67
|
+
...parsedFrontmatter
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Merge default CLI preference if not specified
|
|
71
|
+
if (!reviews[name].cli_preference) {
|
|
72
|
+
reviews[name].cli_preference = projectConfig.cli.default_preference;
|
|
73
|
+
} else {
|
|
74
|
+
// Validate that specified preferences are allowed by project config
|
|
75
|
+
const allowedTools = new Set(projectConfig.cli.default_preference);
|
|
76
|
+
for (const tool of reviews[name].cli_preference) {
|
|
77
|
+
if (!allowedTools.has(tool)) {
|
|
78
|
+
throw new Error(`Review "${name}" uses CLI tool "${tool}" which is not in the project-level allowed list (cli.default_preference).`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Validate entry point references
|
|
87
|
+
const checkNames = new Set(Object.keys(checks));
|
|
88
|
+
const reviewNames = new Set(Object.keys(reviews));
|
|
89
|
+
|
|
90
|
+
for (const entryPoint of projectConfig.entry_points) {
|
|
91
|
+
if (entryPoint.checks) {
|
|
92
|
+
for (const checkName of entryPoint.checks) {
|
|
93
|
+
if (!checkNames.has(checkName)) {
|
|
94
|
+
throw new Error(`Entry point "${entryPoint.path}" references non-existent check gate: "${checkName}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (entryPoint.reviews) {
|
|
99
|
+
for (const reviewName of entryPoint.reviews) {
|
|
100
|
+
if (!reviewNames.has(reviewName)) {
|
|
101
|
+
throw new Error(`Entry point "${entryPoint.path}" references non-existent review gate: "${reviewName}"`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
project: projectConfig,
|
|
109
|
+
checks,
|
|
110
|
+
reviews,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
115
|
+
try {
|
|
116
|
+
const stat = await fs.stat(path);
|
|
117
|
+
return stat.isFile();
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function dirExists(path: string): Promise<boolean> {
|
|
124
|
+
try {
|
|
125
|
+
const stat = await fs.stat(path);
|
|
126
|
+
return stat.isDirectory();
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const cliConfigSchema = z.object({
|
|
4
|
+
default_preference: z.array(z.string().min(1)).min(1),
|
|
5
|
+
check_usage_limit: z.boolean().default(false),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const checkGateSchema = z.object({
|
|
9
|
+
name: z.string().min(1),
|
|
10
|
+
command: z.string().min(1),
|
|
11
|
+
working_directory: z.string().optional(),
|
|
12
|
+
parallel: z.boolean().default(false),
|
|
13
|
+
run_in_ci: z.boolean().default(true),
|
|
14
|
+
run_locally: z.boolean().default(true),
|
|
15
|
+
timeout: z.number().optional(),
|
|
16
|
+
fail_fast: z.boolean().optional(),
|
|
17
|
+
}).refine(
|
|
18
|
+
(data) => {
|
|
19
|
+
// fail_fast can only be used when parallel is false
|
|
20
|
+
if (data.fail_fast === true && data.parallel === true) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
message: "fail_fast can only be used when parallel is false",
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export const reviewGateSchema = z.object({
|
|
31
|
+
name: z.string().min(1),
|
|
32
|
+
prompt: z.string().min(1), // Path relative to .gauntlet/reviews/
|
|
33
|
+
cli_preference: z.array(z.string().min(1)).optional(),
|
|
34
|
+
num_reviews: z.number().default(1),
|
|
35
|
+
parallel: z.boolean().default(true),
|
|
36
|
+
run_in_ci: z.boolean().default(true),
|
|
37
|
+
run_locally: z.boolean().default(true),
|
|
38
|
+
timeout: z.number().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const reviewPromptFrontmatterSchema = z.object({
|
|
42
|
+
model: z.string().optional(),
|
|
43
|
+
cli_preference: z.array(z.string().min(1)).optional(),
|
|
44
|
+
num_reviews: z.number().default(1),
|
|
45
|
+
parallel: z.boolean().default(true),
|
|
46
|
+
run_in_ci: z.boolean().default(true),
|
|
47
|
+
run_locally: z.boolean().default(true),
|
|
48
|
+
timeout: z.number().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const entryPointSchema = z.object({
|
|
52
|
+
path: z.string().min(1),
|
|
53
|
+
checks: z.array(z.string().min(1)).optional(),
|
|
54
|
+
reviews: z.array(z.string().min(1)).optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const gauntletConfigSchema = z.object({
|
|
58
|
+
base_branch: z.string().min(1).default('origin/main'),
|
|
59
|
+
log_dir: z.string().min(1).default('.gauntlet_logs'),
|
|
60
|
+
allow_parallel: z.boolean().default(true),
|
|
61
|
+
cli: cliConfigSchema,
|
|
62
|
+
entry_points: z.array(entryPointSchema).min(1),
|
|
63
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import {
|
|
3
|
+
checkGateSchema,
|
|
4
|
+
reviewGateSchema,
|
|
5
|
+
reviewPromptFrontmatterSchema,
|
|
6
|
+
entryPointSchema,
|
|
7
|
+
gauntletConfigSchema,
|
|
8
|
+
cliConfigSchema
|
|
9
|
+
} from './schema.js';
|
|
10
|
+
|
|
11
|
+
export type CheckGateConfig = z.infer<typeof checkGateSchema>;
|
|
12
|
+
export type ReviewGateConfig = z.infer<typeof reviewGateSchema>;
|
|
13
|
+
export type ReviewPromptFrontmatter = z.infer<typeof reviewPromptFrontmatterSchema>;
|
|
14
|
+
export type EntryPointConfig = z.infer<typeof entryPointSchema>;
|
|
15
|
+
export type GauntletConfig = z.infer<typeof gauntletConfigSchema>;
|
|
16
|
+
export type CLIConfig = z.infer<typeof cliConfigSchema>;
|
|
17
|
+
|
|
18
|
+
// Combined type for the fully loaded configuration
|
|
19
|
+
export interface LoadedConfig {
|
|
20
|
+
project: GauntletConfig;
|
|
21
|
+
checks: Record<string, CheckGateConfig>;
|
|
22
|
+
reviews: Record<string, ReviewGateConfig & ReviewPromptFrontmatter>; // Merged with frontmatter
|
|
23
|
+
}
|