BMAD-METHOD/tools/schema/workflow.js

450 lines
14 KiB
JavaScript

// Zod schema definition for workflow.yaml files
const { z } = require('zod');
const KEBAB_CASE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// Public API ---------------------------------------------------------------
/**
* Validate a workflow YAML payload against the schema.
* Exposed as the single public entry point, so callers do not reach into schema internals.
*
* @param {string} filePath Path to the workflow file (for error reporting).
* @param {unknown} workflowYaml Parsed YAML content.
* @returns {object} Result with success, data/error, and warnings array
*/
function validateWorkflowFile(filePath, workflowYaml) {
const schema = workflowSchema();
const result = schema.safeParse(workflowYaml);
const warnings = collectUnknownFieldWarnings(workflowYaml);
return { ...result, warnings };
}
module.exports = { validateWorkflowFile };
// Canonical lists of known variable references (extracted from 65 real workflows)
// These are used to validate variable references and catch typos
const KNOWN_PATH_PLACEHOLDERS = [
'agent_filename',
'bmad_folder',
'custom_agent_location',
'custom_module_location',
'custom_workflow_location',
'date',
'epic_id',
'epic_num',
'game_name',
'installed_path',
'item_name',
'item_type',
'module_code',
'output_folder',
'path',
'prev_epic_num',
'project-root',
'project_name',
'reference_agents',
'research_type',
'shared_path',
'sprint_artifacts',
'status_file',
'story_dir',
'story_id',
'story_key',
'story_path',
'target_module',
'test_dir',
'timestamp',
'workflow_name',
'workflow_template_path',
];
const KNOWN_CONFIG_VARIABLES = [
'communication_language',
'custom_agent_location',
'custom_module_location',
'custom_workflow_location',
'document_output_language',
'game_dev_experience',
'output_folder',
'project_name',
'sprint_artifacts',
'user_name',
'user_skill_level',
];
const KNOWN_TEMPLATE_VARIABLES = [
'agent_filename',
'date',
'epic_id',
'epic_num',
'game_name',
'item_name',
'item_type',
'module_code',
'prev_epic_num',
'project_name',
'research_type',
'story_key',
'target_module',
'workflow_name',
];
// Internal helpers ---------------------------------------------------------
/**
* Build a Zod schema for validating a single workflow definition.
*
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
*/
function workflowSchema() {
return z
.object({
name: createKebabCaseString('name'),
description: createNonEmptyString('description'),
standalone: z.boolean().optional(),
template: z.union([z.literal(false), z.string()]).optional(),
instructions: createNonEmptyString('instructions'),
validation: z.string().optional(),
config_source: z.string().optional(),
installed_path: z.string().optional(),
output_folder: z.string().optional(),
user_name: z.string().optional(),
communication_language: z.string().optional(),
date: z.string().optional(),
brain_techniques: z.string().optional(),
validation_output_file: z.string().optional(),
required_tools: z.array(z.union([z.string(), z.object({ description: z.string().optional() }).catchall(z.unknown())])).optional(),
default_output_file: z.string().optional(),
autonomous: z.boolean().optional(),
web_bundle: z.union([z.literal(false), buildWebBundleSchema()]).optional(),
input_file_patterns: z.record(z.string(), buildInputFilePatternSchema()).optional(),
exit_triggers: z.array(createNonEmptyString('exit_triggers[]')).optional(),
// Common optional fields (often injected variables or context)
document_output_language: z.string().optional(),
user_skill_level: z.string().optional(),
sprint_artifacts: z.string().optional(),
sprint_status: z.string().optional(),
game_dev_experience: z.string().optional(),
variables: z.record(z.string(), z.unknown()).optional(),
checklist: z.string().optional(),
story_dir: z.string().optional(),
story_file: z.string().optional(),
context_file: z.string().optional(),
game_types_csv: z.string().optional(),
game_type_guides: z.string().optional(),
game_context: z.string().optional(),
game_brain_methods: z.string().optional(),
core_brainstorming: z.string().optional(),
decision_catalog: z.string().optional(),
architecture_patterns: z.string().optional(),
pattern_categories: z.string().optional(),
replaces: z.string().optional(),
paradigm: z.string().optional(),
execution_time: z.string().optional(),
features: z.array(z.string()).optional(),
project_context: z.string().optional(),
// Additional common fields
tags: z.array(z.string()).optional(),
execution_hints: z.record(z.string(), z.unknown()).optional(),
project_name: z.string().optional(),
library: z.string().optional(),
helpers: z.string().optional(),
templates: z.string().optional(),
json_validation: z.string().optional(),
shared_path: z.string().optional(),
})
.catchall(z.unknown()) // Allow unknown fields for dynamic workflow variables
.superRefine((data, ctx) => {
// Validate variable references in all string values
validateVariableReferences(data, [], ctx);
});
}
/**
* Schema for web_bundle object when it's not a boolean.
*/
function buildWebBundleSchema() {
return z
.object({
name: createNonEmptyString('web_bundle.name').optional(),
description: createNonEmptyString('web_bundle.description').optional(),
instructions: createNonEmptyString('web_bundle.instructions').optional(),
web_bundle_files: z.array(createNonEmptyString('web_bundle.web_bundle_files[]')).optional(),
validation: z.string().optional(),
template: z.union([z.literal(false), z.string()]).optional(),
existing_workflows: z.array(z.unknown()).optional(),
})
.catchall(z.unknown()); // Allow custom fields like agent_manifest, author, etc.
}
/**
* Schema for individual input_file_patterns entries.
*/
function buildInputFilePatternSchema() {
return z
.object({
description: createNonEmptyString('input_file_patterns[].description').optional(),
whole: z.string().optional(),
sharded: z.string().optional(),
sharded_index: z.string().optional(),
sharded_single: z.string().optional(),
pattern: z.string().optional(), // Legacy support
load_strategy: z.enum(['FULL_LOAD', 'SELECTIVE_LOAD', 'INDEX_GUIDED']).optional(),
})
.strict(); // Warn about unknown fields - patterns have a defined schema
}
/**
* Recursively validate variable references in all string values.
* Checks for:
* 1. Malformed syntax (unclosed braces, invalid patterns)
* 2. Unknown placeholders/variables (warns about potential typos)
*
* @param {any} obj - Object to validate
* @param {Array} path - Current path in object tree
* @param {import('zod').RefinementCtx} ctx - Zod context for adding issues
*/
function collectUnknownFieldWarnings(workflowYaml) {
const warnings = [];
if (typeof workflowYaml === 'object' && workflowYaml !== null) {
const knownFields = new Set([
'name',
'description',
'standalone',
'template',
'instructions',
'validation',
'config_source',
'installed_path',
'output_folder',
'user_name',
'communication_language',
'date',
'brain_techniques',
'validation_output_file',
'required_tools',
'default_output_file',
'autonomous',
'web_bundle',
'input_file_patterns',
'exit_triggers',
// Added common fields
'document_output_language',
'user_skill_level',
'sprint_artifacts',
'sprint_status',
'game_dev_experience',
'variables',
'checklist',
'story_dir',
'story_file',
'context_file',
'game_types_csv',
'game_type_guides',
'game_context',
'game_brain_methods',
'core_brainstorming',
'decision_catalog',
'architecture_patterns',
'pattern_categories',
'replaces',
'paradigm',
'execution_time',
'features',
'project_context',
// Additional common fields
'tags',
'execution_hints',
'project_name',
'library',
'helpers',
'templates',
'json_validation',
'shared_path',
]);
for (const key of Object.keys(workflowYaml)) {
if (!knownFields.has(key)) {
warnings.push({
path: [key],
message: `Unknown field '${key}' - not in workflow schema. This may be a workflow-specific variable or a typo.`,
});
}
}
// web_bundle unknown fields
if (workflowYaml.web_bundle && typeof workflowYaml.web_bundle === 'object') {
const knownWebBundleFields = new Set([
'name',
'description',
'instructions',
'web_bundle_files',
'validation',
'template',
'existing_workflows',
]);
for (const key of Object.keys(workflowYaml.web_bundle)) {
if (!knownWebBundleFields.has(key)) {
warnings.push({
path: ['web_bundle', key],
message: `Unknown field 'web_bundle.${key}' - not in web_bundle schema. This may be a custom field or a typo.`,
});
}
}
}
// input_file_patterns unknown fields
if (workflowYaml.input_file_patterns && typeof workflowYaml.input_file_patterns === 'object') {
const knownPatternFields = new Set([
'description',
'whole',
'sharded',
'sharded_index',
'sharded_single',
'load_strategy',
'pattern',
]);
for (const [patternName, pattern] of Object.entries(workflowYaml.input_file_patterns)) {
if (typeof pattern === 'object' && pattern !== null) {
for (const key of Object.keys(pattern)) {
if (!knownPatternFields.has(key)) {
warnings.push({
path: ['input_file_patterns', patternName, key],
message: `Unknown field 'input_file_patterns.${patternName}.${key}' - not in pattern schema. This may be a typo.`,
});
}
}
}
}
}
}
return warnings;
}
/**
* Recursively validate variable references in all string values.
*
* @param {any} data - Data to validate
* @param {Array} path - Current path
* @param {import('zod').RefinementCtx} ctx - Zod context
*/
function validateVariableReferences(data, path, ctx) {
if (typeof data === 'string') {
validateStringReferences(data, path, ctx);
return;
}
if (Array.isArray(data)) {
for (const [index, item] of data.entries()) {
validateVariableReferences(item, [...path, index], ctx);
}
return;
}
if (typeof data === 'object' && data !== null) {
for (const [key, value] of Object.entries(data)) {
validateVariableReferences(value, [...path, key], ctx);
}
}
}
/**
* Validate variable references in a single string value.
*/
function validateStringReferences(str, path, ctx) {
// Check for unclosed braces
const openBraces = (str.match(/\{/g) || []).length;
const closeBraces = (str.match(/\}/g) || []).length;
if (openBraces !== closeBraces) {
ctx.addIssue({
code: 'custom',
path,
message: `Malformed variable reference: unclosed braces (${openBraces} open, ${closeBraces} close)`,
});
return;
}
// Extract and validate path placeholders: {placeholder}
const pathPlaceholderMatches = str.matchAll(/\{([a-z][a-z0-9_-]*)\}/g);
for (const match of pathPlaceholderMatches) {
const placeholder = match[1];
// Skip config_source as it's a special keyword
if (placeholder === 'config_source') continue;
if (!KNOWN_PATH_PLACEHOLDERS.includes(placeholder)) {
ctx.addIssue({
code: 'custom',
path,
message: `Unknown path placeholder: {${placeholder}} - possible typo? Known placeholders: ${KNOWN_PATH_PLACEHOLDERS.slice(0, 5).join(', ')}...`,
});
}
}
// Extract and validate config variables: {config_source}:variable
const configMatches = str.matchAll(/\{config_source\}:([a-z][a-z0-9_]*)/g);
for (const match of configMatches) {
const variable = match[1];
if (!KNOWN_CONFIG_VARIABLES.includes(variable)) {
ctx.addIssue({
code: 'custom',
path,
message: `Unknown config variable: {config_source}:${variable} - possible typo? Known variables: ${KNOWN_CONFIG_VARIABLES.slice(0, 5).join(', ')}...`,
});
}
}
// Extract and validate template variables: {{variable}}
const templateMatches = str.matchAll(/\{\{([a-z][a-z0-9_]*)\}\}/g);
for (const match of templateMatches) {
const variable = match[1];
if (!KNOWN_TEMPLATE_VARIABLES.includes(variable)) {
ctx.addIssue({
code: 'custom',
path,
message: `Unknown template variable: {{${variable}}} - possible typo? Known variables: ${KNOWN_TEMPLATE_VARIABLES.slice(0, 5).join(', ')}...`,
});
}
}
// Check for malformed config_source references (missing colon)
if (str.includes('{config_source}') && !/\{config_source\}:[a-z]/.test(str)) {
const malformedMatch = str.match(/\{config_source\}([^:]|$)/);
if (malformedMatch) {
ctx.addIssue({
code: 'custom',
path,
message: 'Malformed config reference: {config_source} must be followed by :variable_name',
});
}
}
}
// Primitive validators -----------------------------------------------------
/**
* Create a validator for non-empty strings.
* @param {string} label Field label for error messages.
*/
function createNonEmptyString(label) {
return z.string().refine((value) => value.trim().length > 0, {
message: `${label} must be a non-empty string`,
});
}
/**
* Create a validator for kebab-case strings.
* @param {string} label Field label for error messages.
*/
function createKebabCaseString(label) {
return z.string().refine(
(value) => {
const trimmed = value.trim();
return trimmed.length > 0 && KEBAB_CASE_PATTERN.test(trimmed);
},
{
message: `${label} must be kebab-case (lowercase words separated by hyphen)`,
},
);
}