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
@@ -1,493 +1,560 @@
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 { ZodError } from 'zod';
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import matter from "gray-matter";
4
+ import YAML from "yaml";
5
+ import { ZodError } from "zod";
6
+ import { getValidCLITools } from "../cli-adapters/index.js";
6
7
  import {
7
- gauntletConfigSchema,
8
- checkGateSchema,
9
- reviewPromptFrontmatterSchema,
10
- entryPointSchema,
11
- } from './schema.js';
8
+ checkGateSchema,
9
+ entryPointSchema,
10
+ gauntletConfigSchema,
11
+ reviewPromptFrontmatterSchema,
12
+ } from "./schema.js";
13
+ import type {
14
+ CheckGateConfig,
15
+ GauntletConfig,
16
+ ReviewPromptFrontmatter,
17
+ } from "./types.js";
12
18
 
13
- // Valid CLI tool names (must match cli-adapters/index.ts)
14
- const VALID_CLI_TOOLS = ['gemini', 'codex', 'claude'];
15
-
16
- const GAUNTLET_DIR = '.gauntlet';
17
- const CONFIG_FILE = 'config.yml';
18
- const CHECKS_DIR = 'checks';
19
- const REVIEWS_DIR = 'reviews';
19
+ const GAUNTLET_DIR = ".gauntlet";
20
+ const CONFIG_FILE = "config.yml";
21
+ const CHECKS_DIR = "checks";
22
+ const REVIEWS_DIR = "reviews";
20
23
 
21
24
  export interface ValidationIssue {
22
- file: string;
23
- severity: 'error' | 'warning';
24
- message: string;
25
- field?: string;
25
+ file: string;
26
+ severity: "error" | "warning";
27
+ message: string;
28
+ field?: string;
26
29
  }
27
30
 
28
31
  export interface ValidationResult {
29
- valid: boolean;
30
- issues: ValidationIssue[];
31
- filesChecked: string[];
32
+ valid: boolean;
33
+ issues: ValidationIssue[];
34
+ filesChecked: string[];
32
35
  }
33
36
 
