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,493 @@
|
|
|
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';
|
|
6
|
+
import {
|
|
7
|
+
gauntletConfigSchema,
|
|
8
|
+
checkGateSchema,
|
|
9
|
+
reviewPromptFrontmatterSchema,
|
|
10
|
+
entryPointSchema,
|
|
11
|
+
} from './schema.js';
|
|
12
|
+
|
|
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';
|
|
20
|
+
|
|
21
|
+
export interface ValidationIssue {
|
|
22
|
+
file: string;
|
|
23
|
+
severity: 'error' | 'warning';
|
|
24
|
+
message: string;
|
|
25
|
+
field?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ValidationResult {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
issues: ValidationIssue[];
|
|
31
|
+
filesChecked: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
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)
|
|
40
|
+
|
|
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> = {};
|
|
46
|
+
|
|
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
|
+
}
|
|
92
|
+
|
|
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;
|
|
108
|
+
|
|
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
|
+
}
|
|
163
|
+
|
|
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);
|
|
178
|
+
|
|
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
|
+
}
|
|
188
|
+
|
|
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
|
+
}
|
|
203
|
+
|
|
204
|
+
const parsedFrontmatter = reviewPromptFrontmatterSchema.parse(frontmatter);
|
|
205
|
+
const name = path.basename(file, '.md');
|
|
206
|
+
reviews[name] = parsedFrontmatter;
|
|
207
|
+
|
|
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
|
+
}
|
|
235
|
+
|
|
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
|
+
}
|
|
244
|
+
|
|
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
|
+
}
|
|
313
|
+
|
|
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}]`;
|
|
319
|
+
|
|
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
|
+
}
|
|
335
|
+
|
|
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
|
+
}
|
|
353
|
+
|
|
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
|
+
}
|
|
371
|
+
|
|
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
|
+
}
|
|
382
|
+
|
|
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
|
+
}
|
|
394
|
+
|
|
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
|
+
}
|
|
405
|
+
|
|
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
|
+
}
|
|
414
|
+
|
|
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
|
+
}
|
|
423
|
+
|
|
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
|
+
}
|
|
447
|
+
|
|
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
|
+
}
|
|
472
|
+
|
|
473
|
+
const valid = issues.filter(i => i.severity === 'error').length === 0;
|
|
474
|
+
return { valid, issues, filesChecked };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
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
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
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
|
+
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
export interface ChangeDetectorOptions {
|
|
7
|
+
commit?: string; // If provided, get diff for this commit vs its parent
|
|
8
|
+
uncommitted?: boolean; // If true, only get uncommitted changes (staged + unstaged)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ChangeDetector {
|
|
12
|
+
constructor(
|
|
13
|
+
private baseBranch: string = 'origin/main',
|
|
14
|
+
private options: ChangeDetectorOptions = {}
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
async getChangedFiles(): Promise<string[]> {
|
|
18
|
+
// If commit option is provided, use that
|
|
19
|
+
if (this.options.commit) {
|
|
20
|
+
return this.getCommitChangedFiles(this.options.commit);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// If uncommitted option is provided, only get uncommitted changes
|
|
24
|
+
if (this.options.uncommitted) {
|
|
25
|
+
return this.getUncommittedChangedFiles();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
|
|
29
|
+
|
|
30
|
+
if (isCI) {
|
|
31
|
+
return this.getCIChangedFiles();
|
|
32
|
+
} else {
|
|
33
|
+
return this.getLocalChangedFiles();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async getCIChangedFiles(): Promise<string[]> {
|
|
38
|
+
// In GitHub Actions, GITHUB_BASE_REF is the target branch (e.g., main)
|
|
39
|
+
// GITHUB_SHA is the commit being built
|
|
40
|
+
const baseRef = process.env.GITHUB_BASE_REF || this.baseBranch;
|
|
41
|
+
const headRef = process.env.GITHUB_SHA || 'HEAD';
|
|
42
|
+
|
|
43
|
+
// We might need to fetch first in some shallow clones, but assuming strictly for now
|
|
44
|
+
// git diff --name-only base...head
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execAsync(`git diff --name-only ${baseRef}...${headRef}`);
|
|
47
|
+
return this.parseOutput(stdout);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn('Failed to detect changes via git diff in CI, falling back to HEAD^...HEAD', error);
|
|
50
|
+
// Fallback for push events where base ref might not be available
|
|
51
|
+
const { stdout } = await execAsync('git diff --name-only HEAD^...HEAD');
|
|
52
|
+
return this.parseOutput(stdout);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async getLocalChangedFiles(): Promise<string[]> {
|
|
57
|
+
// 1. Committed changes relative to base branch
|
|
58
|
+
const { stdout: committed } = await execAsync(`git diff --name-only ${this.baseBranch}...HEAD`);
|
|
59
|
+
|
|
60
|
+
// 2. Uncommitted changes (staged and unstaged)
|
|
61
|
+
const { stdout: uncommitted } = await execAsync('git diff --name-only HEAD');
|
|
62
|
+
|
|
63
|
+
// 3. Untracked files
|
|
64
|
+
const { stdout: untracked } = await execAsync('git ls-files --others --exclude-standard');
|
|
65
|
+
|
|
66
|
+
const files = new Set([
|
|
67
|
+
...this.parseOutput(committed),
|
|
68
|
+
...this.parseOutput(uncommitted),
|
|
69
|
+
...this.parseOutput(untracked)
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
return Array.from(files);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async getCommitChangedFiles(commit: string): Promise<string[]> {
|
|
76
|
+
// Get diff for commit vs its parent
|
|
77
|
+
try {
|
|
78
|
+
const { stdout } = await execAsync(`git diff --name-only ${commit}^..${commit}`);
|
|
79
|
+
return this.parseOutput(stdout);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// If commit has no parent (initial commit), just get files in that commit
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execAsync(`git diff --name-only --root ${commit}`);
|
|
84
|
+
return this.parseOutput(stdout);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(`Failed to get changes for commit ${commit}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async getUncommittedChangedFiles(): Promise<string[]> {
|
|
92
|
+
// Get uncommitted changes (staged + unstaged) and untracked files
|
|
93
|
+
const { stdout: staged } = await execAsync('git diff --name-only --cached');
|
|
94
|
+
const { stdout: unstaged } = await execAsync('git diff --name-only');
|
|
95
|
+
const { stdout: untracked } = await execAsync('git ls-files --others --exclude-standard');
|
|
96
|
+
|
|
97
|
+
const files = new Set([
|
|
98
|
+
...this.parseOutput(staged),
|
|
99
|
+
...this.parseOutput(unstaged),
|
|
100
|
+
...this.parseOutput(untracked)
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
return Array.from(files);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private parseOutput(stdout: string): string[] {
|
|
107
|
+
return stdout
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map(line => line.trim())
|
|
110
|
+
.filter(line => line.length > 0);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import { EntryPointExpander } from './entry-point.js';
|
|
3
|
+
import { EntryPointConfig } from '../config/types.js';
|
|
4
|
+
|
|
5
|
+
describe('EntryPointExpander', () => {
|
|
6
|
+
const expander = new EntryPointExpander();
|
|
7
|
+
|
|
8
|
+
it('should include root entry point if there are any changes', async () => {
|
|
9
|
+
const entryPoints: EntryPointConfig[] = [{ path: '.' }];
|
|
10
|
+
const changes = ['some/file.ts'];
|
|
11
|
+
|
|
12
|
+
const result = await expander.expand(entryPoints, changes);
|
|
13
|
+
|
|
14
|
+
expect(result).toHaveLength(1);
|
|
15
|
+
expect(result[0].path).toBe('.');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should match fixed directory entry points', async () => {
|
|
19
|
+
const entryPoints: EntryPointConfig[] = [
|
|
20
|
+
{ path: 'apps/api' },
|
|
21
|
+
{ path: 'apps/web' }
|
|
22
|
+
];
|
|
23
|
+
const changes = ['apps/api/src/index.ts'];
|
|
24
|
+
|
|
25
|
+
const result = await expander.expand(entryPoints, changes);
|
|
26
|
+
|
|
27
|
+
// Result should have root (implicit or explicit fallback in code) + matched
|
|
28
|
+
// Looking at code: "if (changedFiles.length > 0) ... results.push({ path: '.', ... })"
|
|
29
|
+
// Wait, the code creates a default root config if one isn't provided in the list?
|
|
30
|
+
// Code: "const rootConfig = rootEntryPoint ?? { path: '.' }; results.push({ path: '.', config: rootConfig });"
|
|
31
|
+
// Yes, it always pushes root if changes > 0.
|
|
32
|
+
|
|
33
|
+
expect(result.some(r => r.path === 'apps/api')).toBe(true);
|
|
34
|
+
expect(result.some(r => r.path === 'apps/web')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should match wildcard entry points', async () => {
|
|
38
|
+
const entryPoints: EntryPointConfig[] = [
|
|
39
|
+
{ path: 'packages/*' }
|
|
40
|
+
];
|
|
41
|
+
const changes = [
|
|
42
|
+
'packages/ui/button.ts',
|
|
43
|
+
'packages/utils/helper.ts',
|
|
44
|
+
'other/file.ts'
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const result = await expander.expand(entryPoints, changes);
|
|
48
|
+
|
|
49
|
+
const paths = result.map(r => r.path);
|
|
50
|
+
expect(paths).toContain('packages/ui');
|
|
51
|
+
expect(paths).toContain('packages/utils');
|
|
52
|
+
expect(paths).not.toContain('packages/other');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle no changes', async () => {
|
|
56
|
+
const entryPoints: EntryPointConfig[] = [{ path: '.' }];
|
|
57
|
+
const changes: string[] = [];
|
|
58
|
+
|
|
59
|
+
const result = await expander.expand(entryPoints, changes);
|
|
60
|
+
|
|
61
|
+
expect(result).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
});
|