450 lines
14 KiB
JavaScript
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)`,
|
|
},
|
|
);
|
|
}
|