34
- export async function validateConfig(rootDir: string = process.cwd()): Promise<ValidationResult> {
35
- const issues: ValidationIssue[] = [];
36
- const filesChecked: string[] = [];
37
- const gauntletPath = path.join(rootDir, GAUNTLET_DIR);
38
- const existingCheckNames = new Set<string>(); // Track all check files that exist (even if invalid)
39
- const existingReviewNames = new Set<string>(); // Track all review files that exist (even if invalid)
37
+ export async function validateConfig(
38
+ rootDir: string = process.cwd(),
39
+ ): Promise<ValidationResult> {
40
+ const issues: ValidationIssue[] = [];
41
+ const filesChecked: string[] = [];
42
+ const gauntletPath = path.join(rootDir, GAUNTLET_DIR);
43
+ const existingCheckNames = new Set<string>(); // Track all check files that exist (even if invalid)
44
+ const existingReviewNames = new Set<string>(); // Track all review files that exist (even if invalid)
45
+
46
+ // 1. Validate project config
47
+ const configPath = path.join(gauntletPath, CONFIG_FILE);
48
+ let projectConfig: GauntletConfig | null = null;
49
+ const checks: Record<string, CheckGateConfig> = {};
50
+ const reviews: Record<string, ReviewPromptFrontmatter> = {};
40
51
 
41
- // 1. Validate project config
42
- const configPath = path.join(gauntletPath, CONFIG_FILE);
43
- let projectConfig: any = null;
44
- let checks: Record<string, any> = {};
45
- let reviews: Record<string, any> = {};
52
+ try {
53
+ if (await fileExists(configPath)) {
54
+ filesChecked.push(configPath);
55
+ const configContent = await fs.readFile(configPath, "utf-8");
56
+ try {
57
+ const raw = YAML.parse(configContent);
58
+ projectConfig = gauntletConfigSchema.parse(raw);
59
+ } catch (error: unknown) {
60
+ if (error instanceof ZodError) {
61
+ error.errors.forEach((err) => {
62
+ issues.push({
63
+ file: configPath,
64
+ severity: "error",
65
+ message: err.message,
66
+ field: err.path.join("."),
67
+ });
68
+ });
69
+ } else {
70
+ const err = error as { name?: string; message?: string };
71
+ if (err.name === "YAMLSyntaxError" || err.message?.includes("YAML")) {
72
+ issues.push({
73
+ file: configPath,
74
+ severity: "error",
75
+ message: `Malformed YAML: ${err.message}`,
76
+ });
77
+ } else {
78
+ issues.push({
79
+ file: configPath,
80
+ severity: "error",
81
+ message: `Parse error: ${err.message}`,
82
+ });
83
+ }
84
+ }
85
+ }
86
+ } else {
87
+ issues.push({
88
+ file: configPath,
89
+ severity: "error",
90
+ message: "Config file not found",
91
+ });
92
+ }
93
+ } catch (error: unknown) {
94
+ const err = error as { message?: string };
95
+ issues.push({
96
+ file: configPath,
97
+ severity: "error",
98
+ message: `Error reading file: ${err.message}`,
99
+ });
100
+ }
46
101
 
47
- try {
48
- if (await fileExists(configPath)) {
49
- filesChecked.push(configPath);
50
- const configContent = await fs.readFile(configPath, 'utf-8');
51
- try {
52
- const raw = YAML.parse(configContent);
53
- projectConfig = gauntletConfigSchema.parse(raw);
54
- } catch (error: any) {
55
- if (error instanceof ZodError) {
56
- error.errors.forEach(err => {
57
- issues.push({
58
- file: configPath,
59
- severity: 'error',
60
- message: err.message,
61
- field: err.path.join('.'),
62
- });
63
- });
64
- } else if (error.name === 'YAMLSyntaxError' || error.message?.includes('YAML')) {
65
- issues.push({
66
- file: configPath,
67
- severity: 'error',
68
- message: `Malformed YAML: ${error.message}`,
69
- });
70
- } else {
71
- issues.push({
72
- file: configPath,
73
- severity: 'error',
74
- message: `Parse error: ${error.message}`,
75
- });
76
- }
77
- }
78
- } else {
79
- issues.push({
80
- file: configPath,
81
- severity: 'error',
82
- message: 'Config file not found',
83
- });
84
- }
85
- } catch (error: any) {
86
- issues.push({
87
- file: configPath,
88
- severity: 'error',
89
- message: `Error reading file: ${error.message}`,
90
- });
91
- }
102
+ // 2. Validate check gates
103
+ const checksPath = path.join(gauntletPath, CHECKS_DIR);
104
+ if (await dirExists(checksPath)) {
105
+ try {
106
+ const checkFiles = await fs.readdir(checksPath);
107
+ for (const file of checkFiles) {
108
+ if (file.endsWith(".yml") || file.endsWith(".yaml")) {
109
+ const filePath = path.join(checksPath, file);
110
+ filesChecked.push(filePath);
111
+ try {
112
+ const content = await fs.readFile(filePath, "utf-8");
113
+ const raw = YAML.parse(content);
114
+ const parsed = checkGateSchema.parse(raw);
115
+ existingCheckNames.add(parsed.name); // Track that this check exists
116
+ checks[parsed.name] = parsed;
92
117
 
93
- // 2. Validate check gates
94
- const checksPath = path.join(gauntletPath, CHECKS_DIR);
95
- if (await dirExists(checksPath)) {
96
- try {
97
- const checkFiles = await fs.readdir(checksPath);
98
- for (const file of checkFiles) {
99
- if (file.endsWith('.yml') || file.endsWith('.yaml')) {
100
- const filePath = path.join(checksPath, file);
101
- filesChecked.push(filePath);
102
- try {
103
- const content = await fs.readFile(filePath, 'utf-8');
104
- const raw = YAML.parse(content);
105
- const parsed = checkGateSchema.parse(raw);
106
- existingCheckNames.add(parsed.name); // Track that this check exists
107
- checks[parsed.name] = parsed;
118
+ // Semantic validation
119
+ if (!parsed.command || parsed.command.trim() === "") {
120
+ issues.push({
121
+ file: filePath,
122
+ severity: "error",
123
+ message: "command field is required and cannot be empty",
124
+ field: "command",
125
+ });
126
+ }
127
+ } catch (error: unknown) {
128
+ // Try to extract check name from raw YAML even if parsing failed
129
+ try {
130
+ const content = await fs.readFile(filePath, "utf-8");
131
+ const raw = YAML.parse(content);
132
+ if (raw.name && typeof raw.name === "string") {
133
+ existingCheckNames.add(raw.name); // Track that this check file exists
134
+ }
135
+ } catch {
136
+ // If we can't even parse the name, that's okay - we'll just skip tracking it
137
+ }
108
138
 
109
- // Semantic validation
110
- if (!parsed.command || parsed.command.trim() === '') {
111
- issues.push({
112
- file: filePath,
113
- severity: 'error',
114
- message: 'command field is required and cannot be empty',
115
- field: 'command',
116
- });
117
- }
118
- } catch (error: any) {
119
- // Try to extract check name from raw YAML even if parsing failed
120
- try {
121
- const content = await fs.readFile(filePath, 'utf-8');
122
- const raw = YAML.parse(content);
123
- if (raw.name && typeof raw.name === 'string') {
124
- existingCheckNames.add(raw.name); // Track that this check file exists
125
- }
126
- } catch {
127
- // If we can't even parse the name, that's okay - we'll just skip tracking it
128
- }
129
-
130
- if (error instanceof ZodError) {
131
- error.errors.forEach(err => {
132
- issues.push({
133
- file: filePath,
134
- severity: 'error',
135
- message: err.message,
136
- field: err.path.join('.'),
137
- });
138
- });
139
- } else if (error.name === 'YAMLSyntaxError' || error.message?.includes('YAML')) {
140
- issues.push({
141
- file: filePath,
142
- severity: 'error',
143
- message: `Malformed YAML: ${error.message}`,
144
- });
145
- } else {
146
- issues.push({
147
- file: filePath,
148
- severity: 'error',
149
- message: `Parse error: ${error.message}`,
150
- });
151
- }
152
- }
153
- }
154
- }
155
- } catch (error: any) {
156
- issues.push({
157
- file: checksPath,
158
- severity: 'error',
159
- message: `Error reading checks directory: ${error.message}`,
160
- });
161
- }
162
- }
139
+ if (error instanceof ZodError) {
140
+ error.errors.forEach((err) => {
141
+ issues.push({
142
+ file: filePath,
143
+ severity: "error",
144
+ message: err.message,
145
+ field: err.path.join("."),
146
+ });
147
+ });
148
+ } else {
149
+ const err = error as { name?: string; message?: string };
150
+ if (
151
+ err.name === "YAMLSyntaxError" ||
152
+ err.message?.includes("YAML")
153
+ ) {
154
+ issues.push({
155
+ file: filePath,
156
+ severity: "error",
157
+ message: `Malformed YAML: ${err.message}`,
158
+ });
159
+ } else {
160
+ issues.push({
161
+ file: filePath,
162
+ severity: "error",
163
+ message: `Parse error: ${err.message}`,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ } catch (error: unknown) {
171
+ const err = error as { message?: string };
172
+ issues.push({
173
+ file: checksPath,
174
+ severity: "error",
175
+ message: `Error reading checks directory: ${err.message}`,
176
+ });
177
+ }
178
+ }
163
179
 
164
- // 3. Validate review gates
165
- const reviewsPath = path.join(gauntletPath, REVIEWS_DIR);
166
- if (await dirExists(reviewsPath)) {
167
- try {
168
- const reviewFiles = await fs.readdir(reviewsPath);
169
- for (const file of reviewFiles) {
170
- if (file.endsWith('.md')) {
171
- const filePath = path.join(reviewsPath, file);
172
- const reviewName = path.basename(file, '.md');
173
- existingReviewNames.add(reviewName); // Track that this review file exists
174
- filesChecked.push(filePath);
175
- try {
176
- const content = await fs.readFile(filePath, 'utf-8');
177
- const { data: frontmatter, content: promptBody } = matter(content);
180
+ // 3. Validate review gates
181
+ const reviewsPath = path.join(gauntletPath, REVIEWS_DIR);
182
+ if (await dirExists(reviewsPath)) {
183
+ try {
184
+ const reviewFiles = await fs.readdir(reviewsPath);
185
+ for (const file of reviewFiles) {
186
+ if (file.endsWith(".md")) {
187
+ const filePath = path.join(reviewsPath, file);
188
+ const reviewName = path.basename(file, ".md");
189
+ existingReviewNames.add(reviewName); // Track that this review file exists
190
+ filesChecked.push(filePath);
191
+ try {
192
+ const content = await fs.readFile(filePath, "utf-8");
193
+ const { data: frontmatter, content: _promptBody } = matter(content);
178
194
 
179
- // Check if frontmatter exists
180
- if (!frontmatter || Object.keys(frontmatter).length === 0) {
181
- issues.push({
182
- file: filePath,
183
- severity: 'error',
184
- message: 'Review gate must have YAML frontmatter',
185
- });
186
- continue;
187
- }
195
+ // Check if frontmatter exists
196
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
197
+ issues.push({
198
+ file: filePath,
199
+ severity: "error",
200
+ message: "Review gate must have YAML frontmatter",
201
+ });
202
+ continue;
203
+ }
188
204
 
189
- // Validate CLI tools even if schema validation fails
190
- if (frontmatter.cli_preference && Array.isArray(frontmatter.cli_preference)) {
191
- for (let i = 0; i < frontmatter.cli_preference.length; i++) {
192
- const toolName = frontmatter.cli_preference[i];
193
- if (typeof toolName === 'string' && !VALID_CLI_TOOLS.includes(toolName)) {
194
- issues.push({
195
- file: filePath,
196
- severity: 'error',
197
- message: `Invalid CLI tool "${toolName}" in cli_preference. Valid options are: ${VALID_CLI_TOOLS.join(', ')}`,
198
- field: `cli_preference[${i}]`,
199
- });
200
- }
201
- }
202
- }
205
+ // Validate CLI tools even if schema validation fails
206
+ if (
207
+ frontmatter.cli_preference &&
208
+ Array.isArray(frontmatter.cli_preference)
209
+ ) {
210
+ for (let i = 0; i < frontmatter.cli_preference.length; i++) {
211
+ const toolName = frontmatter.cli_preference[i];
212
+ if (
213
+ typeof toolName === "string" &&
214
+ !getValidCLITools().includes(toolName)
215
+ ) {
216
+ issues.push({
217
+ file: filePath,
218
+ severity: "error",
219
+ message: `Invalid CLI tool "${toolName}" in cli_preference. Valid options are: ${getValidCLITools().join(", ")}`,
220
+ field: `cli_preference[${i}]`,
221
+ });
222
+ }
223
+ }
224
+ }
203
225
 
204
- const parsedFrontmatter = reviewPromptFrontmatterSchema.parse(frontmatter);
205
- const name = path.basename(file, '.md');
206
- reviews[name] = parsedFrontmatter;
226
+ const parsedFrontmatter =
227
+ reviewPromptFrontmatterSchema.parse(frontmatter);
228
+ const name = path.basename(file, ".md");
229
+ reviews[name] = parsedFrontmatter;
207
230
 
208
- // Semantic validation
209
- // cli_preference is optional; if missing, it defaults to project-level default_preference during load.
210
- // See src/config/loader.ts loadConfig() for default merging logic.
211
- // However, if explicitly provided, it must not be empty.
212
- if (parsedFrontmatter.cli_preference !== undefined) {
213
- if (parsedFrontmatter.cli_preference.length === 0) {
214
- issues.push({
215
- file: filePath,
216
- severity: 'error',
217
- message: 'cli_preference if provided cannot be an empty array. Remove it to use defaults.',
218
- field: 'cli_preference',
219
- });
220
- } else {
221
- // Validate each CLI tool name (double-check after parsing)
222
- for (let i = 0; i < parsedFrontmatter.cli_preference.length; i++) {
223
- const toolName = parsedFrontmatter.cli_preference[i];
224
- if (!VALID_CLI_TOOLS.includes(toolName)) {
225
- issues.push({
226
- file: filePath,
227
- severity: 'error',
228
- message: `Invalid CLI tool "${toolName}" in cli_preference. Valid options are: ${VALID_CLI_TOOLS.join(', ')}`,
229
- field: `cli_preference[${i}]`,
230
- });
231
- }
232
- }
233
- }
234
- }
231
+ // Semantic validation
232
+ // cli_preference is optional; if missing, it defaults to project-level default_preference during load.
233
+ // See src/config/loader.ts loadConfig() for default merging logic.
234
+ // However, if explicitly provided, it must not be empty.
235
+ if (parsedFrontmatter.cli_preference !== undefined) {
236
+ if (parsedFrontmatter.cli_preference.length === 0) {
237
+ issues.push({
238
+ file: filePath,
239
+ severity: "error",
240
+ message:
241
+ "cli_preference if provided cannot be an empty array. Remove it to use defaults.",
242
+ field: "cli_preference",
243
+ });
244
+ } else {
245
+ // Validate each CLI tool name (double-check after parsing)
246
+ for (
247
+ let i = 0;
248
+ i < parsedFrontmatter.cli_preference.length;
249
+ i++
250
+ ) {
251
+ const toolName = parsedFrontmatter.cli_preference[i];
252
+ if (!getValidCLITools().includes(toolName)) {
253
+ issues.push({
254
+ file: filePath,
255
+ severity: "error",
256
+ message: `Invalid CLI tool "${toolName}" in cli_preference. Valid options are: ${getValidCLITools().join(", ")}`,
257
+ field: `cli_preference[${i}]`,
258
+ });
259
+ }
260
+ }
261
+ }
262
+ }
235
263
 
236
- if (parsedFrontmatter.num_reviews !== undefined && parsedFrontmatter.num_reviews < 1) {
237
- issues.push({
238
- file: filePath,
239
- severity: 'error',
240
- message: 'num_reviews must be at least 1',
241
- field: 'num_reviews',
242
- });
243
- }
264
+ if (
265
+ parsedFrontmatter.num_reviews !== undefined &&
266
+ parsedFrontmatter.num_reviews < 1
267
+ ) {
268
+ issues.push({
269
+ file: filePath,
270
+ severity: "error",
271
+ message: "num_reviews must be at least 1",
272
+ field: "num_reviews",
273
+ });
274
+ }
244
275
 
245
- if (parsedFrontmatter.timeout !== undefined && parsedFrontmatter.timeout <= 0) {
246
- issues.push({
247
- file: filePath,
248
- severity: 'error',
249
- message: 'timeout must be greater than 0',
250
- field: 'timeout',
251
- });
252
- }
253
- } catch (error: any) {
254
- if (error instanceof ZodError && error.errors && Array.isArray(error.errors)) {
255
- error.errors.forEach((err: any) => {
256
- const fieldPath = err.path && Array.isArray(err.path) ? err.path.join('.') : undefined;
257
- const message = err.message || `Invalid value for ${fieldPath || 'field'}`;
258
- issues.push({
259
- file: filePath,
260
- severity: 'error',
261
- message: message,
262
- field: fieldPath,
263
- });
264
- });
265
- } else if (error.name === 'YAMLSyntaxError' || error.message?.includes('YAML')) {
266
- issues.push({
267
- file: filePath,
268
- severity: 'error',
269
- message: `Malformed YAML frontmatter: ${error.message || 'Unknown YAML error'}`,
270
- });
271
- } else {
272
- // Try to parse error message from stringified error
273
- let errorMessage = error.message || String(error);
274
- try {
275
- const parsed = JSON.parse(errorMessage);
276
- if (Array.isArray(parsed)) {
277
- // Handle array of Zod errors
278
- parsed.forEach((err: any) => {
279
- const fieldPath = err.path && Array.isArray(err.path) ? err.path.join('.') : undefined;
280
- issues.push({
281
- file: filePath,
282
- severity: 'error',
283
- message: err.message || `Invalid value for ${fieldPath || 'field'}`,
284
- field: fieldPath,
285
- });
286
- });
287
- } else {
288
- issues.push({
289
- file: filePath,
290
- severity: 'error',
291
- message: errorMessage,
292
- });
293
- }
294
- } catch {
295
- issues.push({
296
- file: filePath,
297
- severity: 'error',
298
- message: errorMessage,
299
- });
300
- }
301
- }
302
- }
303
- }
304
- }
305
- } catch (error: any) {
306
- issues.push({
307
- file: reviewsPath,
308
- severity: 'error',
309
- message: `Error reading reviews directory: ${error.message || String(error)}`,
310
- });
311
- }
312
- }
276
+ if (
277
+ parsedFrontmatter.timeout !== undefined &&
278
+ parsedFrontmatter.timeout <= 0
279
+ ) {
280
+ issues.push({
281
+ file: filePath,
282
+ severity: "error",
283
+ message: "timeout must be greater than 0",
284
+ field: "timeout",
285
+ });
286
+ }
287
+ } catch (error: unknown) {
288
+ if (error instanceof ZodError) {
289
+ error.errors.forEach((err) => {
290
+ const fieldPath =
291
+ err.path && Array.isArray(err.path)
292
+ ? err.path.join(".")
293
+ : undefined;
294
+ const message =
295
+ err.message || `Invalid value for ${fieldPath || "field"}`;
296
+ issues.push({
297
+ file: filePath,
298
+ severity: "error",
299
+ message,
300
+ field: fieldPath,
301
+ });
302
+ });
303
+ } else {
304
+ const err = error as { name?: string; message?: string };
305
+ if (
306
+ err.name === "YAMLSyntaxError" ||
307
+ err.message?.includes("YAML")
308
+ ) {
309
+ issues.push({
310
+ file: filePath,
311
+ severity: "error",
312
+ message: `Malformed YAML frontmatter: ${
313
+ err.message || "Unknown YAML error"
314
+ }`,
315
+ });
316
+ } else {
317
+ // Try to parse error message from stringified error
318
+ const errorMessage = err.message || String(error);
319
+ try {
320
+ const parsed = JSON.parse(errorMessage);
321
+ if (Array.isArray(parsed)) {
322
+ // Handle array of Zod errors
323
+ parsed.forEach(
324
+ (err: { path: string[]; message: string }) => {
325
+ const fieldPath =
326
+ err.path && Array.isArray(err.path)
327
+ ? err.path.join(".")
328
+ : undefined;
329
+ issues.push({
330
+ file: filePath,
331
+ severity: "error",
332
+ message:
333
+ err.message ||
334
+ `Invalid value for ${fieldPath || "field"}`,
335
+ field: fieldPath,
336
+ });
337
+ },
338
+ );
339
+ } else {
340
+ issues.push({
341
+ file: filePath,
342
+ severity: "error",
343
+ message: errorMessage,
344
+ });
345
+ }
346
+ } catch {
347
+ issues.push({
348
+ file: filePath,
349
+ severity: "error",
350
+ message: errorMessage,
351
+ });
352
+ }
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
358
+ } catch (error: unknown) {
359
+ const err = error as { message?: string };
360
+ issues.push({
361
+ file: reviewsPath,
362
+ severity: "error",
363
+ message: `Error reading reviews directory: ${
364
+ err.message || String(error)
365
+ }`,
366
+ });
367
+ }
368
+ }
313
369
 
314
- // 4. Cross-reference validation (entry points referencing gates)
315
- if (projectConfig && projectConfig.entry_points) {
316
- for (let i = 0; i < projectConfig.entry_points.length; i++) {
317
- const entryPoint = projectConfig.entry_points[i];
318
- const entryPointPath = `entry_points[${i}]`;
370
+ // 4. Cross-reference validation (entry points referencing gates)
371
+ if (projectConfig?.entry_points) {
372
+ for (let i = 0; i < projectConfig.entry_points.length; i++) {
373
+ const entryPoint = projectConfig.entry_points[i];
374
+ const entryPointPath = `entry_points[${i}]`;
319
375
 
320
- // Validate entry point schema
321
- try {
322
- entryPointSchema.parse(entryPoint);
323
- } catch (error: any) {
324
- if (error instanceof ZodError) {
325
- error.errors.forEach(err => {
326
- issues.push({
327
- file: configPath,
328
- severity: 'error',
329
- message: err.message,
330
- field: `${entryPointPath}.${err.path.join('.')}`,
331
- });
332
- });
333
- }
334
- }
376
+ // Validate entry point schema
377
+ try {
378
+ entryPointSchema.parse(entryPoint);
379
+ } catch (error: unknown) {
380
+ if (error instanceof ZodError) {
381
+ error.errors.forEach((err) => {
382
+ issues.push({
383
+ file: configPath,
384
+ severity: "error",
385
+ message: err.message,
386
+ field: `${entryPointPath}.${err.path.join(".")}`,
387
+ });
388
+ });
389
+ }
390
+ }
335
391
 
336
- // Check referenced checks exist
337
- if (entryPoint.checks) {
338
- for (const checkName of entryPoint.checks) {
339
- // Only report as "non-existent" if the file doesn't exist at all
340
- // If the file exists but has validation errors, those are already reported
341
- if (!existingCheckNames.has(checkName)) {
342
- issues.push({
343
- file: configPath,
344
- severity: 'error',
345
- message: `Entry point references non-existent check gate: "${checkName}"`,
346
- field: `${entryPointPath}.checks`,
347
- });
348
- }
349
- // If the check file exists but wasn't successfully parsed (has errors),
350
- // we don't report it here - the validation errors for that file are already shown
351
- }
352
- }
392
+ // Check referenced checks exist
393
+ if (entryPoint.checks) {
394
+ for (const checkName of entryPoint.checks) {
395
+ // Only report as "non-existent" if the file doesn't exist at all
396
+ // If the file exists but has validation errors, those are already reported
397
+ if (!existingCheckNames.has(checkName)) {
398
+ issues.push({
399
+ file: configPath,
400
+ severity: "error",
401
+ message: `Entry point references non-existent check gate: "${checkName}"`,
402
+ field: `${entryPointPath}.checks`,
403
+ });
404
+ }
405
+ // If the check file exists but wasn't successfully parsed (has errors),
406
+ // we don't report it here - the validation errors for that file are already shown
407
+ }
408
+ }
353
409
 
354
- // Check referenced reviews exist
355
- if (entryPoint.reviews) {
356
- for (const reviewName of entryPoint.reviews) {
357
- // Only report as "non-existent" if the file doesn't exist at all
358
- // If the file exists but has validation errors, those are already reported
359
- if (!existingReviewNames.has(reviewName)) {
360
- issues.push({
361
- file: configPath,
362
- severity: 'error',
363
- message: `Entry point references non-existent review gate: "${reviewName}"`,
364
- field: `${entryPointPath}.reviews`,
365
- });
366
- }
367
- // If the review file exists but wasn't successfully parsed (has errors),
368
- // we don't report it here - the validation errors for that file are already shown
369
- }
370
- }
410
+ // Check referenced reviews exist
411
+ if (entryPoint.reviews) {
412
+ for (const reviewName of entryPoint.reviews) {
413
+ // Only report as "non-existent" if the file doesn't exist at all
414
+ // If the file exists but has validation errors, those are already reported
415
+ if (!existingReviewNames.has(reviewName)) {
416
+ issues.push({
417
+ file: configPath,
418
+ severity: "error",
419
+ message: `Entry point references non-existent review gate: "${reviewName}"`,
420
+ field: `${entryPointPath}.reviews`,
421
+ });
422
+ }
423
+ // If the review file exists but wasn't successfully parsed (has errors),
424
+ // we don't report it here - the validation errors for that file are already shown
425
+ }
426
+ }
371
427
 
372
- // Validate entry point has at least one gate
373
- if ((!entryPoint.checks || entryPoint.checks.length === 0) &&
374
- (!entryPoint.reviews || entryPoint.reviews.length === 0)) {
375
- issues.push({
376
- file: configPath,
377
- severity: 'warning',
378
- message: `Entry point at "${entryPoint.path}" has no checks or reviews configured`,
379
- field: `${entryPointPath}`,
380
- });
381
- }
428
+ // Validate entry point has at least one gate
429
+ if (
430
+ (!entryPoint.checks || entryPoint.checks.length === 0) &&
431
+ (!entryPoint.reviews || entryPoint.reviews.length === 0)
432
+ ) {
433
+ issues.push({
434
+ file: configPath,
435
+ severity: "warning",
436
+ message: `Entry point at "${entryPoint.path}" has no checks or reviews configured`,
437
+ field: `${entryPointPath}`,
438
+ });
439
+ }
382
440
 
383
- // Validate path format (basic check)
384
- if (!entryPoint.path || entryPoint.path.trim() === '') {
385
- issues.push({
386
- file: configPath,
387
- severity: 'error',
388
- message: 'Entry point path cannot be empty',
389
- field: `${entryPointPath}.path`,
390
- });
391
- }
392
- }
393
- }
441
+ // Validate path format (basic check)
442
+ if (!entryPoint.path || entryPoint.path.trim() === "") {
443
+ issues.push({
444
+ file: configPath,
445
+ severity: "error",
446
+ message: "Entry point path cannot be empty",
447
+ field: `${entryPointPath}.path`,
448
+ });
449
+ }
450
+ }
451
+ }
394
452
 
395
- // 5. Validate project-level config values
396
- if (projectConfig) {
397
- if (projectConfig.log_dir !== undefined && projectConfig.log_dir.trim() === '') {
398
- issues.push({
399
- file: configPath,
400
- severity: 'error',
401
- message: 'log_dir cannot be empty',
402
- field: 'log_dir',
403
- });
404
- }
453
+ // 5. Validate project-level config values
454
+ if (projectConfig) {
455
+ if (
456
+ projectConfig.log_dir !== undefined &&
457
+ projectConfig.log_dir.trim() === ""
458
+ ) {
459
+ issues.push({
460
+ file: configPath,
461
+ severity: "error",
462
+ message: "log_dir cannot be empty",
463
+ field: "log_dir",
464
+ });
465
+ }
405
466
 
406
- if (projectConfig.base_branch !== undefined && projectConfig.base_branch.trim() === '') {
407
- issues.push({
408
- file: configPath,
409
- severity: 'error',
410
- message: 'base_branch cannot be empty',
411
- field: 'base_branch',
412
- });
413
- }
467
+ if (
468
+ projectConfig.base_branch !== undefined &&
469
+ projectConfig.base_branch.trim() === ""
470
+ ) {
471
+ issues.push({
472
+ file: configPath,
473
+ severity: "error",
474
+ message: "base_branch cannot be empty",
475
+ field: "base_branch",
476
+ });
477
+ }
414
478
 
415
- if (projectConfig.entry_points === undefined || projectConfig.entry_points.length === 0) {
416
- issues.push({
417
- file: configPath,
418
- severity: 'error',
419
- message: 'entry_points is required and cannot be empty',
420
- field: 'entry_points',
421
- });
422
- }
479
+ if (
480
+ projectConfig.entry_points === undefined ||
481
+ projectConfig.entry_points.length === 0
482
+ ) {
483
+ issues.push({
484
+ file: configPath,
485
+ severity: "error",
486
+ message: "entry_points is required and cannot be empty",
487
+ field: "entry_points",
488
+ });
489
+ }
423
490
 
424
- // Validate CLI config
425
- if (projectConfig.cli) {
426
- const defaults = projectConfig.cli.default_preference;
427
- if (!defaults || !Array.isArray(defaults) || defaults.length === 0) {
428
- issues.push({
429
- file: configPath,
430
- severity: 'error',
431
- message: 'cli.default_preference is required and cannot be empty',
432
- field: 'cli.default_preference',
433
- });
434
- } else {
435
- // Validate defaults are valid tools
436
- for (let i = 0; i < defaults.length; i++) {
437
- const toolName = defaults[i];
438
- if (!VALID_CLI_TOOLS.includes(toolName)) {
439
- issues.push({
440
- file: configPath,
441
- severity: 'error',
442
- message: `Invalid CLI tool "${toolName}" in default_preference. Valid options are: ${VALID_CLI_TOOLS.join(', ')}`,
443
- field: `cli.default_preference[${i}]`,
444
- });
445
- }
446
- }
491
+ // Validate CLI config
492
+ if (projectConfig.cli) {
493
+ const defaults = projectConfig.cli.default_preference;
494
+ if (!defaults || !Array.isArray(defaults) || defaults.length === 0) {
495
+ issues.push({
496
+ file: configPath,
497
+ severity: "error",
498
+ message: "cli.default_preference is required and cannot be empty",
499
+ field: "cli.default_preference",
500
+ });
501
+ } else {
502
+ // Validate defaults are valid tools
503
+ for (let i = 0; i < defaults.length; i++) {
504
+ const toolName = defaults[i];
505
+ if (!getValidCLITools().includes(toolName)) {
506
+ issues.push({
507
+ file: configPath,
508
+ severity: "error",
509
+ message: `Invalid CLI tool "${toolName}" in default_preference. Valid options are: ${getValidCLITools().join(", ")}`,
510
+ field: `cli.default_preference[${i}]`,
511
+ });
512
+ }
513
+ }
447
514
 
448
- // Validate review preferences against defaults
449
- // We need to re-scan reviews here because we need the project config to be loaded first
450
- // Ideally we would do this in the review loop, but we didn't have project config then.
451
- // Instead, we'll iterate over the parsed reviews we collected.
452
- const allowedTools = new Set(defaults);
453
- for (const [reviewName, reviewConfig] of Object.entries(reviews)) {
454
- const pref = (reviewConfig as any).cli_preference;
455
- if (pref && Array.isArray(pref)) {
456
- for (let i = 0; i < pref.length; i++) {
457
- const tool = pref[i];
458
- if (!allowedTools.has(tool)) {
459
- issues.push({
460
- file: path.join(reviewsPath, `${reviewName}.md`),
461
- severity: 'error',
462
- message: `CLI tool "${tool}" is not in project-level default_preference. Review gates can only use tools enabled in config.yml`,
463
- field: `cli_preference[${i}]`,
464
- });
465
- }
466
- }
467
- }
468
- }
469
- }
470
- }
471
- }
515
+ // Validate review preferences against defaults
516
+ // We need to re-scan reviews here because we need the project config to be loaded first
517
+ // Ideally we would do this in the review loop, but we didn't have project config then.
518
+ // Instead, we'll iterate over the parsed reviews we collected.
519
+ const allowedTools = new Set(defaults);
520
+ for (const [reviewName, reviewConfig] of Object.entries(reviews)) {
521
+ const pref = reviewConfig.cli_preference;
522
+ if (pref && Array.isArray(pref)) {
523
+ for (let i = 0; i < pref.length; i++) {
524
+ const tool = pref[i];
525
+ if (!allowedTools.has(tool)) {
526
+ issues.push({
527
+ file: path.join(reviewsPath, `${reviewName}.md`),
528
+ severity: "error",
529
+ message: `CLI tool "${tool}" is not in project-level default_preference. Review gates can only use tools enabled in config.yml`,
530
+ field: `cli_preference[${i}]`,
531
+ });
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
472
539
 
473
- const valid = issues.filter(i => i.severity === 'error').length === 0;
474
- return { valid, issues, filesChecked };
540
+ const valid = issues.filter((i) => i.severity === "error").length === 0;
541
+ return { valid, issues, filesChecked };
475
542
  }
476
543
 
477
544
  async function fileExists(path: string): Promise<boolean> {
478
- try {
479
- const stat = await fs.stat(path);
480
- return stat.isFile();
481
- } catch {
482
- return false;
483
- }
545
+ try {
546
+ const stat = await fs.stat(path);
547
+ return stat.isFile();
548
+ } catch {
549
+ return false;
550
+ }
484
551
  }
485
552
 
486
553
  async function dirExists(path: string): Promise<boolean> {
487
- try {
488
- const stat = await fs.stat(path);
489
- return stat.isDirectory();
490
- } catch {
491
- return false;
492
- }
554
+ try {
555
+ const stat = await fs.stat(path);
556
+ return stat.isDirectory();
557
+ } catch {
558
+ return false;
559
+ }
493
560
  }