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.
- package/README.md +55 -87
- package/package.json +4 -2
- package/src/bun-plugins.d.ts +4 -0
- package/src/cli-adapters/claude.ts +139 -108
- package/src/cli-adapters/codex.ts +141 -117
- package/src/cli-adapters/cursor.ts +152 -0
- package/src/cli-adapters/gemini.ts +171 -139
- package/src/cli-adapters/github-copilot.ts +153 -0
- package/src/cli-adapters/index.ts +77 -48
- package/src/commands/check.test.ts +24 -20
- package/src/commands/check.ts +86 -59
- package/src/commands/ci/index.ts +15 -0
- package/src/commands/ci/init.ts +96 -0
- package/src/commands/ci/list-jobs.ts +78 -0
- package/src/commands/detect.test.ts +38 -32
- package/src/commands/detect.ts +89 -61
- package/src/commands/health.test.ts +67 -53
- package/src/commands/health.ts +167 -145
- package/src/commands/help.test.ts +37 -37
- package/src/commands/help.ts +31 -22
- package/src/commands/index.ts +10 -9
- package/src/commands/init.test.ts +120 -107
- package/src/commands/init.ts +514 -417
- package/src/commands/list.test.ts +87 -70
- package/src/commands/list.ts +28 -24
- package/src/commands/rerun.ts +157 -119
- package/src/commands/review.test.ts +26 -20
- package/src/commands/review.ts +86 -59
- package/src/commands/run.test.ts +22 -20
- package/src/commands/run.ts +85 -58
- package/src/commands/shared.ts +44 -35
- package/src/config/ci-loader.ts +33 -0
- package/src/config/ci-schema.ts +52 -0
- package/src/config/loader.test.ts +112 -90
- package/src/config/loader.ts +132 -123
- package/src/config/schema.ts +48 -47
- package/src/config/types.ts +28 -13
- package/src/config/validator.ts +521 -454
- package/src/core/change-detector.ts +122 -104
- package/src/core/entry-point.test.ts +60 -62
- package/src/core/entry-point.ts +120 -74
- package/src/core/job.ts +69 -59
- package/src/core/runner.ts +264 -230
- package/src/gates/check.ts +78 -69
- package/src/gates/result.ts +7 -7
- package/src/gates/review.test.ts +277 -138
- package/src/gates/review.ts +724 -561
- package/src/index.ts +18 -15
- package/src/output/console.ts +253 -214
- package/src/output/logger.ts +66 -52
- package/src/templates/run_gauntlet.template.md +18 -0
- package/src/templates/workflow.yml +77 -0
- package/src/utils/diff-parser.ts +64 -62
- package/src/utils/log-parser.ts +227 -206
- package/src/utils/sanitizer.ts +1 -1
package/src/config/validator.ts
CHANGED
|
@@ -1,493 +1,560 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { ZodError } from
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from
|
|
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
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
file: string;
|
|
26
|
+
severity: "error" | "warning";
|
|
27
|
+
message: string;
|
|
28
|
+
field?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface ValidationResult {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
valid: boolean;
|
|
33
|
+
issues: ValidationIssue[];
|
|
34
|
+
filesChecked: string[];
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
export async function validateConfig(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
const parsedFrontmatter =
|
|
227
|
+
reviewPromptFrontmatterSchema.parse(frontmatter);
|
|
228
|
+
const name = path.basename(file, ".md");
|
|
229
|
+
reviews[name] = parsedFrontmatter;
|
|
207
230
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
554
|
+
try {
|
|
555
|
+
const stat = await fs.stat(path);
|
|
556
|
+
return stat.isDirectory();
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
493
560
|
}
|