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.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +106 -0
  3. package/package.json +51 -0
  4. package/src/cli-adapters/claude.ts +114 -0
  5. package/src/cli-adapters/codex.ts +123 -0
  6. package/src/cli-adapters/gemini.ts +149 -0
  7. package/src/cli-adapters/index.ts +79 -0
  8. package/src/commands/check.test.ts +25 -0
  9. package/src/commands/check.ts +67 -0
  10. package/src/commands/detect.test.ts +37 -0
  11. package/src/commands/detect.ts +69 -0
  12. package/src/commands/health.test.ts +79 -0
  13. package/src/commands/health.ts +148 -0
  14. package/src/commands/help.test.ts +44 -0
  15. package/src/commands/help.ts +24 -0
  16. package/src/commands/index.ts +9 -0
  17. package/src/commands/init.test.ts +105 -0
  18. package/src/commands/init.ts +330 -0
  19. package/src/commands/list.test.ts +104 -0
  20. package/src/commands/list.ts +29 -0
  21. package/src/commands/rerun.ts +118 -0
  22. package/src/commands/review.test.ts +25 -0
  23. package/src/commands/review.ts +67 -0
  24. package/src/commands/run.test.ts +25 -0
  25. package/src/commands/run.ts +64 -0
  26. package/src/commands/shared.ts +10 -0
  27. package/src/config/loader.test.ts +129 -0
  28. package/src/config/loader.ts +130 -0
  29. package/src/config/schema.ts +63 -0
  30. package/src/config/types.ts +23 -0
  31. package/src/config/validator.ts +493 -0
  32. package/src/core/change-detector.ts +112 -0
  33. package/src/core/entry-point.test.ts +63 -0
  34. package/src/core/entry-point.ts +80 -0
  35. package/src/core/job.ts +74 -0
  36. package/src/core/runner.ts +226 -0
  37. package/src/gates/check.ts +82 -0
  38. package/src/gates/result.ts +9 -0
  39. package/src/gates/review.ts +501 -0
  40. package/src/index.ts +38 -0
  41. package/src/output/console.ts +201 -0
  42. package/src/output/logger.ts +66 -0
  43. package/src/utils/log-parser.ts +228 -0
  44. 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
+ });