/** * Diff-based test selection for E2E and LLM-judge evals — the LOGIC half. * * Each test declares which source files it depends on ("touchfiles") in * ./touchfiles-data.ts (literals only — see the note there). The test runner * checks `git diff` and only runs tests whose dependencies were modified. * Override with EVALS_ALL=1 to run everything. * * Import sites should keep using the ./touchfiles facade, which re-exports * both this module and the data module. */ import { spawnSync } from 'child_process'; import { GLOBAL_TOUCHFILES } from './touchfiles-data'; // --- Glob matching --- /** * Match a file path against a glob pattern. * Supports: * ** — match any number of path segments * * — match within a single segment (no /) */ export function matchGlob(file: string, pattern: string): boolean { const regexStr = pattern .replace(/\./g, '\\.') .replace(/\*\*/g, '{{GLOBSTAR}}') .replace(/\*/g, '[^/]*') .replace(/\{\{GLOBSTAR\}\}/g, '.*'); return new RegExp(`^${regexStr}$`).test(file); } // --- Base branch detection --- /** * Detect the base branch by trying refs in order. * Returns the first valid ref, or null if none found. */ export function detectBaseBranch(cwd: string): string | null { for (const ref of ['origin/main', 'origin/master', 'main', 'master']) { const result = spawnSync('git', ['rev-parse', '--verify', ref], { cwd, stdio: 'pipe', timeout: 3000, }); if (result.status === 0) return ref; } return null; } /** * Get list of files changed between base branch and HEAD. */ export function getChangedFiles(baseBranch: string, cwd: string): string[] { const result = spawnSync('git', ['diff', '--name-only', `${baseBranch}...HEAD`], { cwd, stdio: 'pipe', timeout: 5000, }); if (result.status !== 0) return []; return result.stdout.toString().trim().split('\n').filter(Boolean); } // --- Test selection --- /** * Select tests to run based on changed files. * * Algorithm: * 1. If any changed file matches a global touchfile → run ALL tests * 2. Otherwise, for each test, check if any changed file matches its patterns * 3. Return selected + skipped lists with reason */ export function selectTests( changedFiles: string[], touchfiles: Record, globalTouchfiles: string[] = GLOBAL_TOUCHFILES, ): { selected: string[]; skipped: string[]; reason: string } { const allTestNames = Object.keys(touchfiles); // Global touchfile hit → run all for (const file of changedFiles) { if (globalTouchfiles.some(g => matchGlob(file, g))) { return { selected: allTestNames, skipped: [], reason: `global: ${file}` }; } } // Per-test matching const selected: string[] = []; const skipped: string[] = []; for (const [testName, patterns] of Object.entries(touchfiles)) { const hit = changedFiles.some(f => patterns.some(p => matchGlob(f, p))); (hit ? selected : skipped).push(testName); } return { selected, skipped, reason: 'diff' }; }