validate-suites-cli.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. #!/usr/bin/env node
  2. /**
  3. * CLI tool to validate test suite JSON files
  4. *
  5. * Usage:
  6. * npm run validate:suites
  7. * npm run validate:suites -- core/openagent
  8. * npm run validate:suites -- --all
  9. */
  10. import { SuiteValidator } from './suite-validator.js';
  11. import { existsSync, readdirSync } from 'fs';
  12. import { join, dirname } from 'path';
  13. import { fileURLToPath } from 'url';
  14. const __filename = fileURLToPath(import.meta.url);
  15. const __dirname = dirname(__filename);
  16. // Colors
  17. const colors = {
  18. reset: '\x1b[0m',
  19. red: '\x1b[31m',
  20. green: '\x1b[32m',
  21. yellow: '\x1b[33m',
  22. blue: '\x1b[34m'
  23. };
  24. export interface ValidationStats {
  25. totalSuites: number;
  26. validSuites: number;
  27. invalidSuites: number;
  28. totalErrors: number;
  29. totalWarnings: number;
  30. }
  31. /**
  32. * Recursively discover agent ids under a nested agents directory.
  33. *
  34. * An "agent" is any directory that CONTAINS a `config` subdirectory. Agents
  35. * live at a category-based path such as `core/openagent`, so the returned ids
  36. * are the relative path from `agentsDir` to each such directory. Discovery:
  37. * - recurses through category directories (which have no direct `config`),
  38. * - stops descending once a `config` subdir is found (agents don't nest),
  39. * - skips hidden dirs and `node_modules`,
  40. * - returns a SORTED array for deterministic ordering.
  41. */
  42. export function discoverAgents(agentsDir: string): string[] {
  43. const results: string[] = [];
  44. function walk(dir: string, relPrefix: string): void {
  45. let entries;
  46. try {
  47. entries = readdirSync(dir, { withFileTypes: true });
  48. } catch {
  49. return;
  50. }
  51. for (const entry of entries) {
  52. if (!entry.isDirectory()) continue;
  53. const name = entry.name;
  54. if (name.startsWith('.') || name === 'node_modules') continue;
  55. const rel = relPrefix ? `${relPrefix}/${name}` : name;
  56. const full = join(dir, name);
  57. if (existsSync(join(full, 'config'))) {
  58. // This directory is an agent; record it and do not descend further.
  59. results.push(rel);
  60. } else {
  61. walk(full, rel);
  62. }
  63. }
  64. }
  65. walk(agentsDir, '');
  66. return results.sort();
  67. }
  68. /**
  69. * Collect suite JSON files for a single agent.
  70. *
  71. * Looks in `<config>/suites/*.json` (new location) and `<config>/*.json`
  72. * (legacy location, excluding the suite schema). Returns absolute paths.
  73. */
  74. export function collectSuiteFiles(agentsDir: string, agentId: string): string[] {
  75. const agentConfigDir = join(agentsDir, agentId, 'config');
  76. const suiteFiles: string[] = [];
  77. if (!existsSync(agentConfigDir)) {
  78. return suiteFiles;
  79. }
  80. // New location: suites directory
  81. const suitesDir = join(agentConfigDir, 'suites');
  82. if (existsSync(suitesDir)) {
  83. readdirSync(suitesDir)
  84. .filter(f => f.endsWith('.json'))
  85. .forEach(f => suiteFiles.push(join(suitesDir, f)));
  86. }
  87. // Legacy location: JSON files directly in config directory
  88. readdirSync(agentConfigDir)
  89. .filter(f => f.endsWith('.json') && f !== 'suite-schema.json')
  90. .forEach(f => {
  91. const filePath = join(agentConfigDir, f);
  92. if (!suiteFiles.includes(filePath)) {
  93. suiteFiles.push(filePath);
  94. }
  95. });
  96. return suiteFiles;
  97. }
  98. /**
  99. * Fail-closed exit-code rule.
  100. *
  101. * Returns nonzero when NO suites were discovered (`totalSuites === 0`) OR when
  102. * any discovered suite is invalid (`invalidSuites > 0`). Returns 0 only when at
  103. * least one suite was discovered and every one of them validated.
  104. */
  105. export function computeExitCode(stats: Pick<ValidationStats, 'totalSuites' | 'invalidSuites'>): number {
  106. if (stats.totalSuites === 0) return 1;
  107. if (stats.invalidSuites > 0) return 1;
  108. return 0;
  109. }
  110. function validateSuite(agent: string, suitePath: string, agentsDir: string): boolean {
  111. const suiteName = suitePath.split('/').pop()?.replace('.json', '') || 'unknown';
  112. console.log(`${colors.blue}Validating:${colors.reset} ${agent}/${suiteName}`);
  113. const validator = new SuiteValidator(agentsDir);
  114. const result = validator.validateSuiteFile(agent, suitePath);
  115. if (result.valid) {
  116. const testCount = result.suite?.tests.length || 0;
  117. console.log(` ${colors.green}✅ Valid${colors.reset} (${testCount} tests)`);
  118. if (result.warnings.length > 0) {
  119. result.warnings.forEach(warn => {
  120. console.log(` ${colors.yellow}⚠️ ${warn}${colors.reset}`);
  121. });
  122. }
  123. } else {
  124. console.log(` ${colors.red}❌ Invalid${colors.reset} (${result.errors.length} errors, ${result.warnings.length} warnings)`);
  125. result.errors.forEach(err => {
  126. console.log(` ${colors.red}Error:${colors.reset} ${err.field}: ${err.message}`);
  127. if (err.value) {
  128. console.log(` Value: ${err.value}`);
  129. }
  130. });
  131. if (result.missingTests.length > 0) {
  132. console.log(` ${colors.red}Missing test files (${result.missingTests.length}):${colors.reset}`);
  133. result.missingTests.forEach(path => {
  134. console.log(` - ${path}`);
  135. });
  136. }
  137. }
  138. console.log();
  139. return result.valid;
  140. }
  141. function main() {
  142. const args = process.argv.slice(2);
  143. const validateAll = args.includes('--all');
  144. const agent = validateAll ? null : (args[0] || 'openagent');
  145. console.log(`${colors.blue}🔍 Validating Test Suites${colors.reset}\n`);
  146. const projectRoot = join(__dirname, '../../../..');
  147. const agentsDir = join(projectRoot, 'evals', 'agents');
  148. const stats: ValidationStats = {
  149. totalSuites: 0,
  150. validSuites: 0,
  151. invalidSuites: 0,
  152. totalErrors: 0,
  153. totalWarnings: 0
  154. };
  155. const agentsToValidate = validateAll
  156. ? discoverAgents(agentsDir)
  157. : [agent!];
  158. if (validateAll && agentsToValidate.length === 0) {
  159. console.log(`${colors.yellow}⚠️ No agents with a config directory found under: ${agentsDir}${colors.reset}\n`);
  160. }
  161. for (const agentName of agentsToValidate) {
  162. const agentConfigDir = join(agentsDir, agentName, 'config');
  163. if (!existsSync(agentConfigDir)) {
  164. console.log(`${colors.yellow}⚠️ No config directory for agent: ${agentName}${colors.reset}\n`);
  165. continue;
  166. }
  167. const suiteFiles = collectSuiteFiles(agentsDir, agentName);
  168. if (suiteFiles.length === 0) {
  169. console.log(`${colors.yellow}⚠️ No test suites found for agent: ${agentName}${colors.reset}\n`);
  170. continue;
  171. }
  172. // Validate each suite
  173. for (const suiteFile of suiteFiles) {
  174. stats.totalSuites++;
  175. const isValid = validateSuite(agentName, suiteFile, agentsDir);
  176. if (isValid) {
  177. stats.validSuites++;
  178. } else {
  179. stats.invalidSuites++;
  180. }
  181. }
  182. }
  183. // Print summary
  184. console.log(`${colors.blue}${'='.repeat(55)}${colors.reset}`);
  185. console.log(`${colors.blue}Summary${colors.reset}`);
  186. console.log(`${colors.blue}${'='.repeat(55)}${colors.reset}`);
  187. console.log(`Total suites: ${stats.totalSuites}`);
  188. console.log(`${colors.green}Valid suites: ${stats.validSuites}${colors.reset}`);
  189. if (stats.invalidSuites > 0) {
  190. console.log(`${colors.red}Invalid suites: ${stats.invalidSuites}${colors.reset}`);
  191. }
  192. console.log();
  193. const exitCode = computeExitCode(stats);
  194. if (exitCode !== 0) {
  195. if (stats.totalSuites === 0) {
  196. console.log(`${colors.red}❌ Validation failed: no test suites were discovered${colors.reset}`);
  197. } else {
  198. console.log(`${colors.red}❌ Validation failed${colors.reset}`);
  199. }
  200. process.exit(exitCode);
  201. } else {
  202. console.log(`${colors.green}✅ All suites valid${colors.reset}`);
  203. process.exit(exitCode);
  204. }
  205. }
  206. // ESM entrypoint guard: only run the CLI when this module is executed directly
  207. // (e.g. via `tsx src/sdk/validate-suites-cli.ts`). Importing it in tests must
  208. // NOT trigger main()/process.exit().
  209. if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  210. main();
  211. }