UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

758 lines 31.1 kB
/** * Core Template Validator * * Validates orchestration templates against both OpenAPI schema requirements * and orchestration-specific rules that extend beyond the base API. */ import { FIELDS } from '../../generated/fields.generated'; import { OrchestrationRulesLayer } from './OrchestrationRulesLayer'; import { ReferenceResolver } from './ReferenceResolver'; /** * Main Template Validator Class */ export class TemplateValidator { rules = new Map(); startTime = 0; rulesChecked = 0; orchestrationRules; referenceResolver; constructor() { this.orchestrationRules = new OrchestrationRulesLayer(); this.referenceResolver = new ReferenceResolver(); this.initializeRules(); } /** * Initialize all validation rules */ initializeRules() { // Rule 1: Metrics Scope Validation this.addRule({ id: 'METRICS_SCOPE', name: 'Metrics Scope Validation', description: 'Validates correct metrics scope for campaigns vs experiments', validate: (context) => this.validateMetricsScope(context) }); // Rule 2: Audience Complexity Acknowledgment this.addRule({ id: 'AUDIENCE_COMPLEXITY', name: 'Audience Complexity Acknowledgment', description: 'Complex audiences require _acknowledged_complexity flag', validate: (context) => this.validateAudienceComplexity(context) }); // Rule 3: Reference Format Validation this.addRule({ id: 'REFERENCE_FORMAT', name: 'Reference Format Validation', description: 'Validates entity references use correct format', validate: (context) => this.validateReferenceFormat(context) }); // Rule 4: Platform Compatibility this.addRule({ id: 'PLATFORM_COMPATIBILITY', name: 'Platform Compatibility', description: 'Ensures entities are valid for the target platform', validate: (context) => this.validatePlatformCompatibility(context) }); // Rule 5: Required Fields this.addRule({ id: 'REQUIRED_FIELDS', name: 'Required Fields Validation', description: 'Ensures all required fields are present', validate: (context) => this.validateRequiredFields(context) }); // Rule 6: Field Type Validation this.addRule({ id: 'FIELD_TYPES', name: 'Field Type Validation', description: 'Validates field values match expected types', validate: (context) => this.validateFieldTypes(context) }); // Rule 7: Orchestration Rules Validation this.addRule({ id: 'ORCHESTRATION_RULES', name: 'Orchestration Rules Validation', description: 'Validates orchestration-specific rules from OrchestrationRulesLayer', validate: (context) => this.validateOrchestrationRules(context) }); // Rule 8: Traffic Allocation Validation this.addRule({ id: 'TRAFFIC_ALLOCATION', name: 'Traffic Allocation Validation', description: 'Validates traffic allocation sums to 10000 for variations', validate: (context) => this.validateTrafficAllocation(context) }); } /** * Add a validation rule */ addRule(rule) { this.rules.set(rule.id, rule); } /** * Main validation entry point */ async validate(template) { this.startTime = Date.now(); this.rulesChecked = 0; const errors = []; const warnings = []; let validSteps = 0; const context = { platform: template.platform || 'web', mode: template.mode || 'template', template }; // Validate template structure const structureError = this.validateTemplateStructure(template); if (structureError) { errors.push(structureError); return this.createResult(errors, warnings, template.steps?.length || 0, validSteps); } // Validate each step for (const step of template.steps) { context.currentStep = step; let stepHasErrors = false; // Run all rules for this step for (const rule of this.rules.values()) { this.rulesChecked++; const error = await Promise.resolve(rule.validate(context)); if (error) { if (error.severity === 'warning') { warnings.push(error); } else { errors.push(error); stepHasErrors = true; } } } if (!stepHasErrors) { validSteps++; } } return this.createResult(errors, warnings, template.steps.length, validSteps); } /** * Create validation result */ createResult(errors, warnings, totalSteps, validSteps) { return { valid: errors.length === 0, errors, warnings, summary: { totalSteps, validSteps, errorCount: errors.length, warningCount: warnings.length }, performance: { duration: Date.now() - this.startTime, rulesChecked: this.rulesChecked } }; } /** * Validate template structure */ validateTemplateStructure(template) { if (!template.steps || !Array.isArray(template.steps)) { return { code: 'INVALID_STRUCTURE', severity: 'fatal', path: 'template.steps', message: 'Template must have a "steps" array', expected: 'array', found: typeof template.steps }; } if (template.steps.length === 0) { return { code: 'EMPTY_TEMPLATE', severity: 'error', path: 'template.steps', message: 'Template must have at least one step', expected: 'steps.length > 0', found: 0 }; } return null; } /** * Rule Implementation: Metrics Scope */ validateMetricsScope(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType || !['campaign', 'experiment'].includes(entityType)) return null; const metrics = step.template?.inputs?.metrics; if (!metrics || !Array.isArray(metrics)) return null; const requiredScope = entityType === 'campaign' ? 'session' : 'visitor'; for (let i = 0; i < metrics.length; i++) { const metric = metrics[i]; if (metric.scope && metric.scope !== requiredScope) { return { code: `${entityType.toUpperCase()}_METRICS_SCOPE`, severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.metrics[${i}].scope`, message: `${entityType === 'campaign' ? 'Campaigns' : 'Experiments'} MUST use metrics scope: "${requiredScope}", not "${metric.scope}"`, found: metric.scope, expected: requiredScope, fix: { description: `Change metrics scope to "${requiredScope}"`, path: `steps.${step.id}.template.inputs.metrics[${i}].scope`, value: requiredScope } }; } } return null; } /** * Rule Implementation: Audience Complexity */ validateAudienceComplexity(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (entityType !== 'audience') return null; const inputs = step.template?.inputs; if (!inputs) return null; // Check if audience has complex conditions const hasConditions = inputs.conditions && (typeof inputs.conditions === 'string' || (typeof inputs.conditions === 'object' && Object.keys(inputs.conditions).length > 0)); if (hasConditions && inputs._acknowledged_complexity !== true) { return { code: 'AUDIENCE_COMPLEXITY_ACK', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs._acknowledged_complexity`, message: 'Complex audiences with conditions require _acknowledged_complexity: true', found: inputs._acknowledged_complexity, expected: true, fix: { description: 'Add _acknowledged_complexity: true to acknowledge complex audience creation', path: `steps.${step.id}.template.inputs._acknowledged_complexity`, value: true } }; } return null; } /** * Rule Implementation: Reference Format */ validateReferenceFormat(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const inputs = step.template?.inputs; if (!inputs) return null; // Check for reference fields const referenceFields = ['audience', 'audiences', 'page_ids', 'event_id', 'campaign_id']; for (const field of referenceFields) { const value = inputs[field]; if (!value) continue; // In template mode, certain fields should use ref objects if (context.mode === 'template' && field === 'audience') { if (typeof value === 'string') { return { code: 'REFERENCE_FORMAT', severity: 'warning', stepId: step.id, path: `steps.${step.id}.template.inputs.${field}`, message: 'In template mode, use ref object format for audience field', found: 'string', expected: 'ref object', fix: { description: 'Convert to ref object format', path: `steps.${step.id}.template.inputs.${field}`, value: { ref: { name: value } } } }; } } // Validate step references if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { const refPattern = /^\$\{([^.]+)\.([^}]+)\}$/; const match = value.match(refPattern); if (!match) { return { code: 'INVALID_STEP_REFERENCE', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${field}`, message: 'Invalid step reference format. Use ${step_id.field_name}', found: value, expected: '${step_id.entity_id}' }; } // Check if referenced step exists const referencedStepId = match[1]; const stepExists = context.template?.steps?.some((s) => s.id === referencedStepId); if (!stepExists) { return { code: 'UNKNOWN_STEP_REFERENCE', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${field}`, message: `Referenced step "${referencedStepId}" not found`, found: value }; } } } return null; } /** * Rule Implementation: Platform Compatibility */ validatePlatformCompatibility(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType) return null; const platform = context.platform; // Define platform restrictions const platformRestrictions = { web: ['experiment', 'campaign', 'page', 'audience', 'event', 'project', 'environment', 'variation'], feature: ['flag', 'ruleset', 'rule', 'audience', 'event', 'project', 'environment', 'variation'] }; const allowedEntities = platformRestrictions[platform] || []; if (!allowedEntities.includes(entityType)) { return { code: 'PLATFORM_MISMATCH', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.system_template_id`, message: `Entity type "${entityType}" is not supported on ${platform} platform`, found: entityType, expected: `one of: ${allowedEntities.join(', ')}` }; } return null; } /** * Rule Implementation: Required Fields */ validateRequiredFields(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType) return null; const inputs = step.template?.inputs || {}; const fieldDef = FIELDS[entityType]; if (!fieldDef || !fieldDef.required) return null; // Check each required field for (const requiredField of fieldDef.required) { if (inputs[requiredField] === undefined || inputs[requiredField] === null) { // Skip fields that might be provided by references if (requiredField.endsWith('_id') && inputs[requiredField.replace('_id', '')] !== undefined) { continue; } // Skip parameter references const value = inputs[requiredField]; if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { continue; } return { code: 'MISSING_REQUIRED_FIELD', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${requiredField}`, message: `Required field "${requiredField}" is missing`, expected: fieldDef.fieldTypes?.[requiredField] || 'any' }; } } return null; } /** * Rule Implementation: Field Type Validation */ validateFieldTypes(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType) return null; const inputs = step.template?.inputs || {}; const fieldDef = FIELDS[entityType]; if (!fieldDef || !fieldDef.fieldTypes) return null; // Check each field's type for (const [fieldName, value] of Object.entries(inputs)) { if (value === undefined || value === null) continue; const expectedType = fieldDef.fieldTypes?.[fieldName]; if (!expectedType) continue; // Skip parameter references and step references for type checking if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { continue; } const actualType = this.getActualType(value); const isValidType = this.isTypeMatch(value, expectedType); if (!isValidType) { return { code: 'INVALID_FIELD_TYPE', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${fieldName}`, message: `Field "${fieldName}" has invalid type`, found: actualType, expected: expectedType }; } // Check enum values const enumValues = fieldDef.enums?.[fieldName]; if (enumValues && !enumValues.includes(value)) { return { code: 'INVALID_ENUM_VALUE', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${fieldName}`, message: `Field "${fieldName}" must be one of: ${enumValues.join(', ')}`, found: value, expected: enumValues }; } } return null; } /** * Rule Implementation: Orchestration Rules Validation */ async validateOrchestrationRules(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType) return null; const inputs = step.template?.inputs || {}; const platform = context.platform || 'web'; const mode = context.mode || 'template'; // Get all orchestration rules for this entity const rules = this.orchestrationRules.getAllRulesForEntity(entityType, 'create', platform); // Validate each field with orchestration rules for (const [fieldName, value] of Object.entries(inputs)) { // Skip parameter references for orchestration validation if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { continue; } const rulesContext = { platform, mode, operation: 'create', entityType, step, template: context.template }; const result = await this.orchestrationRules.validateField(entityType, fieldName, value, rulesContext); if (!result.valid) { return { code: 'ORCHESTRATION_RULE_VIOLATION', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.${fieldName}`, message: result.error || `Orchestration rule violation for field "${fieldName}"`, found: value, fix: result.fix ? { description: result.fix.description || 'Fix orchestration rule violation', path: `steps.${step.id}.template.inputs.${fieldName}`, value: result.fix.value } : undefined }; } } return null; } /** * Rule Implementation: Traffic Allocation Validation */ validateTrafficAllocation(context) { const step = context.currentStep; if (!step || step.type !== 'template') return null; const entityType = this.extractEntityType(step.template?.system_template_id); // Only validate for experiments if (entityType !== 'experiment') return null; const variations = step.template?.inputs?.variations; if (!variations || !Array.isArray(variations)) return null; // Calculate total weight const totalWeight = variations.reduce((sum, v) => sum + (v.weight || 0), 0); if (totalWeight !== 10000) { return { code: 'INVALID_TRAFFIC_ALLOCATION', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.variations`, message: `Variation weights must sum to 10000 (100%), currently ${totalWeight}`, found: totalWeight, expected: 10000, fix: { description: 'Adjust variation weights to sum to 10000', path: `steps.${step.id}.template.inputs.variations`, value: this.redistributeWeights(variations) } }; } // Check individual variation weights for (let i = 0; i < variations.length; i++) { const weight = variations[i].weight; if (weight === undefined || weight < 0 || weight > 10000) { return { code: 'INVALID_VARIATION_WEIGHT', severity: 'error', stepId: step.id, path: `steps.${step.id}.template.inputs.variations[${i}].weight`, message: `Variation weight must be between 0 and 10000`, found: weight, expected: '0-10000' }; } } return null; } /** * Redistribute weights to sum to 10000 */ redistributeWeights(variations) { if (!variations || variations.length === 0) return variations; const totalWeight = variations.reduce((sum, v) => sum + (v.weight || 0), 0); if (totalWeight === 0) { // Equal distribution const equalWeight = Math.floor(10000 / variations.length); return variations.map((v, i) => ({ ...v, weight: i === variations.length - 1 ? 10000 - (equalWeight * (variations.length - 1)) : equalWeight })); } // Proportional distribution return variations.map((v, i) => { const proportionalWeight = Math.round((v.weight || 0) * 10000 / totalWeight); return { ...v, weight: proportionalWeight }; }); } /** * Extract entity type from system template ID */ extractEntityType(systemTemplateId) { if (!systemTemplateId) return null; const patterns = [ { regex: /optimizely_experiment_/, type: 'experiment' }, { regex: /optimizely_campaign_/, type: 'campaign' }, { regex: /optimizely_audience_/, type: 'audience' }, { regex: /optimizely_event_/, type: 'event' }, { regex: /optimizely_flag_/, type: 'flag' }, { regex: /optimizely_ruleset_/, type: 'ruleset' }, { regex: /optimizely_rule_/, type: 'rule' }, { regex: /optimizely_page_/, type: 'page' }, { regex: /optimizely_project_/, type: 'project' }, { regex: /optimizely_environment_/, type: 'environment' } ]; for (const pattern of patterns) { if (pattern.regex.test(systemTemplateId)) { return pattern.type; } } return null; } /** * Get actual type of a value */ getActualType(value) { if (Array.isArray(value)) return 'array'; if (value === null) return 'null'; return typeof value; } /** * Check if value matches expected type */ isTypeMatch(value, expectedType) { switch (expectedType) { case 'string': return typeof value === 'string'; case 'number': case 'integer': return typeof value === 'number' && !isNaN(value); case 'boolean': return typeof value === 'boolean'; case 'array': return Array.isArray(value); case 'object': return typeof value === 'object' && !Array.isArray(value) && value !== null; default: return true; } } /** * Apply auto-fixes to a template */ async applyAutoFixes(template) { const fixes = []; const fixedTemplate = JSON.parse(JSON.stringify(template)); // Deep clone // First validate to find all errors with fixes const validationResult = await this.validate(template); // Apply fixes from validation errors for (const error of validationResult.errors) { if (error.fix) { const pathParts = error.path.split('.'); let target = fixedTemplate; // Navigate to the parent of the field to fix for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (!target) break; if (part.includes('[') && part.includes(']')) { // Handle array access const [arrayName, indexStr] = part.split('['); const index = parseInt(indexStr.replace(']', '')); target = target[arrayName] ? target[arrayName][index] : undefined; } else if (part === 'steps' && i + 1 < pathParts.length) { // Special handling for steps - find by ID const stepId = pathParts[i + 1]; const step = target.steps?.find((s) => s.id === stepId); if (step) { target = step; i++; // Skip the step ID part } else { target = undefined; } } else { target = target[part]; } } if (!target) continue; // Skip if path is invalid // Apply the fix const fieldName = pathParts[pathParts.length - 1]; let oldValue = undefined; if (fieldName.includes('[') && fieldName.includes(']')) { // Handle array element const [arrayName, indexStr] = fieldName.split('['); const index = parseInt(indexStr.replace(']', '')); if (target[arrayName] && Array.isArray(target[arrayName])) { oldValue = target[arrayName][index]; target[arrayName][index] = error.fix.value; } } else { oldValue = target[fieldName]; target[fieldName] = error.fix.value; } fixes.push({ path: error.path, oldValue, newValue: error.fix.value, description: error.fix.description || error.message }); } } // Apply orchestration layer auto-fixes for (const step of fixedTemplate.steps || []) { if (step.type !== 'template') continue; const entityType = this.extractEntityType(step.template?.system_template_id); if (!entityType) continue; const platform = fixedTemplate.platform || 'web'; const mode = fixedTemplate.mode || 'template'; const { fixed, changes } = this.orchestrationRules.applyAutoFixes(step, entityType, platform, mode); if (fixed) { for (const change of changes) { fixes.push({ path: `steps.${step.id}.template.inputs.${change.field}`, oldValue: change.oldValue, newValue: change.newValue, description: `Auto-corrected ${change.field} for ${platform} ${entityType}` }); } } } return { fixedTemplate, fixes }; } /** * Format validation results for display */ formatResults(result) { const lines = []; lines.push('ORCHESTRATION TEMPLATE VALIDATION'); lines.push('=================================\n'); if (result.valid) { lines.push('✅ Template is valid!'); lines.push(` ${result.summary.totalSteps} steps validated successfully`); } else { if (result.errors.length > 0) { lines.push(`❌ Found ${result.errors.length} error(s):\n`); for (const error of result.errors) { lines.push(`[${error.code}] ${error.stepId ? `Step "${error.stepId}"` : 'Template level'}`); lines.push(` ${error.message}`); if (error.found !== undefined) { lines.push(` Found: ${JSON.stringify(error.found)}`); } if (error.expected !== undefined) { lines.push(` Expected: ${JSON.stringify(error.expected)}`); } if (error.fix) { lines.push(` ✨ Fix: ${error.fix.description}`); } lines.push(''); } } } if (result.warnings.length > 0) { lines.push(`⚠️ ${result.warnings.length} warning(s):\n`); for (const warning of result.warnings) { lines.push(`[${warning.code}] ${warning.stepId ? `Step "${warning.stepId}"` : 'Template level'}`); lines.push(` ${warning.message}`); if (warning.fix) { lines.push(` 💡 Suggestion: ${warning.fix.description}`); } lines.push(''); } } lines.push('SUMMARY:'); lines.push(` Total steps: ${result.summary.totalSteps}`); lines.push(` Valid steps: ${result.summary.validSteps}`); lines.push(` Errors: ${result.summary.errorCount}`); lines.push(` Warnings: ${result.summary.warningCount}`); if (result.performance) { lines.push(`\nPERFORMANCE:`); lines.push(` Duration: ${result.performance.duration}ms`); lines.push(` Rules checked: ${result.performance.rulesChecked}`); } return lines.join('\n'); } } //# sourceMappingURL=TemplateValidator.js.map