Files
gridpilot.gg/scripts/contract-compatibility.ts
2025-12-24 00:01:01 +01:00

288 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<string, OpenAPISchema>;
required?: string[];
enum?: string[];
nullable?: boolean;
description?: string;
}
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, OpenAPISchema>;
};
}
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<void> {
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<ContractChange[]> {
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<string, { type: string; optional: boolean }> {
const properties: Record<string, { type: string; optional: boolean }> = {};
// 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<void> {
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);
});
}