diff --git a/package.json b/package.json index ed427489..1e926720 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,12 @@ "release:minor": "gh workflow run \"Manual Release\" -f version_bump=minor", "release:patch": "gh workflow run \"Manual Release\" -f version_bump=patch", "release:watch": "gh run watch", - "test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:schemas && npm run test:modules && npm run test:install && npm run validate:schemas && npm run validate:modules && npm run lint && npm run lint:md && npm run format:check", "test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas", "test:install": "node test/test-installation-components.js", + "test:modules": "node test/test-module-schema.js", "test:schemas": "node test/test-agent-schema.js", + "validate:modules": "node tools/validate-module-schema.js", "validate:schemas": "node tools/validate-agent-schema.js" }, "lint-staged": { diff --git a/test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml b/test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml new file mode 100644 index 00000000..622e24e6 --- /dev/null +++ b/test/fixtures/module-schema/invalid/code-format/number-start-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Code field starts with a number +# Expected: FAIL +# Error code: invalid_string +# Error path: code + +code: 123-module +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml b/test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml new file mode 100644 index 00000000..73cd56cb --- /dev/null +++ b/test/fixtures/module-schema/invalid/code-format/placeholder-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Code field contains placeholder text (the main bug we're fixing) +# Expected: FAIL +# Error code: invalid_string +# Error path: code + +code: "{module_code}" +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/code-format/too-short-code.module.yaml b/test/fixtures/module-schema/invalid/code-format/too-short-code.module.yaml new file mode 100644 index 00000000..c40a7551 --- /dev/null +++ b/test/fixtures/module-schema/invalid/code-format/too-short-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Code field too short (minimum 2 characters) +# Expected: FAIL +# Error code: invalid_string +# Error path: code + +code: x +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/code-format/underscore-code.module.yaml b/test/fixtures/module-schema/invalid/code-format/underscore-code.module.yaml new file mode 100644 index 00000000..0938e074 --- /dev/null +++ b/test/fixtures/module-schema/invalid/code-format/underscore-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Code field with underscores (should be kebab-case) +# Expected: FAIL +# Error code: invalid_string +# Error path: code + +code: test_module +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/code-format/uppercase-code.module.yaml b/test/fixtures/module-schema/invalid/code-format/uppercase-code.module.yaml new file mode 100644 index 00000000..23cafab3 --- /dev/null +++ b/test/fixtures/module-schema/invalid/code-format/uppercase-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Code field with uppercase letters +# Expected: FAIL +# Error code: invalid_string +# Error path: code + +code: TestModule +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml b/test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml new file mode 100644 index 00000000..f0fabfe3 --- /dev/null +++ b/test/fixtures/module-schema/invalid/required-fields/missing-code.module.yaml @@ -0,0 +1,11 @@ +# Test: Missing required code field +# Expected: FAIL +# Error code: invalid_type +# Error path: code +# Error message: Required + +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml b/test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml new file mode 100644 index 00000000..2e712371 --- /dev/null +++ b/test/fixtures/module-schema/invalid/required-fields/missing-header.module.yaml @@ -0,0 +1,11 @@ +# Test: Missing required header field +# Expected: FAIL +# Error code: invalid_type +# Error path: header +# Error message: Required + +code: test-module +name: Test Module +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml b/test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml new file mode 100644 index 00000000..3b36b5fa --- /dev/null +++ b/test/fixtures/module-schema/invalid/required-fields/missing-name.module.yaml @@ -0,0 +1,11 @@ +# Test: Missing required name field +# Expected: FAIL +# Error code: invalid_type +# Error path: name +# Error message: Required + +code: test-module +header: Test Header +subheader: Test Subheader +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml b/test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml new file mode 100644 index 00000000..6ad37d21 --- /dev/null +++ b/test/fixtures/module-schema/invalid/required-fields/missing-subheader.module.yaml @@ -0,0 +1,11 @@ +# Test: Missing required subheader field +# Expected: FAIL +# Error code: invalid_type +# Error path: subheader +# Error message: Required + +code: test-module +name: Test Module +header: Test Header +default_selected: false + diff --git a/test/fixtures/module-schema/invalid/variables/empty-prompt.module.yaml b/test/fixtures/module-schema/invalid/variables/empty-prompt.module.yaml new file mode 100644 index 00000000..97472867 --- /dev/null +++ b/test/fixtures/module-schema/invalid/variables/empty-prompt.module.yaml @@ -0,0 +1,15 @@ +# Test: Variable with empty prompt +# Expected: FAIL +# Error code: custom +# Error path: my_variable +# Error message: my_variable.prompt must be a non-empty string + +code: test-module +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + +my_variable: + prompt: " " + diff --git a/test/fixtures/module-schema/invalid/variables/missing-prompt.module.yaml b/test/fixtures/module-schema/invalid/variables/missing-prompt.module.yaml new file mode 100644 index 00000000..fd4e6411 --- /dev/null +++ b/test/fixtures/module-schema/invalid/variables/missing-prompt.module.yaml @@ -0,0 +1,16 @@ +# Test: Variable without prompt or inherit +# Expected: FAIL +# Error code: custom +# Error path: my_variable +# Error message: my_variable must have a 'prompt' or 'inherit' field + +code: test-module +name: Test Module +header: Test Header +subheader: Test Subheader +default_selected: false + +my_variable: + required: true + result: some result + diff --git a/test/fixtures/module-schema/valid/minimal/minimal-module.module.yaml b/test/fixtures/module-schema/valid/minimal/minimal-module.module.yaml new file mode 100644 index 00000000..0115c7ae --- /dev/null +++ b/test/fixtures/module-schema/valid/minimal/minimal-module.module.yaml @@ -0,0 +1,9 @@ +# Test: Valid module with only required fields +# Expected: PASS + +code: my-module +name: My Module Name +header: Module Header +subheader: Short description of what this module does +default_selected: false + diff --git a/test/fixtures/module-schema/valid/minimal/no-default-selected.module.yaml b/test/fixtures/module-schema/valid/minimal/no-default-selected.module.yaml new file mode 100644 index 00000000..eaddbe03 --- /dev/null +++ b/test/fixtures/module-schema/valid/minimal/no-default-selected.module.yaml @@ -0,0 +1,8 @@ +# Test: Valid module without default_selected (optional for core module) +# Expected: PASS + +code: core-like +name: Core Module +header: Core Header +subheader: Core modules don't need default_selected + diff --git a/test/fixtures/module-schema/valid/minimal/short-code.module.yaml b/test/fixtures/module-schema/valid/minimal/short-code.module.yaml new file mode 100644 index 00000000..bbc321f9 --- /dev/null +++ b/test/fixtures/module-schema/valid/minimal/short-code.module.yaml @@ -0,0 +1,9 @@ +# Test: Valid module with minimum 2-character code +# Expected: PASS + +code: ab +name: Two Letter Module +header: Short Code Header +subheader: The shortest valid code is 2 characters +default_selected: true + diff --git a/test/fixtures/module-schema/valid/variables/with-inherit.module.yaml b/test/fixtures/module-schema/valid/variables/with-inherit.module.yaml new file mode 100644 index 00000000..0fc91e7b --- /dev/null +++ b/test/fixtures/module-schema/valid/variables/with-inherit.module.yaml @@ -0,0 +1,12 @@ +# Test: Valid module with inherit variable +# Expected: PASS + +code: inherit-mod +name: Inherit Module +header: Module With Inherited Variables +subheader: Uses inherit instead of prompt +default_selected: false + +inherited_var: + inherit: other-module.some_var + diff --git a/test/fixtures/module-schema/valid/variables/with-prompt.module.yaml b/test/fixtures/module-schema/valid/variables/with-prompt.module.yaml new file mode 100644 index 00000000..ba4ffc78 --- /dev/null +++ b/test/fixtures/module-schema/valid/variables/with-prompt.module.yaml @@ -0,0 +1,14 @@ +# Test: Valid module with variable definition (prompt style) +# Expected: PASS + +code: var-module +name: Variable Module +header: Module With Variables +subheader: Demonstrates variable definitions +default_selected: false + +project_name: + prompt: What is your project name? + required: true + result: The project name is {project_name} + diff --git a/test/fixtures/module-schema/valid/variables/with-select.module.yaml b/test/fixtures/module-schema/valid/variables/with-select.module.yaml new file mode 100644 index 00000000..81b5ff6e --- /dev/null +++ b/test/fixtures/module-schema/valid/variables/with-select.module.yaml @@ -0,0 +1,31 @@ +# Test: Valid module with single-select and multi-select +# Expected: PASS + +code: select-mod +name: Select Module +header: Module With Selects +subheader: Demonstrates single-select and multi-select +default_selected: false + +environment: + prompt: Select your target environment + single-select: + - value: dev + label: Development + - value: staging + label: Staging + - value: prod + label: Production + +features: + prompt: + - What features do you want? + - You can select multiple options. + multi-select: + - value: auth + label: Authentication + - value: api + label: REST API + - value: db + label: Database + diff --git a/test/test-module-schema.js b/test/test-module-schema.js new file mode 100644 index 00000000..17402968 --- /dev/null +++ b/test/test-module-schema.js @@ -0,0 +1,339 @@ +/** + * Module 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-module-schema.js + * Exit codes: 0 = all tests pass, 1 = test failures + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('yaml'); +const { validateModuleFile } = require('../tools/schema/module.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}} + */ +function parseTestMetadata(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + + let shouldPass = true; + 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, '')); + } + } + + return { + shouldPass, + errorExpectation: Object.keys(errorExpectation).length > 0 ? errorExpectation : null, + }; +} + +/** + * Convert dot-notation path string to array (handles array indices) + * e.g., "module.dependencies[0]" => ["module", "dependencies", 0] + */ +function parsePathString(pathString) { + return pathString + .replaceAll(/\[(\d+)\]/g, '.$1') + .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) { + if (expectation.code && error.code !== expectation.code) { + return { valid: false, reason: `Expected code "${expectation.code}", got "${error.code}"` }; + } + + 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)}`, + }; + } + } + + if (expectation.code === 'custom' && expectation.message && error.message !== expectation.message) { + return { + valid: false, + reason: `Expected message "${expectation.message}", got "${error.message}"`, + }; + } + + 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 } = metadata; + + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + let moduleData; + + try { + moduleData = yaml.parse(fileContent); + } catch (parseError) { + if (shouldPass) { + return { + passed: false, + message: `Expected PASS but got YAML parse error: ${parseError.message}`, + }; + } + return { + passed: true, + message: 'Got expected YAML parse error', + }; + } + + const result = validateModuleFile(filePath, moduleData); + + if (result.success && shouldPass) { + return { + passed: true, + message: 'Validation passed as expected', + }; + } + + if (!result.success && !shouldPass) { + const actualError = result.error.issues[0]; + + 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}`, + }; + } + + 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}║ Module Schema Validation Test Suite ║${colors.reset}`); + console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}\n`); + + const testFiles = await glob('test/fixtures/module-schema/**/*.module.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/module-schema'), testFile); + const parts = relativePath.split(path.sep); + const validInvalid = parts[0]; // 'valid' or 'invalid' + const category = parts[1] || 'general'; + + 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, '.module.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`); + + 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); +}); diff --git a/tools/schema/module.js b/tools/schema/module.js new file mode 100644 index 00000000..5810c417 --- /dev/null +++ b/tools/schema/module.js @@ -0,0 +1,188 @@ +// Zod schema definition for module.yaml files +const { z } = require('zod'); + +// Pattern for module code: kebab-case, 2-20 characters, starts with letter +const MODULE_CODE_PATTERN = /^[a-z][a-z0-9-]{1,19}$/; + +// Public API --------------------------------------------------------------- + +/** + * Validate a module YAML payload against the schema. + * + * @param {string} filePath Path to the module file (for consistency with other validators). + * @param {unknown} moduleYaml Parsed YAML content. + * @returns {import('zod').SafeParseReturnType} SafeParse result. + */ +function validateModuleFile(filePath, moduleYaml) { + const schema = moduleSchema(); + return schema.safeParse(moduleYaml); +} + +module.exports = { validateModuleFile }; + +// Internal helpers --------------------------------------------------------- + +/** + * Build the Zod schema for validating a module.yaml file. + * @returns {import('zod').ZodSchema} Configured Zod schema instance. + */ +function moduleSchema() { + return z + .object({ + // Required fields + code: z.string().regex(MODULE_CODE_PATTERN, { + message: 'module.code must be kebab-case, 2-20 characters, starting with a letter', + }), + name: createNonEmptyString('module.name'), + header: createNonEmptyString('module.header'), + subheader: createNonEmptyString('module.subheader'), + // default_selected is optional for core module, required for others + // Core module doesn't need this as it's always included + default_selected: z.boolean().optional(), + + // Optional fields + type: createNonEmptyString('module.type').optional(), + global: z.boolean().optional(), + }) + .strict() + .passthrough() + .superRefine((value, ctx) => { + // Validate any additional keys as variable definitions + const reservedKeys = new Set(['code', 'name', 'header', 'subheader', 'default_selected', 'type', 'global']); + + for (const key of Object.keys(value)) { + if (reservedKeys.has(key)) { + continue; + } + + const variableValue = value[key]; + + // Skip if it's a comment (starts with #) or null/undefined + if (variableValue === null || variableValue === undefined) { + continue; + } + + // Validate variable definition + const variableResult = validateVariableDefinition(key, variableValue); + if (!variableResult.valid) { + ctx.addIssue({ + code: 'custom', + path: [key], + message: variableResult.error, + }); + } + } + }); +} + +/** + * Validate a variable definition object. + * @param {string} variableName The name of the variable. + * @param {unknown} variableValue The variable definition value. + * @returns {{ valid: boolean, error?: string }} + */ +function validateVariableDefinition(variableName, variableValue) { + // If it's not an object, it's invalid + if (typeof variableValue !== 'object' || variableValue === null) { + return { valid: false, error: `${variableName} must be an object with variable definition properties` }; + } + + // Check for inherit alias - if present, it's the only required field + if ('inherit' in variableValue) { + if (typeof variableValue.inherit !== 'string' || variableValue.inherit.trim().length === 0) { + return { valid: false, error: `${variableName}.inherit must be a non-empty string` }; + } + return { valid: true }; + } + + // Otherwise, prompt is required + if (!('prompt' in variableValue)) { + return { valid: false, error: `${variableName} must have a 'prompt' or 'inherit' field` }; + } + + // Validate prompt: string or array of strings + const prompt = variableValue.prompt; + if (typeof prompt === 'string') { + if (prompt.trim().length === 0) { + return { valid: false, error: `${variableName}.prompt must be a non-empty string` }; + } + } else if (Array.isArray(prompt)) { + if (prompt.length === 0) { + return { valid: false, error: `${variableName}.prompt array must not be empty` }; + } + for (const [index, promptItem] of prompt.entries()) { + if (typeof promptItem !== 'string' || promptItem.trim().length === 0) { + return { valid: false, error: `${variableName}.prompt[${index}] must be a non-empty string` }; + } + } + } else { + return { valid: false, error: `${variableName}.prompt must be a string or array of strings` }; + } + + // Validate optional single-select + if ('single-select' in variableValue) { + const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']); + if (!selectResult.valid) { + return selectResult; + } + } + + // Validate optional multi-select + if ('multi-select' in variableValue) { + const selectResult = validateSelectOptions(variableName, 'multi-select', variableValue['multi-select']); + if (!selectResult.valid) { + return selectResult; + } + } + + // Validate optional required field + if ('required' in variableValue && typeof variableValue.required !== 'boolean') { + return { valid: false, error: `${variableName}.required must be a boolean` }; + } + + // Validate optional result field + if ('result' in variableValue && (typeof variableValue.result !== 'string' || variableValue.result.trim().length === 0)) { + return { valid: false, error: `${variableName}.result must be a non-empty string` }; + } + + return { valid: true }; +} + +/** + * Validate single-select or multi-select options array. + * @param {string} variableName The variable name for error messages. + * @param {string} selectType Either 'single-select' or 'multi-select'. + * @param {unknown} options The options array to validate. + * @returns {{ valid: boolean, error?: string }} + */ +function validateSelectOptions(variableName, selectType, options) { + if (!Array.isArray(options)) { + return { valid: false, error: `${variableName}.${selectType} must be an array` }; + } + + if (options.length === 0) { + return { valid: false, error: `${variableName}.${selectType} must not be empty` }; + } + + for (const [index, option] of options.entries()) { + if (typeof option !== 'object' || option === null) { + return { valid: false, error: `${variableName}.${selectType}[${index}] must be an object` }; + } + if (!('value' in option) || typeof option.value !== 'string') { + return { valid: false, error: `${variableName}.${selectType}[${index}].value must be a string` }; + } + if (!('label' in option) || typeof option.label !== 'string') { + return { valid: false, error: `${variableName}.${selectType}[${index}].label must be a string` }; + } + } + + return { valid: true }; +} + +// Primitive validators ----------------------------------------------------- + +function createNonEmptyString(label) { + return z.string().refine((value) => value.trim().length > 0, { + message: `${label} must be a non-empty string`, + }); +} diff --git a/tools/validate-module-schema.js b/tools/validate-module-schema.js new file mode 100644 index 00000000..17dd7b40 --- /dev/null +++ b/tools/validate-module-schema.js @@ -0,0 +1,110 @@ +/** + * Module Schema Validator CLI + * + * Scans all module.yaml files in src/core/ and src/modules/ + * and validates them against the Zod schema. + * + * Usage: node tools/validate-module-schema.js [project_root] + * Exit codes: 0 = success, 1 = validation failures + * + * Optional argument: + * project_root - Directory to scan (defaults to BMAD repo root) + */ + +const { glob } = require('glob'); +const yaml = require('yaml'); +const fs = require('node:fs'); +const path = require('node:path'); +const { validateModuleFile } = require('./schema/module.js'); + +/** + * Main validation routine + * @param {string} [customProjectRoot] - Optional project root to scan (for testing) + */ +async function main(customProjectRoot) { + console.log('🔍 Scanning for module files...\n'); + + // Determine project root: use custom path if provided, otherwise default to repo root + const project_root = customProjectRoot || path.join(__dirname, '..'); + + // Find all module files: core/module.yaml and bmm/module.yaml (and any other top-level modules) + const moduleFiles = await glob('src/{core,bmm}/module.yaml', { + cwd: project_root, + absolute: true, + }); + + if (moduleFiles.length === 0) { + console.log('❌ No module files found. This likely indicates a configuration error.'); + console.log(' Expected to find module.yaml files in src/core/ and src/modules/*/'); + process.exit(1); + } + + console.log(`Found ${moduleFiles.length} module file(s)\n`); + + const errors = []; + + // Validate each file + for (const filePath of moduleFiles) { + const relativePath = path.relative(process.cwd(), filePath); + + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const moduleData = yaml.parse(fileContent); + + // Convert absolute path to relative src/ path for context + const srcRelativePath = relativePath.startsWith('src/') ? relativePath : path.relative(project_root, filePath).replaceAll('\\', '/'); + + const result = validateModuleFile(srcRelativePath, moduleData); + + if (result.success) { + console.log(`✅ ${relativePath}`); + } else { + errors.push({ + file: relativePath, + issues: result.error.issues, + }); + } + } catch (error) { + errors.push({ + file: relativePath, + issues: [ + { + code: 'parse_error', + message: `Failed to parse YAML: ${error.message}`, + path: [], + }, + ], + }); + } + } + + // Report errors + if (errors.length > 0) { + console.log('\n❌ Validation failed for the following files:\n'); + + for (const { file, issues } of errors) { + console.log(`\n📄 ${file}`); + for (const issue of issues) { + const pathString = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + console.log(` Path: ${pathString}`); + console.log(` Error: ${issue.message}`); + if (issue.code) { + console.log(` Code: ${issue.code}`); + } + } + } + + console.log(`\n\n💥 ${errors.length} file(s) failed validation`); + process.exit(1); + } + + console.log(`\n✨ All ${moduleFiles.length} module file(s) passed validation!\n`); + process.exit(0); +} + +// Run with optional command-line argument for project root +const customProjectRoot = process.argv[2]; +main(customProjectRoot).catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +});