381 lines
10 KiB
TypeScript
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);
|
|
});
|