#!/usr/bin/env tsx /** * Contract Compatibility Verification Script * * This script verifies that the API contracts are compatible with the website * by running the type generation and then checking for breaking changes. */ import { execSync } from 'child_process'; import * as fs from 'fs/promises'; import * as path from 'path'; import { glob } from 'glob'; interface OpenAPISchema { type?: string; format?: string; $ref?: string; items?: OpenAPISchema; properties?: Record; required?: string[]; enum?: string[]; nullable?: boolean; description?: string; } interface OpenAPISpec { openapi: string; info: { title: string; description: string; version: string; }; paths: Record; components: { schemas: Record; }; } interface ContractChange { type: 'added' | 'removed' | 'modified' | 'breaking'; dto: string; property?: string; details: string; } const colors = { reset: '\x1b[0m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', dim: '\x1b[2m' }; async function runContractCompatibilityCheck(): Promise { console.log(`${colors.blue}🔍 Running Contract Compatibility Check...${colors.reset}\n`); const apiRoot = path.join(__dirname, '../apps/api'); const websiteRoot = path.join(__dirname, '../apps/website'); const openapiPath = path.join(apiRoot, 'openapi.json'); const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated'); const backupDir = path.join(__dirname, '../.backup/contract-types'); // Step 1: Generate current OpenAPI spec console.log(`${colors.yellow}1. Generating OpenAPI spec...${colors.reset}`); try { execSync('npm run api:generate-spec', { stdio: 'inherit' }); } catch (error) { console.error(`${colors.red}❌ Failed to generate OpenAPI spec${colors.reset}`); process.exit(1); } // Step 2: Backup current generated types console.log(`${colors.yellow}2. Backing up current generated types...${colors.reset}`); await fs.mkdir(backupDir, { recursive: true }); try { const files = await fs.readdir(generatedTypesDir); for (const file of files) { if (file.endsWith('.ts')) { const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8'); await fs.writeFile(path.join(backupDir, file), content); } } } catch (error) { console.log(`${colors.yellow}⚠️ No existing types to backup${colors.reset}`); } // Step 3: Generate new types console.log(`${colors.yellow}3. Generating new types...${colors.reset}`); try { execSync('npm run api:generate-types', { stdio: 'inherit' }); } catch (error) { console.error(`${colors.red}❌ Failed to generate types${colors.reset}`); process.exit(1); } // Step 4: Compare and detect changes console.log(`${colors.yellow}4. Analyzing contract changes...${colors.reset}`); const changes = await detectContractChanges(backupDir, generatedTypesDir, openapiPath); // Step 5: Report results console.log(`${colors.yellow}5. Reporting changes...${colors.reset}\n`); await reportChanges(changes); // Step 6: Clean up backup console.log(`${colors.yellow}6. Cleaning up...${colors.reset}`); await fs.rm(backupDir, { recursive: true, force: true }); console.log(`\n${colors.green}✅ Contract compatibility check completed!${colors.reset}`); } async function detectContractChanges( backupDir: string, currentDir: string, openapiPath: string ): Promise { const changes: ContractChange[] = []; // Read OpenAPI spec const specContent = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(specContent); const schemas = spec.components.schemas; // Get current and backup files const currentFiles = await fs.readdir(currentDir); const backupFiles = await fs.readdir(backupDir); const currentDTOs = currentFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', '')); const backupDTOs = backupFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', '')); // Check for removed DTOs for (const backupDTO of backupDTOs) { if (!currentDTOs.includes(backupDTO)) { changes.push({ type: 'removed', dto: backupDTO, details: `DTO ${backupDTO} was removed` }); } } // Check for added and modified DTOs for (const currentDTO of currentDTOs) { const currentPath = path.join(currentDir, `${currentDTO}.ts`); const backupPath = path.join(backupDir, `${currentDTO}.ts`); const currentContent = await fs.readFile(currentPath, 'utf-8'); const backupExists = backupDTOs.includes(currentDTO); if (!backupExists) { changes.push({ type: 'added', dto: currentDTO, details: `New DTO ${currentDTO} was added` }); } else { const backupContent = await fs.readFile(backupPath, 'utf-8'); // Check for property changes const schema = schemas[currentDTO]; if (schema && schema.properties) { const currentProps = extractProperties(currentContent); const backupProps = extractProperties(backupContent); // Check for removed properties for (const [propName, backupProp] of Object.entries(backupProps)) { if (!currentProps[propName]) { const isRequired = schema.required?.includes(propName); changes.push({ type: isRequired ? 'breaking' : 'modified', dto: currentDTO, property: propName, details: `Property ${propName} was removed${isRequired ? ' (BREAKING)' : ''}` }); } } // Check for added properties for (const [propName, currentProp] of Object.entries(currentProps)) { if (!backupProps[propName]) { const isRequired = schema.required?.includes(propName); changes.push({ type: isRequired ? 'breaking' : 'added', dto: currentDTO, property: propName, details: `Property ${propName} was added${isRequired ? ' (potentially breaking)' : ''}` }); } } // Check for type changes for (const [propName, currentProp] of Object.entries(currentProps)) { if (backupProps[propName]) { const backupProp = backupProps[propName]; if (currentProp.type !== backupProp.type) { changes.push({ type: 'breaking', dto: currentDTO, property: propName, details: `Property ${propName} type changed from ${backupProp.type} to ${currentProp.type} (BREAKING)` }); } } } } } } return changes; } function extractProperties(content: string): Record { const properties: Record = {}; // Match property lines: propertyName?: type; const propertyRegex = /^\s*(\w+)(\??):\s*([^;]+);/gm; let match; while ((match = propertyRegex.exec(content)) !== null) { const [, name, optional, type] = match; properties[name] = { type: type.trim(), optional: !!optional }; } return properties; } async function reportChanges(changes: ContractChange[]): Promise { if (changes.length === 0) { console.log(`${colors.green}✅ No changes detected - contracts are stable${colors.reset}`); return; } const breaking = changes.filter(c => c.type === 'breaking'); const modified = changes.filter(c => c.type === 'modified'); const added = changes.filter(c => c.type === 'added'); const removed = changes.filter(c => c.type === 'removed'); if (breaking.length > 0) { console.log(`${colors.red}🚨 BREAKING CHANGES DETECTED:${colors.reset}`); breaking.forEach(change => { console.log(` ${colors.red}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`); }); console.log(''); } if (removed.length > 0) { console.log(`${colors.red}❌ REMOVED:${colors.reset}`); removed.forEach(change => { console.log(` ${colors.red}• ${change.dto}: ${change.details}${colors.reset}`); }); console.log(''); } if (modified.length > 0) { console.log(`${colors.yellow}⚠️ MODIFIED:${colors.reset}`); modified.forEach(change => { console.log(` ${colors.yellow}• ${change.dto}.${change.property}: ${change.details}${colors.reset}`); }); console.log(''); } if (added.length > 0) { console.log(`${colors.green}➕ ADDED:${colors.reset}`); added.forEach(change => { console.log(` ${colors.green}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`); }); console.log(''); } const totalChanges = changes.length; console.log(`${colors.blue}📊 Summary: ${totalChanges} total changes (${breaking.length} breaking, ${removed.length} removed, ${modified.length} modified, ${added.length} added)${colors.reset}`); if (breaking.length > 0) { console.log(`\n${colors.red}❌ Contract compatibility check FAILED due to breaking changes${colors.reset}`); process.exit(1); } } // Run if called directly if (require.main === module) { runContractCompatibilityCheck().catch(error => { console.error(`${colors.red}❌ Error running contract compatibility check:${colors.reset}`, error); process.exit(1); }); }