Compare commits

...

6 Commits

Author SHA1 Message Date
Noam Brendel a95b3765f3
Merge 0c660aa0e9 into 7d3d51ff4f 2026-01-27 10:19:05 +01:00
The Chef 7d3d51ff4f
docs: fix typos in README and customization guide (#1424)
- Remove duplicate "for" in README example command
- Fix "this is" to "thing is" in README
- Remove extra "at" in "star project icon at near" in README
- Fix "MEthod" to "Method" in customize-bmad.md
2026-01-26 23:25:08 -08:00
Noam Brendel 0c660aa0e9 fix: add missing header and subheader fields to module.yaml files
The new module schema validation requires header and subheader fields.
Updated bmm and bmb modules to comply with the schema by:
- Renaming 'description' to 'header'
- Adding 'subheader' field with configuration context
2026-01-26 15:42:08 +02:00
cx-noam-brendel b46e20dc3a fix: exit with code 1 when no test fixtures found
Per CodeRabbit review - quality gate scripts should fail on unexpected states
2026-01-26 15:41:45 +02:00
cx-noam-brendel 6bf6a2b6d2 fix: address PR review comments
Changes per reviewer feedback:
- Remove ⚠️ CRITICAL warnings from documentation (per @alexeyv)
- Replace placeholder YAML examples with concrete values
- Simplify validation checklist text

Technical fixes per CodeRabbit:
- Remove .strict() that contradicted .passthrough()
- Add isCoreModule parameter for conditional default_selected validation
- Enforce mutual exclusivity: inherit/prompt and single-select/multi-select
- Use project_root for consistent path reporting
- Harden test runner with guarded access for error.issues
- Move parseTestMetadata inside try/catch block

All tests pass (52 agent + 17 module + 13 installation)
2026-01-26 15:41:45 +02:00
cx-noam-brendel 6dc292c850 fix(bmb): add module.yaml schema validation and fix #1288
- Add Zod schema validation for module.yaml files
- Create test suite for module schema with 17 test fixtures
- Fix create-module workflow to make 'code' field requirements clearer

Fixes #1288 - The create-module workflow could produce module.yaml files
without the required 'code' field or with placeholder text like '{module_code}'.

1. Created tools/schema/module.js with Zod validation for:
   - Required fields: code, name, header, subheader
   - Code format: kebab-case, 2-20 chars, starts with letter
   - Variable definitions with prompt/inherit patterns

2. Created tools/validate-module-schema.js CLI validator

3. Added npm scripts:
   - validate:modules - validates all module.yaml files
   - test:modules - runs module schema test suite

4. Updated BMB workflow instructions with explicit warnings about
   required fields in step-03-config.md and step-03-module-yaml.md

5. Added 17 test fixtures covering:
   - Missing required fields (code, name, header, subheader)
   - Invalid code formats (placeholder, uppercase, underscore, etc.)
   - Variable definitions (prompt, inherit, single-select, multi-select)

- npm run test:modules - 17/17 tests pass
- npm run validate:modules - All 5 existing modules pass
- npm test - Full test suite passes
2026-01-26 15:41:22 +02:00
24 changed files with 891 additions and 6 deletions

View File

@ -32,10 +32,10 @@ Follow the installer prompts, then open your AI IDE (Claude Code, Cursor, Windsu
> **Not sure what to do?** Run `/bmad-help` — it tells you exactly what's next and what's optional. You can also ask it questions like:
- `/bmad-help How should I build a web app for for my TShirt Business that can scale to millions?`
- `/bmad-help How should I build a web app for my TShirt Business that can scale to millions?`
- `/bmad-help I just finished the architecture, I am not sure what to do next`
And the amazing this is BMad Help evolves depending on what modules you install also!
And the amazing thing is BMad Help evolves depending on what modules you install also!
- `/bmad-help Im interested in really exploring creative ways to demo BMad at work, what do you recommend to help plan a great slide deck and compelling narrative?`, and if you have the Creative Intelligence Suite installed, it will offer you different or complimentary advice than if you just have BMad Method Module installed!
The workflows below show the fastest path to working code. You can also load agents directly for a more structured process, extensive planning, or to learn about agile development practices — the agents guide you with menus, explanations, and elicitation at each step.
@ -96,7 +96,7 @@ BMad Method extends with official modules for specialized domains. Modules are a
BMad is free for everyone — and always will be. If you'd like to support development:
- ⭐ Please click the star project icon at near the top right of this page
- ⭐ Please click the star project icon near the top right of this page
- ☕ [Buy Me a Coffee](https://buymeacoffee.com/bmad) — Fuel the development
- 🏢 Corporate sponsorship — DM on Discord
- 🎤 Speaking & Media — Available for conferences, podcasts, interviews (BM on Discord)

View File

@ -151,7 +151,7 @@ prompts:
## Workflow Customization
Information about customizing existing BMad MEthod workflows and skills are coming soon.
Information about customizing existing BMad Method workflows and skills are coming soon.
## Module Customization

View File

@ -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": {

View File

@ -1,6 +1,7 @@
code: bmm
name: "BMad Method Agile-AI Driven-Development"
description: "AI-driven agile development framework"
header: "AI-driven agile development framework"
subheader: "Configure the BMad Method module for AI-powered agile project development"
default_selected: true # This module will be selected by default for new installations
# Variables from Core Config inserted:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: " "

View File

@ -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

View File

@ -0,0 +1,9 @@
# Test: Valid module with default_selected set to true
# Expected: PASS
code: core-like
name: Core Module
header: Core Header
subheader: Module with default_selected true
default_selected: true

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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

345
test/test-module-schema.js Normal file
View File

@ -0,0 +1,345 @@
/**
* 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) {
try {
const metadata = parseTestMetadata(filePath);
const { shouldPass, errorExpectation } = metadata;
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 (!actualError) {
return {
passed: false,
message: 'Expected validation error issues, but validator returned none',
};
}
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 ?? 'Unknown error'}`,
};
}
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(1);
}
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);
});

213
tools/schema/module.js Normal file
View File

@ -0,0 +1,213 @@
// 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 (used to detect core vs non-core modules).
* @param {unknown} moduleYaml Parsed YAML content.
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
*/
function validateModuleFile(filePath, moduleYaml) {
const isCoreModule = typeof filePath === 'string' && filePath.replaceAll('\\', '/').includes('src/core/');
const schema = moduleSchema({ isCoreModule });
return schema.safeParse(moduleYaml);
}
module.exports = { validateModuleFile };
// Internal helpers ---------------------------------------------------------
/**
* Build the Zod schema for validating a module.yaml file.
* @param {{isCoreModule?: boolean}} options - Options for schema validation.
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
*/
function moduleSchema(options) {
const { isCoreModule = false } = options ?? {};
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 non-core modules
default_selected: z.boolean().optional(),
// Optional fields
type: createNonEmptyString('module.type').optional(),
global: z.boolean().optional(),
})
.passthrough()
.superRefine((value, ctx) => {
// Enforce default_selected for non-core modules
if (!isCoreModule && !('default_selected' in value)) {
ctx.addIssue({
code: 'custom',
path: ['default_selected'],
message: 'module.default_selected is required for non-core modules',
});
}
// 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 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` };
}
const hasInherit = 'inherit' in variableValue;
const hasPrompt = 'prompt' in variableValue;
// Enforce mutual exclusivity: inherit and prompt cannot coexist
if (hasInherit && hasPrompt) {
return { valid: false, error: `${variableName} must not define both 'inherit' and 'prompt'` };
}
// Check for inherit alias - if present, it's the only required field
if (hasInherit) {
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 (!hasPrompt) {
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` };
}
// Enforce mutual exclusivity: single-select and multi-select cannot coexist
const hasSingle = 'single-select' in variableValue;
const hasMulti = 'multi-select' in variableValue;
if (hasSingle && hasMulti) {
return { valid: false, error: `${variableName} must not define both 'single-select' and 'multi-select'` };
}
// Validate optional single-select
if (hasSingle) {
const selectResult = validateSelectOptions(variableName, 'single-select', variableValue['single-select']);
if (!selectResult.valid) {
return selectResult;
}
}
// Validate optional multi-select
if (hasMulti) {
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`,
});
}

View File

@ -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(project_root, filePath).replaceAll('\\', '/');
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const moduleData = yaml.parse(fileContent);
// Ensure path starts with src/ for core module detection
const srcRelativePath = relativePath.startsWith('src/') ? relativePath : `src/${relativePath}`;
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);
});