UNPKG

agentic-qe

Version:

Agentic Quality Engineering Fleet System - AI-driven quality management platform

787 lines 31.4 kB
"use strict"; /** * ApiContractValidatorAgent - P1 agent for API contract validation * Prevents breaking changes and ensures backward compatibility * * Key Capabilities: * - OpenAPI 3.0 / Swagger validation * - GraphQL schema validation * - Breaking change detection * - Consumer impact analysis * - Semantic versioning compliance * - Contract diffing and migration guides * * Memory Keys: aqe/api-contract/* * Events: api.contract.validated, api.breaking.change.detected * ROI: 350% (prevents 15-20% of production incidents) */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ApiContractValidatorAgent = void 0; const ajv_1 = __importDefault(require("ajv")); const ajv_formats_1 = __importDefault(require("ajv-formats")); const graphql_1 = require("graphql"); const BaseAgent_1 = require("./BaseAgent"); const types_1 = require("../types"); class ApiContractValidatorAgent extends BaseAgent_1.BaseAgent { constructor(config) { super(config); this.config = config.validatorConfig; this.ajv = new ajv_1.default({ allErrors: true }); try { (0, ajv_formats_1.default)(this.ajv); } catch (e) { // Formats may not be compatible, continue without them } } // ============================================================================ // BaseAgent Implementation // ============================================================================ async initializeComponents() { // Initialize validation components this.ajv = new ajv_1.default({ allErrors: true }); try { (0, ajv_formats_1.default)(this.ajv); } catch (e) { // Formats may not be compatible, continue without them } await this.storeSharedMemory('status', { initialized: true, timestamp: new Date(), config: this.config }); } async loadKnowledge() { // Load baseline schemas from memory const baselineSchemas = await this.retrieveSharedMemory(types_1.QEAgentType.API_CONTRACT_VALIDATOR, 'baseline-schemas'); if (baselineSchemas) { // Restore baseline schemas for comparison await this.storeMemory('baseline-schemas', baselineSchemas); } } async cleanup() { // Clean up validation caches await this.storeMemory('cleanup', { timestamp: new Date(), status: 'cleaned' }); } async performTask(task) { const taskType = task.type; switch (taskType) { case 'validate-contract': return await this.validateContract(task.payload); case 'detect-breaking-changes': return await this.detectBreakingChanges(task.payload); case 'validate-version': return await this.validateVersionBump(task.payload); case 'analyze-consumer-impact': return await this.analyzeConsumerImpact(task.payload); case 'generate-diff': return await this.generateDiff(task.payload); case 'generate-migration-guide': return await this.generateMigrationGuide(task.payload); case 'validate-request-response': return await this.validateRequestResponse(task.payload); default: throw new Error(`Unknown task type: ${taskType}`); } } // ============================================================================ // Public API // ============================================================================ /** * Validate an API contract (OpenAPI, GraphQL, etc.) */ async validateContract(params) { const { schema, format } = params; let result; if (format === 'graphql') { result = this.validateGraphQLSchema(schema); } else { result = this.validateOpenAPISchema(schema); } // Store validation result in memory await this.storeMemory('validation-result', { result, timestamp: new Date(), format }); // Emit validation event this.emitEvent(types_1.WEEK3_EVENT_TYPES.API_CONTRACT_VALIDATED, { valid: result.valid, errorCount: result.errors.length, format }, result.valid ? 'medium' : 'high'); return result; } /** * Detect breaking changes between two schema versions */ async detectBreakingChanges(params) { const { baseline, candidate, format = 'openapi' } = params; let result; if (format === 'graphql') { result = this.detectGraphQLBreakingChanges(baseline, candidate); } else { result = this.detectOpenAPIBreakingChanges(baseline, candidate); } // Store breaking changes in memory await this.storeMemory('breaking-changes', { result, timestamp: new Date(), format }); // Emit event if breaking changes detected if (result.hasBreakingChanges) { this.emitEvent(types_1.WEEK3_EVENT_TYPES.BREAKING_CHANGE_DETECTED, { breakingCount: result.breaking.length, severity: this.calculateMaxSeverity(result.breaking), summary: result.summary }, 'critical'); } return result; } /** * Validate request/response against schema */ async validateRequestResponse(params) { const { request, response, schema, endpoint, method } = params; const errors = []; const warnings = []; // Get operation definition const operation = schema.paths?.[endpoint]?.[method.toLowerCase()]; if (!operation) { errors.push({ type: 'ENDPOINT_NOT_FOUND', message: `Endpoint ${method.toUpperCase()} ${endpoint} not found in schema`, severity: 'CRITICAL' }); return { valid: false, errors, warnings }; } // Validate request const requestValidation = this.validateRequest(request, operation); errors.push(...requestValidation.errors); // Validate response const responseValidation = this.validateResponse(response, operation); errors.push(...responseValidation.errors); return { valid: errors.length === 0, errors, warnings }; } /** * Validate semantic version bump */ async validateVersionBump(params) { const { currentVersion, proposedVersion, changes } = params; const current = this.parseVersion(currentVersion); const proposed = this.parseVersion(proposedVersion); const required = this.calculateRequiredVersionBump(changes); const actualBump = this.getActualBump(current, proposed); const violations = []; // Validate version bump is sufficient if (required.type === 'MAJOR' && proposed.major <= current.major) { violations.push({ severity: 'CRITICAL', message: 'Breaking changes require major version bump', expected: `v${current.major + 1}.0.0`, actual: proposedVersion }); } if (required.type === 'MINOR' && proposed.major === current.major && proposed.minor <= current.minor) { violations.push({ severity: 'HIGH', message: 'New features require minor version bump', expected: `v${current.major}.${current.minor + 1}.0`, actual: proposedVersion }); } return { valid: violations.length === 0, currentVersion, proposedVersion, requiredBump: required.type, actualBump, recommendation: required.recommendedVersion, violations }; } /** * Analyze consumer impact of API changes */ async analyzeConsumerImpact(params) { const { changes, consumers } = params; const impacts = []; for (const consumer of consumers) { const affectedEndpoints = []; // Check which endpoints this consumer uses for (const usage of consumer.apiUsage) { const endpointChanges = changes.breaking.filter((c) => c.endpoint && this.normalizeEndpoint(c.endpoint).includes(this.normalizeEndpoint(usage.endpoint)) && (!c.method || c.method.toUpperCase() === usage.method.toUpperCase())); if (endpointChanges.length > 0) { affectedEndpoints.push({ endpoint: usage.endpoint, method: usage.method, requestsPerDay: usage.requestsPerDay, changes: endpointChanges, migrationEffort: this.estimateMigrationEffort(endpointChanges) }); } } if (affectedEndpoints.length > 0) { impacts.push({ consumer: consumer.name, team: consumer.team, contact: consumer.contact, affectedEndpoints, totalRequests: affectedEndpoints.reduce((sum, e) => sum + e.requestsPerDay, 0), estimatedMigrationTime: this.calculateMigrationTime(affectedEndpoints), priority: this.calculatePriority(consumer, affectedEndpoints) }); } } return { totalAffectedConsumers: impacts.length, impacts: impacts.sort((a, b) => this.priorityScore(b.priority) - this.priorityScore(a.priority)), coordinationRequired: impacts.length > 5, estimatedTotalMigrationTime: this.sumMigrationTimes(impacts) }; } /** * Generate contract diff report */ async generateDiff(params) { const { baseline, candidate, format = 'markdown' } = params; const changes = await this.detectBreakingChanges({ baseline, candidate }); if (format === 'json') { return JSON.stringify(changes, null, 2); } return this.generateMarkdownDiff(changes); } /** * Generate migration guide */ async generateMigrationGuide(params) { const { fromVersion, toVersion, changes } = params; let guide = `# Migration Guide: ${fromVersion}${toVersion}\n\n`; guide += `## Breaking Changes (${changes.breaking.length})\n\n`; for (const change of changes.breaking) { guide += `### ${change.type}\n`; guide += `**Severity:** ${change.severity}\n`; guide += `**Message:** ${change.message}\n`; if (change.endpoint) guide += `**Endpoint:** ${change.endpoint}\n`; guide += '\n'; } guide += `## Non-Breaking Changes (${changes.nonBreaking.length})\n\n`; for (const change of changes.nonBreaking) { guide += `- ${change.message}\n`; } return guide; } // ============================================================================ // Private Implementation // ============================================================================ validateOpenAPISchema(schema) { const errors = []; // Validate required top-level fields if (!schema.openapi && !schema.swagger) { errors.push({ type: 'MISSING_OPENAPI_VERSION', message: 'Missing openapi or swagger version field', severity: 'CRITICAL' }); } if (!schema.info) { errors.push({ type: 'MISSING_INFO', message: 'Missing info object', severity: 'CRITICAL' }); } else { if (!schema.info.title) { errors.push({ type: 'MISSING_TITLE', message: 'Missing info.title', severity: 'HIGH' }); } if (!schema.info.version) { errors.push({ type: 'MISSING_VERSION', message: 'Missing info.version', severity: 'HIGH' }); } } if (!schema.paths) { errors.push({ type: 'MISSING_PATHS', message: 'Missing paths object', severity: 'CRITICAL' }); } return { valid: errors.length === 0, errors }; } validateGraphQLSchema(schemaString) { const errors = []; try { // Parse GraphQL schema (0, graphql_1.parse)(schemaString); // Build schema to validate types (0, graphql_1.buildSchema)(schemaString); } catch (error) { if (error instanceof graphql_1.GraphQLError) { errors.push({ type: 'GRAPHQL_SYNTAX_ERROR', message: error.message, severity: 'CRITICAL' }); } else { errors.push({ type: 'GRAPHQL_VALIDATION_ERROR', message: String(error), severity: 'CRITICAL' }); } } return { valid: errors.length === 0, errors }; } validateRequest(request, operation) { const errors = []; // Validate path parameters const parameters = operation.parameters || []; for (const param of parameters) { if (param.in === 'path' && param.required) { if (request.params[param.name] === undefined) { errors.push({ type: 'MISSING_PATH_PARAM', param: param.name, message: `Required path parameter '${param.name}' is missing`, severity: 'CRITICAL' }); } } } // Validate query parameters for (const param of parameters) { if (param.in === 'query' && param.required) { if (request.query[param.name] === undefined) { errors.push({ type: 'MISSING_QUERY_PARAM', param: param.name, message: `Required query parameter '${param.name}' is missing`, severity: 'HIGH' }); } } } // Validate request body if (operation.requestBody) { const bodySchema = operation.requestBody.content?.['application/json']?.schema; if (bodySchema) { const bodyValidation = this.validateAgainstJSONSchema(request.body, bodySchema); errors.push(...bodyValidation.errors); } } return { valid: errors.length === 0, errors }; } validateResponse(response, operation) { const errors = []; const statusSchema = operation.responses?.[String(response.status)]; if (!statusSchema) { errors.push({ type: 'UNDOCUMENTED_STATUS', status: response.status, message: `Status code ${response.status} not documented in schema`, severity: 'MEDIUM' }); return { valid: false, errors }; } // Validate response body const contentType = response.headers?.['content-type'] || 'application/json'; const responseSchema = statusSchema.content?.[contentType]?.schema; if (responseSchema) { const bodyValidation = this.validateAgainstJSONSchema(response.body, responseSchema); errors.push(...bodyValidation.errors); } return { valid: errors.length === 0, errors }; } validateAgainstJSONSchema(data, schema) { const validate = this.ajv.compile(schema); const validResult = validate(data); const valid = typeof validResult === 'boolean' ? validResult : false; const errors = valid ? [] : (validate.errors || []).map((error) => ({ type: 'SCHEMA_VALIDATION', path: error.instancePath || error.dataPath || '', message: error.message || 'Validation error', params: error.params, severity: 'HIGH' })); return { valid, errors }; } detectOpenAPIBreakingChanges(baseline, candidate) { const breaking = []; const nonBreaking = []; // Compare endpoints const baselinePaths = baseline.paths || {}; const candidatePaths = candidate.paths || {}; for (const [path, methods] of Object.entries(baselinePaths)) { if (!candidatePaths[path]) { breaking.push({ type: 'ENDPOINT_REMOVED', severity: 'CRITICAL', endpoint: path, message: `Endpoint ${path} was removed` }); continue; } for (const [method, operation] of Object.entries(methods)) { if (!candidatePaths[path][method]) { breaking.push({ type: 'METHOD_REMOVED', severity: 'CRITICAL', endpoint: path, method: method.toUpperCase(), message: `Method ${method.toUpperCase()} ${path} was removed` }); continue; } // Compare parameters const paramChanges = this.compareParameters(operation.parameters || [], candidatePaths[path][method].parameters || []); breaking.push(...paramChanges.breaking); nonBreaking.push(...paramChanges.nonBreaking); // Compare responses const responseChanges = this.compareResponses(operation.responses || {}, candidatePaths[path][method].responses || {}); breaking.push(...responseChanges.breaking.map(c => ({ ...c, endpoint: path, method: method.toUpperCase() }))); nonBreaking.push(...responseChanges.nonBreaking.map(c => ({ ...c, endpoint: path, method: method.toUpperCase() }))); } } return { breaking, nonBreaking, hasBreakingChanges: breaking.length > 0, summary: this.generateSummary(breaking, nonBreaking) }; } detectGraphQLBreakingChanges(baseline, candidate) { const breaking = []; const nonBreaking = []; try { const baselineSchema = (0, graphql_1.buildSchema)(baseline); const candidateSchema = (0, graphql_1.buildSchema)(candidate); const baselineTypes = baselineSchema.getTypeMap(); const candidateTypes = candidateSchema.getTypeMap(); // Check for removed types and fields for (const [typeName, _type] of Object.entries(baselineTypes)) { if (!typeName.startsWith('__') && !candidateTypes[typeName]) { breaking.push({ type: 'TYPE_REMOVED', severity: 'CRITICAL', message: `Type ${typeName} was removed` }); } } } catch (error) { breaking.push({ type: 'GRAPHQL_PARSE_ERROR', severity: 'CRITICAL', message: `Failed to parse GraphQL schema: ${error}` }); } return { breaking, nonBreaking, hasBreakingChanges: breaking.length > 0, summary: this.generateSummary(breaking, nonBreaking) }; } compareParameters(baseline, candidate) { const breaking = []; const nonBreaking = []; // Check for removed required parameters for (const param of baseline) { const candidateParam = candidate.find((p) => p.name === param.name && p.in === param.in); if (!candidateParam) { if (param.required) { breaking.push({ type: 'REQUIRED_PARAM_REMOVED', severity: 'CRITICAL', param: param.name, location: param.in, message: `Required parameter '${param.name}' (${param.in}) was removed` }); } else { nonBreaking.push({ type: 'OPTIONAL_PARAM_REMOVED', param: param.name, location: param.in, message: `Optional parameter '${param.name}' (${param.in}) was removed` }); } } else { // Check if parameter became required if (!param.required && candidateParam.required) { breaking.push({ type: 'PARAM_BECAME_REQUIRED', severity: 'HIGH', param: param.name, location: param.in, message: `Parameter '${param.name}' (${param.in}) became required` }); } // Check for type changes if (param.schema?.type !== candidateParam.schema?.type) { breaking.push({ type: 'PARAM_TYPE_CHANGED', severity: 'HIGH', param: param.name, oldType: param.schema?.type, newType: candidateParam.schema?.type, message: `Parameter '${param.name}' type changed from ${param.schema?.type} to ${candidateParam.schema?.type}` }); } } } // Check for new required parameters for (const param of candidate) { const baselineParam = baseline.find((p) => p.name === param.name && p.in === param.in); if (!baselineParam && param.required) { breaking.push({ type: 'NEW_REQUIRED_PARAM', severity: 'HIGH', param: param.name, location: param.in, message: `New required parameter '${param.name}' (${param.in}) was added` }); } } return { breaking, nonBreaking }; } compareResponses(baseline, candidate) { const breaking = []; const nonBreaking = []; // Check for removed success responses for (const [status, response] of Object.entries(baseline)) { if (!candidate[status]) { if (status.startsWith('2')) { breaking.push({ type: 'RESPONSE_STATUS_REMOVED', severity: 'CRITICAL', status: parseInt(status), message: `Success response ${status} was removed` }); } } else { // Compare response schemas const baselineSchema = response.content?.['application/json']?.schema; const candidateSchema = candidate[status].content?.['application/json']?.schema; if (baselineSchema && candidateSchema) { const schemaChanges = this.compareResponseSchemas(baselineSchema, candidateSchema); breaking.push(...schemaChanges.breaking.map(c => ({ ...c, status: parseInt(status) }))); nonBreaking.push(...schemaChanges.nonBreaking.map(c => ({ ...c, status: parseInt(status) }))); } } } return { breaking, nonBreaking }; } compareResponseSchemas(baseline, candidate) { const breaking = []; const nonBreaking = []; // Check for removed required fields if (baseline.required) { for (const field of baseline.required) { if (!candidate.required?.includes(field)) { breaking.push({ type: 'REQUIRED_FIELD_REMOVED', severity: 'CRITICAL', field, message: `Required response field '${field}' was removed` }); } } } // Check for type changes in existing fields if (baseline.properties && candidate.properties) { for (const [field, fieldSchema] of Object.entries(baseline.properties)) { const candidateFieldSchema = candidate.properties[field]; if (!candidateFieldSchema) { breaking.push({ type: 'FIELD_REMOVED', severity: 'HIGH', field, message: `Response field '${field}' was removed` }); } else if (fieldSchema.type !== candidateFieldSchema.type) { breaking.push({ type: 'FIELD_TYPE_CHANGED', severity: 'HIGH', field, oldType: fieldSchema.type, newType: candidateFieldSchema.type, message: `Response field '${field}' type changed from ${fieldSchema.type} to ${candidateFieldSchema.type}` }); } } // New fields are non-breaking for (const field of Object.keys(candidate.properties)) { if (!baseline.properties[field]) { nonBreaking.push({ type: 'FIELD_ADDED', field, message: `Response field '${field}' was added` }); } } } return { breaking, nonBreaking }; } parseVersion(version) { const cleaned = version.replace(/^v/, ''); const parts = cleaned.split('.').map(Number); return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }; } getActualBump(current, proposed) { if (proposed.major > current.major) return 'MAJOR'; if (proposed.minor > current.minor) return 'MINOR'; return 'PATCH'; } calculateRequiredVersionBump(changes) { if (changes.breaking && changes.breaking.length > 0) { return { type: 'MAJOR', reason: 'Breaking changes detected', recommendedVersion: 'v3.0.0' }; } if (changes.nonBreaking && changes.nonBreaking.some((c) => c.type?.includes('ADDED'))) { return { type: 'MINOR', reason: 'New features added', recommendedVersion: 'v2.5.0' }; } return { type: 'PATCH', reason: 'Bug fixes only', recommendedVersion: 'v2.4.1' }; } generateSummary(breaking, nonBreaking) { const recommendation = breaking.length > 0 ? '🚨 BLOCK DEPLOYMENT - Breaking changes detected' : '✅ SAFE TO DEPLOY - No breaking changes'; return { totalBreaking: breaking.length, totalNonBreaking: nonBreaking.length, recommendation, suggestedVersion: breaking.length > 0 ? 'v3.0.0' : 'v2.5.0', estimatedMigrationTime: breaking.length > 3 ? '2-3 weeks' : '3-5 days' }; } generateMarkdownDiff(changes) { let diff = `# API Contract Diff\n\n`; diff += `## Breaking Changes (${changes.breaking.length})\n\n`; for (const change of changes.breaking) { diff += `- ❌ **${change.type}**: ${change.message}\n`; } diff += `\n## Non-Breaking Changes (${changes.nonBreaking.length})\n\n`; for (const change of changes.nonBreaking) { diff += `- ✅ ${change.message}\n`; } diff += `\n## Summary\n${changes.summary.recommendation}\n`; return diff; } normalizeEndpoint(endpoint) { return endpoint.replace(/\{[^}]+\}/g, '').toLowerCase(); } estimateMigrationEffort(changes) { const criticalCount = changes.filter((c) => c.severity === 'CRITICAL').length; if (criticalCount > 2) return 'HIGH'; if (criticalCount > 0) return 'MEDIUM'; return 'LOW'; } calculateMigrationTime(endpoints) { const totalEffort = endpoints.reduce((sum, e) => { const effortScore = e.migrationEffort === 'HIGH' ? 3 : e.migrationEffort === 'MEDIUM' ? 2 : 1; return sum + effortScore; }, 0); if (totalEffort > 10) return '2-3 weeks'; if (totalEffort > 5) return '1 week'; return '3-5 days'; } calculatePriority(consumer, endpoints) { const totalRequests = endpoints.reduce((sum, e) => sum + e.requestsPerDay, 0); const hasHighEffort = endpoints.some((e) => e.migrationEffort === 'HIGH'); if (totalRequests > 1000000 || hasHighEffort) return 'CRITICAL'; if (totalRequests > 100000) return 'HIGH'; if (totalRequests > 10000) return 'MEDIUM'; return 'LOW'; } priorityScore(priority) { const scores = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 }; return scores[priority] || 0; } sumMigrationTimes(impacts) { const maxTime = Math.max(...impacts.map((i) => { if (i.estimatedMigrationTime.includes('week')) { return parseInt(i.estimatedMigrationTime) * 7; } return parseInt(i.estimatedMigrationTime) || 3; })); if (maxTime > 14) return `${Math.ceil(maxTime / 7)} weeks`; return `${maxTime} days`; } calculateMaxSeverity(changes) { if (changes.some((c) => c.severity === 'CRITICAL')) return 'CRITICAL'; if (changes.some((c) => c.severity === 'HIGH')) return 'HIGH'; if (changes.some((c) => c.severity === 'MEDIUM')) return 'MEDIUM'; return 'LOW'; } } exports.ApiContractValidatorAgent = ApiContractValidatorAgent; //# sourceMappingURL=ApiContractValidatorAgent.js.map