Introduce automated validation for agent YAML files using Zod to ensure
schema compliance across all agent definitions. This feature validates
17 agent files across core and module directories, catching structural
errors and maintaining consistency.
Schema Validation (tools/schema/agent.js):
- Zod-based schema validating metadata, persona, menu, prompts, and critical actions
- Module-aware validation: module field required for src/modules/**/agents/,
optional for src/core/agents/
- Enforces kebab-case unique triggers and at least one command target per menu item
- Validates persona.principles as array (not string)
- Comprehensive refinements for data integrity
CLI Validator (tools/validate-agent-schema.js):
- Scans src/{core,modules/*}/agents/*.agent.yaml
- Parses with js-yaml and validates using Zod schema
- Reports detailed errors with file paths and field paths
- Exits 1 on failures, 0 on success
- Accepts optional project_root parameter for testing
Testing (679 lines across 3 test files):
- test/test-cli-integration.sh: CLI behavior and error handling tests
- test/unit-test-schema.js: Direct schema validation unit tests
- test/test-agent-schema.js: Comprehensive fixture-based tests
- 50 test fixtures covering valid and invalid scenarios
- ESLint configured to support CommonJS test files
- Prettier configured to ignore intentionally broken fixtures
CI Integration (.github/workflows/lint.yaml):
- Renamed from format-check.yaml to lint.yaml
- Added schema-validation job running npm run validate:schemas
- Runs in parallel with prettier and eslint jobs
- Validates on all pull requests
Data Cleanup:
- Fixed src/core/agents/bmad-master.agent.yaml: converted persona.principles
from string to array format
Documentation:
- Updated schema-classification.md with validation section
- Documents validator usage, enforcement rules, and CI integration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
/**
|
|
* Agent Schema Validation Test Runner
|
|
*
|
|
* Runs all test fixtures and verifies expected outcomes.
|
|
* Reports pass/fail for each test and overall coverage statistics.
|
|
*
|
|
* Usage: node test/test-agent-schema.js
|
|
* Exit codes: 0 = all tests pass, 1 = test failures
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const yaml = require('js-yaml');
|
|
const { validateAgentFile } = require('../tools/schema/agent.js');
|
|
const { glob } = require('glob');
|
|
|
|
// ANSI color codes
|
|
const colors = {
|
|
reset: '\u001B[0m',
|
|
green: '\u001B[32m',
|
|
red: '\u001B[31m',
|
|
yellow: '\u001B[33m',
|
|
blue: '\u001B[34m',
|
|
cyan: '\u001B[36m',
|
|
dim: '\u001B[2m',
|
|
};
|
|
|
|
/**
|
|
* Parse test metadata from YAML comments
|
|
* @param {string} filePath
|
|
* @returns {{shouldPass: boolean, errorExpectation?: object, pathContext?: string}}
|
|
*/
|
|
function parseTestMetadata(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split('\n');
|
|
|
|
let shouldPass = true;
|
|
let pathContext = null;
|
|
const errorExpectation = {};
|
|
|
|
for (const line of lines) {
|
|
if (line.includes('Expected: PASS')) {
|
|
shouldPass = true;
|
|
} else if (line.includes('Expected: FAIL')) {
|
|
shouldPass = false;
|
|
}
|
|
|
|
// Parse error metadata
|
|
const codeMatch = line.match(/^# Error code: (.+)$/);
|
|
if (codeMatch) {
|
|
errorExpectation.code = codeMatch[1].trim();
|
|
}
|
|
|
|
const pathMatch = line.match(/^# Error path: (.+)$/);
|
|
if (pathMatch) {
|
|
errorExpectation.path = pathMatch[1].trim();
|
|
}
|
|
|
|
const messageMatch = line.match(/^# Error message: (.+)$/);
|
|
if (messageMatch) {
|
|
errorExpectation.message = messageMatch[1].trim();
|
|
}
|
|
|
|
const minimumMatch = line.match(/^# Error minimum: (\d+)$/);
|
|
if (minimumMatch) {
|
|
errorExpectation.minimum = parseInt(minimumMatch[1], 10);
|
|
}
|
|
|
|
const expectedMatch = line.match(/^# Error expected: (.+)$/);
|
|
if (expectedMatch) {
|
|
errorExpectation.expected = expectedMatch[1].trim();
|
|
}
|
|
|
|
const receivedMatch = line.match(/^# Error received: (.+)$/);
|
|
if (receivedMatch) {
|
|
errorExpectation.received = receivedMatch[1].trim();
|
|
}
|
|
|
|
const keysMatch = line.match(/^# Error keys: \[(.+)\]$/);
|
|
if (keysMatch) {
|
|
errorExpectation.keys = keysMatch[1].split(',').map((k) => k.trim().replaceAll(/['"]/g, ''));
|
|
}
|
|
|
|
const contextMatch = line.match(/^# Path context: (.+)$/);
|
|
if (contextMatch) {
|
|
pathContext = contextMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
return {
|
|
shouldPass,
|
|
errorExpectation: Object.keys(errorExpectation).length > 0 ? errorExpectation : null,
|
|
pathContext,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert dot-notation path string to array (handles array indices)
|
|
* e.g., "agent.menu[0].trigger" => ["agent", "menu", 0, "trigger"]
|
|
*/
|
|
function parsePathString(pathString) {
|
|
return pathString
|
|
.replaceAll(/\[(\d+)\]/g, '.$1') // Convert [0] to .0
|
|
.split('.')
|
|
.map((part) => {
|
|
const num = parseInt(part, 10);
|
|
return isNaN(num) ? part : num;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate error against expectations
|
|
* @param {object} error - Zod error issue
|
|
* @param {object} expectation - Expected error structure
|
|
* @returns {{valid: boolean, reason?: string}}
|
|
*/
|
|
function validateError(error, expectation) {
|
|
// Check error code
|
|
if (expectation.code && error.code !== expectation.code) {
|
|
return { valid: false, reason: `Expected code "${expectation.code}", got "${error.code}"` };
|
|
}
|
|
|
|
// Check error path
|
|
if (expectation.path) {
|
|
const expectedPath = parsePathString(expectation.path);
|
|
const actualPath = error.path;
|
|
|
|
if (JSON.stringify(expectedPath) !== JSON.stringify(actualPath)) {
|
|
return {
|
|
valid: false,
|
|
reason: `Expected path ${JSON.stringify(expectedPath)}, got ${JSON.stringify(actualPath)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// For custom errors, strictly check message
|
|
if (expectation.code === 'custom' && expectation.message && error.message !== expectation.message) {
|
|
return {
|
|
valid: false,
|
|
reason: `Expected message "${expectation.message}", got "${error.message}"`,
|
|
};
|
|
}
|
|
|
|
// For Zod errors, check type-specific fields
|
|
if (expectation.minimum !== undefined && error.minimum !== expectation.minimum) {
|
|
return { valid: false, reason: `Expected minimum ${expectation.minimum}, got ${error.minimum}` };
|
|
}
|
|
|
|
if (expectation.expected && error.expected !== expectation.expected) {
|
|
return { valid: false, reason: `Expected type "${expectation.expected}", got "${error.expected}"` };
|
|
}
|
|
|
|
if (expectation.received && error.received !== expectation.received) {
|
|
return { valid: false, reason: `Expected received "${expectation.received}", got "${error.received}"` };
|
|
}
|
|
|
|
if (expectation.keys) {
|
|
const expectedKeys = expectation.keys.sort();
|
|
const actualKeys = (error.keys || []).sort();
|
|
if (JSON.stringify(expectedKeys) !== JSON.stringify(actualKeys)) {
|
|
return {
|
|
valid: false,
|
|
reason: `Expected keys ${JSON.stringify(expectedKeys)}, got ${JSON.stringify(actualKeys)}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Run a single test case
|
|
* @param {string} filePath
|
|
* @returns {{passed: boolean, message: string}}
|
|
*/
|
|
function runTest(filePath) {
|
|
const metadata = parseTestMetadata(filePath);
|
|
const { shouldPass, errorExpectation, pathContext } = metadata;
|
|
|
|
try {
|
|
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
let agentData;
|
|
|
|
try {
|
|
agentData = yaml.load(fileContent);
|
|
} catch (parseError) {
|
|
// YAML parse error
|
|
if (shouldPass) {
|
|
return {
|
|
passed: false,
|
|
message: `Expected PASS but got YAML parse error: ${parseError.message}`,
|
|
};
|
|
}
|
|
return {
|
|
passed: true,
|
|
message: 'Got expected YAML parse error',
|
|
};
|
|
}
|
|
|
|
// Determine validation path
|
|
// If pathContext is specified in comments, use it; otherwise derive from fixture location
|
|
let validationPath = pathContext;
|
|
if (!validationPath) {
|
|
// Map fixture location to simulated src/ path
|
|
const relativePath = path.relative(path.join(__dirname, 'fixtures/agent-schema'), filePath);
|
|
const parts = relativePath.split(path.sep);
|
|
|
|
if (parts.includes('metadata') && parts[0] === 'valid') {
|
|
// Valid metadata tests: check if filename suggests module or core
|
|
const filename = path.basename(filePath);
|
|
if (filename.includes('module')) {
|
|
validationPath = 'src/modules/bmm/agents/test.agent.yaml';
|
|
} else {
|
|
validationPath = 'src/core/agents/test.agent.yaml';
|
|
}
|
|
} else if (parts.includes('metadata') && parts[0] === 'invalid') {
|
|
// Invalid metadata tests: derive from filename
|
|
const filename = path.basename(filePath);
|
|
if (filename.includes('module') || filename.includes('wrong-module')) {
|
|
validationPath = 'src/modules/bmm/agents/test.agent.yaml';
|
|
} else if (filename.includes('core')) {
|
|
validationPath = 'src/core/agents/test.agent.yaml';
|
|
} else {
|
|
validationPath = 'src/core/agents/test.agent.yaml';
|
|
}
|
|
} else {
|
|
// Default to core agent path
|
|
validationPath = 'src/core/agents/test.agent.yaml';
|
|
}
|
|
}
|
|
|
|
const result = validateAgentFile(validationPath, agentData);
|
|
|
|
if (result.success && shouldPass) {
|
|
return {
|
|
passed: true,
|
|
message: 'Validation passed as expected',
|
|
};
|
|
}
|
|
|
|
if (!result.success && !shouldPass) {
|
|
const actualError = result.error.issues[0];
|
|
|
|
// If we have error expectations, validate strictly
|
|
if (errorExpectation) {
|
|
const validation = validateError(actualError, errorExpectation);
|
|
|
|
if (!validation.valid) {
|
|
return {
|
|
passed: false,
|
|
message: `Error validation failed: ${validation.reason}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
passed: true,
|
|
message: `Got expected error (${errorExpectation.code}): ${actualError.message}`,
|
|
};
|
|
}
|
|
|
|
// No specific expectations - just check that it failed
|
|
return {
|
|
passed: true,
|
|
message: `Got expected validation error: ${actualError?.message}`,
|
|
};
|
|
}
|
|
|
|
if (result.success && !shouldPass) {
|
|
return {
|
|
passed: false,
|
|
message: 'Expected validation to FAIL but it PASSED',
|
|
};
|
|
}
|
|
|
|
if (!result.success && shouldPass) {
|
|
return {
|
|
passed: false,
|
|
message: `Expected validation to PASS but it FAILED: ${result.error.issues[0]?.message}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
passed: false,
|
|
message: 'Unexpected test state',
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
passed: false,
|
|
message: `Test execution error: ${error.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main test runner
|
|
*/
|
|
async function main() {
|
|
console.log(`${colors.cyan}╔═══════════════════════════════════════════════════════════╗${colors.reset}`);
|
|
console.log(`${colors.cyan}║ Agent Schema Validation Test Suite ║${colors.reset}`);
|
|
console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}\n`);
|
|
|
|
// Find all test fixtures
|
|
const testFiles = await glob('test/fixtures/agent-schema/**/*.agent.yaml', {
|
|
cwd: path.join(__dirname, '..'),
|
|
absolute: true,
|
|
});
|
|
|
|
if (testFiles.length === 0) {
|
|
console.log(`${colors.yellow}⚠️ No test fixtures found${colors.reset}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
console.log(`Found ${colors.cyan}${testFiles.length}${colors.reset} test fixture(s)\n`);
|
|
|
|
// Group tests by category
|
|
const categories = {};
|
|
for (const testFile of testFiles) {
|
|
const relativePath = path.relative(path.join(__dirname, 'fixtures/agent-schema'), testFile);
|
|
const parts = relativePath.split(path.sep);
|
|
const validInvalid = parts[0]; // 'valid' or 'invalid'
|
|
const category = parts[1]; // 'top-level', 'metadata', etc.
|
|
|
|
const categoryKey = `${validInvalid}/${category}`;
|
|
if (!categories[categoryKey]) {
|
|
categories[categoryKey] = [];
|
|
}
|
|
categories[categoryKey].push(testFile);
|
|
}
|
|
|
|
// Run tests by category
|
|
let totalTests = 0;
|
|
let passedTests = 0;
|
|
const failures = [];
|
|
|
|
for (const [categoryKey, files] of Object.entries(categories).sort()) {
|
|
const [validInvalid, category] = categoryKey.split('/');
|
|
const categoryLabel = category.replaceAll('-', ' ').toUpperCase();
|
|
const validLabel = validInvalid === 'valid' ? '✅' : '❌';
|
|
|
|
console.log(`${colors.blue}${validLabel} ${categoryLabel} (${validInvalid})${colors.reset}`);
|
|
|
|
for (const testFile of files) {
|
|
totalTests++;
|
|
const testName = path.basename(testFile, '.agent.yaml');
|
|
const result = runTest(testFile);
|
|
|
|
if (result.passed) {
|
|
passedTests++;
|
|
console.log(` ${colors.green}✓${colors.reset} ${testName} ${colors.dim}${result.message}${colors.reset}`);
|
|
} else {
|
|
console.log(` ${colors.red}✗${colors.reset} ${testName} ${colors.red}${result.message}${colors.reset}`);
|
|
failures.push({
|
|
file: path.relative(process.cwd(), testFile),
|
|
message: result.message,
|
|
});
|
|
}
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
// Summary
|
|
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}`);
|
|
console.log(`${colors.cyan}Test Results:${colors.reset}`);
|
|
console.log(` Total: ${totalTests}`);
|
|
console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`);
|
|
console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`);
|
|
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}\n`);
|
|
|
|
// Report failures
|
|
if (failures.length > 0) {
|
|
console.log(`${colors.red}❌ FAILED TESTS:${colors.reset}\n`);
|
|
for (const failure of failures) {
|
|
console.log(`${colors.red}✗${colors.reset} ${failure.file}`);
|
|
console.log(` ${failure.message}\n`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`${colors.green}✨ All tests passed!${colors.reset}\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Run
|
|
main().catch((error) => {
|
|
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
|
|
process.exit(1);
|
|
});
|