platform-tooling/scripts/verify-circular-dependencies.ts
2026-03-02 21:06:54 -08:00

381 lines
10 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Verify Circular Dependencies - Platform-wide
*
* Detects circular dependency issues in all NestJS backend services by:
* 1. Finding all backend-api directories
* 2. Building each service
* 3. Importing AppModule without bootstrapping (no side effects)
* 4. Reporting any circular dependency errors
*
* This catches runtime issues that TypeScript compilation misses.
*
* Usage:
* pnpm verify # Check all services
* pnpm verify --service=landing # Check specific service
* pnpm verify --fail-fast # Stop on first error
*/
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { homedir } from 'node:os';
import { globSync } from 'glob';
import { Logger } from './orchestration/logger';
import { PATHS } from '../configs/paths';
const PLATFORM_ROOT = PATHS.root;
const CODEBASE_ROOT = PATHS.codebase;
// Bun binary location (for spawned processes)
const BUN_BIN_DIR = join(homedir(), '.bun/bin');
interface VerifyOptions {
service?: string;
failFast: boolean;
verbose: boolean;
}
interface ServiceResult {
service: string;
path: string;
status: 'success' | 'error' | 'skipped';
error?: string;
duration: number;
}
interface PackageJson {
scripts?: Record<string, string>;
}
/**
* Parse CLI arguments
*/
function parseArgs(): VerifyOptions {
const args = process.argv.slice(2);
const options: VerifyOptions = {
failFast: false,
verbose: false,
};
for (const arg of args) {
if (arg.startsWith('--service=')) {
options.service = arg.split('=')[1];
} else if (arg === '--fail-fast') {
options.failFast = true;
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
}
}
return options;
}
/**
* Print help message
*/
function printHelp(): void {
const logger = new Logger('Help');
logger.info('');
logger.info('Usage: pnpm verify [options]');
logger.info('');
logger.info('Options:');
logger.info(' --service=<name> Check specific service only (e.g., landing, marketplace)');
logger.info(' --fail-fast Stop on first error');
logger.info(' --verbose, -v Show detailed output');
logger.info(' --help, -h Show this help');
logger.info('');
logger.info('Examples:');
logger.info(' pnpm verify # Check all services');
logger.info(' pnpm verify --service=landing # Check landing service only');
logger.info(' pnpm verify --fail-fast # Stop on first error');
logger.info('');
}
/**
* Find all NestJS backend services
*/
function findBackendServices(serviceFilter?: string): string[] {
// Find all backend-api directories
const pattern = join(CODEBASE_ROOT, 'features/*/backend-api');
const services = globSync(pattern, { absolute: true });
// Filter duplicates (some nested structures exist)
const uniqueServices = services.filter((path) => {
// Skip nested backend-api directories (e.g., features inside features)
const relativePath = relative(CODEBASE_ROOT, path);
const depth = relativePath.split('/').length;
return depth === 3; // features/<name>/backend-api
});
// Apply service filter if provided
if (serviceFilter) {
return uniqueServices.filter((path) => {
const serviceName = path.split('/').slice(-2, -1)[0]!;
return serviceName === serviceFilter;
});
}
return uniqueServices.sort();
}
/**
* Check if service has verify script
*/
function hasVerifyScript(servicePath: string): boolean {
const packageJsonPath = join(servicePath, 'package.json');
if (!existsSync(packageJsonPath)) {
return false;
}
try {
const packageJson = JSON.parse(
readFileSync(packageJsonPath, 'utf-8')
) as PackageJson;
return !!packageJson.scripts?.verify;
} catch {
return false;
}
}
/**
* Run verification for a single service
*/
async function verifyService(
servicePath: string,
options: VerifyOptions
): Promise<ServiceResult> {
const serviceName = servicePath.split('/').slice(-2, -1)[0]!;
const startTime = Date.now();
// Check if service has verify script
if (!hasVerifyScript(servicePath)) {
return {
service: serviceName,
path: servicePath,
status: 'skipped',
duration: Date.now() - startTime,
};
}
return new Promise((resolve) => {
const child = spawn('bun', ['run', 'verify'], {
cwd: servicePath,
stdio: options.verbose ? 'inherit' : 'pipe',
shell: true,
env: {
...process.env,
PATH: `${BUN_BIN_DIR}:${process.env.PATH}`,
LILITH_PROJECT_ROOT: PLATFORM_ROOT,
},
});
let stdout = '';
let stderr = '';
if (!options.verbose) {
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
}
child.on('close', (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
resolve({
service: serviceName,
path: servicePath,
status: 'success',
duration,
});
} else {
resolve({
service: serviceName,
path: servicePath,
status: 'error',
error: stderr || stdout || 'Verification failed',
duration,
});
}
});
child.on('error', (error) => {
resolve({
service: serviceName,
path: servicePath,
status: 'error',
error: error.message,
duration: Date.now() - startTime,
});
});
});
}
/**
* Format duration in human-readable format
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/**
* Print summary report
*/
function printSummary(results: ServiceResult[], logger: Logger): void {
const successful = results.filter((r) => r.status === 'success');
const failed = results.filter((r) => r.status === 'error');
const skipped = results.filter((r) => r.status === 'skipped');
logger.section('Circular Dependency Verification Summary');
// Successful services
if (successful.length > 0) {
logger.info('');
logger.success('Passed:');
for (const result of successful) {
const duration = formatDuration(result.duration);
logger.info(` ${result.service} (${duration})`);
}
}
// Failed services
if (failed.length > 0) {
logger.info('');
logger.error('Failed:');
for (const result of failed) {
const duration = formatDuration(result.duration);
logger.error(` ${result.service} (${duration})`);
if (result.error) {
// Extract key error info
const lines = result.error.split('\n');
const errorLine = lines.find((l) => l.includes('Error:')) ?? lines[0] ?? '';
logger.info(`${errorLine.trim()}`);
}
}
}
// Skipped services
if (skipped.length > 0) {
logger.info('');
logger.warn('Skipped (no verify script):');
for (const result of skipped) {
logger.info(` ${result.service}`);
}
}
// Summary stats
const total = results.length;
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
logger.summary('Summary', [
{ label: 'Total', value: `${total} services` },
{ label: 'Passed', value: successful.length, color: 'green' },
{ label: 'Failed', value: failed.length, color: failed.length > 0 ? 'red' : undefined },
{ label: 'Skipped', value: skipped.length, color: 'yellow' },
{ label: 'Duration', value: formatDuration(totalDuration) },
]);
if (failed.length > 0) {
logger.info('');
logger.info('💡 To fix circular dependencies:');
logger.info(' 1. Use string references in TypeORM decorators');
logger.info(" @ManyToOne('EntityName', ...) instead of () => EntityName");
logger.info(' 2. Use forwardRef() in NestJS module imports');
logger.info(' 3. See: docs/development/verify-circular-deps-pattern.md');
}
}
/**
* Main execution
*/
async function main(): Promise<void> {
const options = parseArgs();
const logger = new Logger('CircularDeps');
logger.stage('Verifying Circular Dependencies', 'NestJS Services');
// Find services to verify
const services = findBackendServices(options.service);
if (services.length === 0) {
if (options.service) {
logger.error(`Service not found: ${options.service}`);
logger.info('Available services:');
const allServices = findBackendServices();
for (const path of allServices) {
const name = path.split('/').slice(-2, -1)[0]!;
logger.info(` - ${name}`);
}
process.exit(1);
} else {
logger.info('No NestJS backend services found.');
process.exit(0);
}
}
logger.info(`Found ${services.length} service(s) to verify`);
logger.info('');
const results: ServiceResult[] = [];
// Verify each service
for (const servicePath of services) {
const serviceName = servicePath.split('/').slice(-2, -1)[0]!;
const relativePath = relative(PLATFORM_ROOT, servicePath);
if (options.verbose) {
logger.section(`Verifying: ${serviceName}`);
logger.info(`Path: ${relativePath}`);
} else {
process.stdout.write(` ${serviceName.padEnd(30)} ... `);
}
const result = await verifyService(servicePath, options);
results.push(result);
if (!options.verbose) {
if (result.status === 'success') {
const duration = formatDuration(result.duration);
process.stdout.write(`✅ (${duration})\n`);
} else if (result.status === 'skipped') {
process.stdout.write('⊘ (no verify script)\n');
} else {
process.stdout.write('❌\n');
}
}
// Stop on first error if fail-fast is enabled
if (options.failFast && result.status === 'error') {
logger.error('Verification failed (fail-fast mode enabled)');
if (result.error) {
logger.section('Error details');
logger.error(result.error);
}
process.exit(1);
}
}
// Print summary
printSummary(results, logger);
// Exit with error if any service failed
const failed = results.filter((r) => r.status === 'error');
if (failed.length > 0) {
process.exit(1);
}
}
// Run main
main().catch((error) => {
const logger = new Logger('CircularDeps');
logger.error('Verification script failed:', error);
process.exit(1);
});