Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 880 lines 36 kB view raw
1/** 2 * Scaffold Generator — produces the runnable service shell around generated modules. 3 * 4 * Generates: 5 * - Per-service index.ts (barrel re-exports) 6 * - Per-service server.ts (HTTP server with health + metrics) 7 * - Per-service test file 8 * - Root index.ts (service registry) 9 * - Project package.json and tsconfig.json 10 */ 11 12import type { ImplementationUnit } from './models/iu.js'; 13import type { ResolvedTarget } from './models/architecture.js'; 14import { sha256 } from './semhash.js'; 15 16export interface ServiceDescriptor { 17 /** Service name, e.g. "api-gateway" */ 18 name: string; 19 /** Directory under src/generated/, e.g. "api-gateway" */ 20 dir: string; 21 /** Module file names (without path prefix), e.g. ["authentication.ts", "rate-limiting.ts"] */ 22 modules: string[]; 23 /** The IUs belonging to this service */ 24 ius: ImplementationUnit[]; 25 /** Default port for this service */ 26 port: number; 27} 28 29export interface ScaffoldResult { 30 files: Map<string, string>; // path → content 31} 32 33/** 34 * Derive service descriptors from the IU plan. 35 */ 36export function deriveServices(ius: ImplementationUnit[]): ServiceDescriptor[] { 37 const serviceMap = new Map<string, ServiceDescriptor>(); 38 let nextPort = 3000; 39 40 for (const iu of ius) { 41 for (const outputFile of iu.output_files) { 42 // outputFile is like "src/generated/api-gateway/authentication.ts" 43 const parts = outputFile.replace('src/generated/', '').split('/'); 44 if (parts.length < 2) continue; 45 46 const dir = parts[0]; 47 const moduleFile = parts.slice(1).join('/'); 48 49 let svc = serviceMap.get(dir); 50 if (!svc) { 51 svc = { 52 name: dirToName(dir), 53 dir, 54 modules: [], 55 ius: [], 56 port: nextPort++, 57 }; 58 serviceMap.set(dir, svc); 59 } 60 svc.modules.push(moduleFile); 61 svc.ius.push(iu); 62 } 63 } 64 65 return [...serviceMap.values()].sort((a, b) => a.dir.localeCompare(b.dir)); 66} 67 68/** 69 * Generate all scaffold files. 70 */ 71export function generateScaffold( 72 services: ServiceDescriptor[], 73 projectName: string = 'phoenix-project', 74 target?: ResolvedTarget | null, 75): ScaffoldResult { 76 const files = new Map<string, string>(); 77 78 // Architecture shared files (db.ts, app.ts, etc.) 79 if (target) { 80 const arch = target.architecture; 81 const rt = target.runtime; 82 for (const [path, content] of Object.entries(rt.sharedFiles)) { 83 files.set(path, content); 84 } 85 86 // Generate architecture-specific server entry point that mounts all generated modules 87 const routeImports: string[] = []; 88 const routeMounts: string[] = []; 89 for (const svc of services) { 90 for (let i = 0; i < svc.modules.length; i++) { 91 const mod = svc.modules[i]; 92 const iu = svc.ius[i]; 93 const modName = mod.replace('.ts', '').replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '_'); 94 const importPath = `./generated/${svc.dir}/${mod.replace('.ts', '.js')}`; 95 routeImports.push(`import ${modName} from '${importPath}';`); 96 // Derive mount path from IU name: "Todos" → "/todos", "Categories" → "/categories" 97 const iuName = iu?.name ?? mod.replace('.ts', ''); 98 const lowerName = iuName.toLowerCase(); 99 // Web interface / UI modules mount at root 100 const isWebUI = /\b(web|ui|frontend|interface|page|dashboard)\b/.test(lowerName); 101 const prefix = isWebUI ? '' : '/' + lowerName.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); 102 routeMounts.push(`mount('${prefix}', ${modName});`); 103 } 104 } 105 106 const serverContent = [ 107 `import { serve } from '@hono/node-server';`, 108 `import { app, mount } from './app.js';`, 109 `import { runMigrations } from './db.js';`, 110 ``, 111 `// Generated route modules`, 112 ...routeImports, 113 ``, 114 `// Mount routes`, 115 ...routeMounts, 116 ``, 117 `const port = parseInt(process.env.PORT ?? '3000', 10);`, 118 `runMigrations();`, 119 `console.log(\`Server running at http://localhost:\${port}\`);`, 120 `serve({ fetch: app.fetch, port });`, 121 ``, 122 ].join('\n'); 123 124 files.set('src/server.ts', serverContent); 125 } 126 127 for (const svc of services) { 128 // Service barrel index 129 files.set( 130 `src/generated/${svc.dir}/index.ts`, 131 generateServiceIndex(svc), 132 ); 133 134 if (!target) { 135 // Only generate per-service servers when no architecture is set 136 files.set( 137 `src/generated/${svc.dir}/server.ts`, 138 generateServiceServer(svc), 139 ); 140 } 141 142 // Service tests 143 files.set( 144 `src/generated/${svc.dir}/__tests__/${svc.dir}.test.ts`, 145 target ? generateArchTests(svc) : generateServiceTests(svc), 146 ); 147 } 148 149 // Root index 150 files.set('src/generated/index.ts', generateRootIndex(services)); 151 152 // Project config 153 files.set('package.json', generatePackageJson(services, projectName, target)); 154 files.set('tsconfig.json', generateTsConfig()); 155 files.set('vitest.config.ts', generateVitestConfig()); 156 157 return { files }; 158} 159 160/** 161 * Generate tests for architecture-based services. 162 */ 163function generateArchTests(svc: ServiceDescriptor): string { 164 const lines: string[] = []; 165 lines.push(`/**`); 166 lines.push(` * ${svc.name} — Generated Tests`); 167 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 168 lines.push(` */`); 169 lines.push(``); 170 lines.push(`import { describe, it, expect } from 'vitest';`); 171 172 for (const mod of svc.modules) { 173 const importName = mod.replace('.ts', '').replace(/-/g, '_'); 174 lines.push(`import ${importName} from '../${mod.replace('.ts', '.js')}';`); 175 } 176 177 lines.push(``); 178 lines.push(`describe('${svc.name} modules', () => {`); 179 180 for (const mod of svc.modules) { 181 const importName = mod.replace('.ts', '').replace(/-/g, '_'); 182 const displayName = mod.replace('.ts', '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); 183 lines.push(` describe('${displayName}', () => {`); 184 lines.push(` it('exports a Hono router as default', () => {`); 185 lines.push(` expect(${importName}).toBeDefined();`); 186 lines.push(` expect(typeof ${importName}.fetch).toBe('function');`); 187 lines.push(` });`); 188 lines.push(` });`); 189 } 190 191 lines.push(`});`); 192 lines.push(``); 193 return lines.join('\n'); 194} 195 196// ─── Service Index ─────────────────────────────────────────────────────────── 197 198function generateServiceIndex(svc: ServiceDescriptor): string { 199 const lines: string[] = []; 200 201 lines.push(`/**`); 202 lines.push(` * ${svc.name}`); 203 lines.push(` *`); 204 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 205 lines.push(` * Barrel export for all ${svc.name} modules.`); 206 lines.push(` */`); 207 lines.push(''); 208 209 for (const mod of svc.modules) { 210 const modName = mod.replace(/\.ts$/, ''); 211 const importName = toCamelCase(modName); 212 lines.push(`export * as ${importName} from './${modName}.js';`); 213 } 214 lines.push(''); 215 216 return lines.join('\n'); 217} 218 219// ─── Web Client Detection ──────────────────────────────────────────────────── 220 221const WEB_HINTS = ['ui', 'client', 'frontend', 'web', 'page', 'view', 'html', 'css', 'style', 'lobby', 'dashboard', 'display']; 222 223/** 224 * Detect whether a service is a web client (should serve HTML). 225 * Heuristic: service dir or module names contain web-related terms. 226 */ 227function isWebClient(svc: ServiceDescriptor): boolean { 228 const dirWords = svc.dir.toLowerCase().split('-'); 229 if (dirWords.some(w => WEB_HINTS.includes(w))) return true; 230 231 const moduleWords = svc.modules.flatMap(m => m.replace(/\.ts$/, '').toLowerCase().split('-')); 232 const webModuleCount = moduleWords.filter(w => WEB_HINTS.includes(w)).length; 233 return webModuleCount >= 2; 234} 235 236// ─── Service Server ────────────────────────────────────────────────────────── 237 238function generateServiceServer(svc: ServiceDescriptor): string { 239 if (isWebClient(svc)) { 240 return generateWebClientServer(svc); 241 } 242 return generateApiServer(svc); 243} 244 245function generateApiServer(svc: ServiceDescriptor): string { 246 const lines: string[] = []; 247 const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`; 248 249 lines.push(`/**`); 250 lines.push(` * ${svc.name} — HTTP Server`); 251 lines.push(` *`); 252 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 253 lines.push(` * Provides health check, metrics, and module endpoints.`); 254 lines.push(` */`); 255 lines.push(''); 256 lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`); 257 lines.push(''); 258 259 // Import modules 260 for (const mod of svc.modules) { 261 const modName = mod.replace(/\.ts$/, ''); 262 const importName = toCamelCase(modName); 263 lines.push(`import * as ${importName} from './${modName}.js';`); 264 } 265 lines.push(''); 266 267 // Metrics tracking 268 lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`); 269 lines.push(''); 270 lines.push(`const _svcMetrics = {`); 271 lines.push(` requests_total: 0,`); 272 lines.push(` requests_by_path: {} as Record<string, number>,`); 273 lines.push(` errors_total: 0,`); 274 lines.push(` uptime_start: Date.now(),`); 275 lines.push(`};`); 276 lines.push(''); 277 278 // Module registry 279 lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`); 280 lines.push(''); 281 lines.push(`const _svcModules = {`); 282 for (const mod of svc.modules) { 283 const modName = mod.replace(/\.ts$/, ''); 284 const importName = toCamelCase(modName); 285 lines.push(` '${modName}': ${importName},`); 286 } 287 lines.push(`};`); 288 lines.push(''); 289 290 // Router 291 lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`); 292 lines.push(''); 293 lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`); 294 lines.push(''); 295 lines.push(`const routes: Record<string, Handler> = {`); 296 lines.push(` '/health': (_req, res) => {`); 297 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 298 lines.push(` res.end(JSON.stringify({`); 299 lines.push(` status: 'ok',`); 300 lines.push(` service: '${svc.name}',`); 301 lines.push(` uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`); 302 lines.push(` modules: Object.keys(_svcModules),`); 303 lines.push(` }));`); 304 lines.push(` },`); 305 lines.push(''); 306 lines.push(` '/metrics': (_req, res) => {`); 307 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 308 lines.push(` res.end(JSON.stringify({`); 309 lines.push(` ..._svcMetrics,`); 310 lines.push(` uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`); 311 lines.push(` }, null, 2));`); 312 lines.push(` },`); 313 lines.push(''); 314 lines.push(` '/modules': (_req, res) => {`); 315 lines.push(` const info = Object.entries(_svcModules).map(([name, mod]) => {`); 316 lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`); 317 lines.push(` return {`); 318 lines.push(` name,`); 319 lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`); 320 lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`); 321 lines.push(` };`); 322 lines.push(` });`); 323 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 324 lines.push(` res.end(JSON.stringify(info, null, 2));`); 325 lines.push(` },`); 326 lines.push(`};`); 327 lines.push(''); 328 329 // Server factory 330 lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`); 331 lines.push(''); 332 lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`); 333 lines.push(` const url = req.url ?? '/';`); 334 lines.push(` const path = url.split('?')[0];`); 335 lines.push(''); 336 lines.push(` _svcMetrics.requests_total++;`); 337 lines.push(` _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1;`); 338 lines.push(''); 339 lines.push(` const handler = routes[path];`); 340 lines.push(` if (handler) {`); 341 lines.push(` try {`); 342 lines.push(` handler(req, res);`); 343 lines.push(` } catch (err) {`); 344 lines.push(` _svcMetrics.errors_total++;`); 345 lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`); 346 lines.push(` res.end(JSON.stringify({ error: String(err) }));`); 347 lines.push(` }`); 348 lines.push(` } else {`); 349 lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`); 350 lines.push(` res.end(JSON.stringify({`); 351 lines.push(` error: 'Not Found',`); 352 lines.push(` path,`); 353 lines.push(` available: Object.keys(routes),`); 354 lines.push(` }));`); 355 lines.push(` }`); 356 lines.push(`}`); 357 lines.push(''); 358 359 lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`); 360 lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`); 361 lines.push(` const server = createServer(handleRequest);`); 362 lines.push(` let actualPort = requestedPort;`); 363 lines.push(''); 364 lines.push(` const ready = new Promise<void>(resolve => {`); 365 lines.push(` server.listen(requestedPort, () => {`); 366 lines.push(` const addr = server.address();`); 367 lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`); 368 lines.push(` result.port = actualPort;`); 369 lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`); 370 lines.push(` console.log(\` /health — health check\`);`); 371 lines.push(` console.log(\` /metrics — request metrics\`);`); 372 lines.push(` console.log(\` /modules — registered modules\`);`); 373 lines.push(` resolve();`); 374 lines.push(` });`); 375 lines.push(` });`); 376 lines.push(''); 377 lines.push(` const result = { server, port: actualPort, ready };`); 378 lines.push(` return result;`); 379 lines.push(`}`); 380 lines.push(''); 381 382 // Main 383 lines.push(`// Start when run directly`); 384 lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`); 385 lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`); 386 lines.push(`if (isMain) {`); 387 lines.push(` startServer();`); 388 lines.push(`}`); 389 lines.push(''); 390 391 return lines.join('\n'); 392} 393 394// ─── Web Client Server ────────────────────────────────────────────────────── 395 396function generateWebClientServer(svc: ServiceDescriptor): string { 397 const portVar = `${svc.dir.toUpperCase().replace(/-/g, '_')}_PORT`; 398 399 // Categorize modules by role 400 const styleModules = svc.modules.filter(m => /styl/i.test(m) || /css/i.test(m) || /theme/i.test(m)); 401 const uiModules = svc.modules.filter(m => !styleModules.includes(m)); 402 403 const imports = svc.modules.map(mod => { 404 const modName = mod.replace(/\.ts$/, ''); 405 const importName = toCamelCase(modName); 406 return { modName, importName }; 407 }); 408 409 const lines: string[] = []; 410 411 lines.push(`/**`); 412 lines.push(` * ${svc.name} — Web Server`); 413 lines.push(` *`); 414 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 415 lines.push(` * Serves the web client HTML, plus health/metrics/modules endpoints.`); 416 lines.push(` */`); 417 lines.push(''); 418 lines.push(`import { createServer, IncomingMessage, ServerResponse } from 'node:http';`); 419 lines.push(''); 420 421 for (const { modName, importName } of imports) { 422 lines.push(`import * as ${importName} from './${modName}.js';`); 423 } 424 lines.push(''); 425 426 // Metrics 427 lines.push(`// ─── Metrics ─────────────────────────────────────────────────────────────────`); 428 lines.push(''); 429 lines.push(`const _svcMetrics = {`); 430 lines.push(` requests_total: 0,`); 431 lines.push(` requests_by_path: {} as Record<string, number>,`); 432 lines.push(` errors_total: 0,`); 433 lines.push(` uptime_start: Date.now(),`); 434 lines.push(`};`); 435 lines.push(''); 436 437 // Module registry 438 lines.push(`// ─── Module Registry ─────────────────────────────────────────────────────────`); 439 lines.push(''); 440 lines.push(`const _svcModules = {`); 441 for (const { modName, importName } of imports) { 442 lines.push(` '${modName}': ${importName},`); 443 } 444 lines.push(`};`); 445 lines.push(''); 446 447 // HTML renderer 448 lines.push(`// ─── HTML Renderer ───────────────────────────────────────────────────────────`); 449 lines.push(''); 450 lines.push(`function renderPage(): string {`); 451 lines.push(` // Collect CSS from style modules`); 452 lines.push(` let css = '';`); 453 454 for (const mod of styleModules) { 455 const importName = toCamelCase(mod.replace(/\.ts$/, '')); 456 // Try common patterns: generateCSS(), getCSS(), css, styles 457 lines.push(` try {`); 458 lines.push(` const styleMod = ${importName} as Record<string, unknown>;`); 459 lines.push(` for (const key of Object.keys(styleMod)) {`); 460 lines.push(` const val = styleMod[key];`); 461 lines.push(` if (typeof val === 'function' && /css|style/i.test(key)) {`); 462 lines.push(` const result = (val as Function)();`); 463 lines.push(` if (typeof result === 'string') css += result;`); 464 lines.push(` else if (result && typeof result === 'object' && 'generateCSS' in result) {`); 465 lines.push(` css += (result as { generateCSS: () => string }).generateCSS();`); 466 lines.push(` }`); 467 lines.push(` }`); 468 lines.push(` }`); 469 lines.push(` } catch { /* style module may not have expected exports */ }`); 470 } 471 472 if (styleModules.length === 0) { 473 lines.push(` css = 'body { font-family: system-ui, sans-serif; margin: 2rem; }';`); 474 } 475 lines.push(''); 476 477 lines.push(` // Collect HTML from UI modules`); 478 lines.push(` const sections: string[] = [];`); 479 480 for (const mod of uiModules) { 481 const importName = toCamelCase(mod.replace(/\.ts$/, '')); 482 const displayName = mod.replace(/\.ts$/, '').split('-').map((w: string) => w[0].toUpperCase() + w.slice(1)).join(' '); 483 lines.push(` try {`); 484 lines.push(` const uiMod = ${importName} as Record<string, unknown>;`); 485 lines.push(` for (const key of Object.keys(uiMod)) {`); 486 lines.push(` const val = uiMod[key];`); 487 lines.push(` // Look for factory functions that return objects with render/renderHTML`); 488 lines.push(` if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) {`); 489 lines.push(` try {`); 490 lines.push(` const instance = (val as Function)();`); 491 lines.push(` if (typeof instance === 'string' && instance.includes('<')) {`); 492 lines.push(` sections.push(instance);`); 493 lines.push(` } else if (instance && typeof instance === 'object') {`); 494 lines.push(` const obj = instance as Record<string, unknown>;`); 495 lines.push(` if (typeof obj.render === 'function') {`); 496 lines.push(` const html = (obj.render as Function)();`); 497 lines.push(` if (typeof html === 'string') sections.push(html);`); 498 lines.push(` } else if (typeof obj.renderHTML === 'function') {`); 499 lines.push(` const html = (obj.renderHTML as Function)();`); 500 lines.push(` if (typeof html === 'string') sections.push(html);`); 501 lines.push(` }`); 502 lines.push(` }`); 503 lines.push(` } catch { /* factory may require args */ }`); 504 lines.push(` }`); 505 lines.push(` }`); 506 lines.push(` } catch { /* module may not have renderable exports */ }`); 507 } 508 lines.push(''); 509 510 lines.push(` return \`<!DOCTYPE html>`); 511 lines.push(`<html lang="en">`); 512 lines.push(`<head>`); 513 lines.push(` <meta charset="utf-8">`); 514 lines.push(` <meta name="viewport" content="width=device-width, initial-scale=1">`); 515 lines.push(` <title>${svc.name}</title>`); 516 lines.push(` <style>\${css}</style>`); 517 lines.push(`</head>`); 518 lines.push(`<body>`); 519 lines.push(` <div class="game-container">`); 520 lines.push(` \${sections.join('\\n')}`); 521 lines.push(` </div>`); 522 lines.push(`</body>`); 523 lines.push(`</html>\`;`); 524 lines.push(`}`); 525 lines.push(''); 526 527 // Router 528 lines.push(`// ─── Router ──────────────────────────────────────────────────────────────────`); 529 lines.push(''); 530 lines.push(`type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;`); 531 lines.push(''); 532 lines.push(`const routes: Record<string, Handler> = {`); 533 534 // HTML index 535 lines.push(` '/': (_req, res) => {`); 536 lines.push(` res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });`); 537 lines.push(` res.end(renderPage());`); 538 lines.push(` },`); 539 lines.push(''); 540 541 // Health 542 lines.push(` '/health': (_req, res) => {`); 543 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 544 lines.push(` res.end(JSON.stringify({`); 545 lines.push(` status: 'ok',`); 546 lines.push(` service: '${svc.name}',`); 547 lines.push(` uptime: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`); 548 lines.push(` modules: Object.keys(_svcModules),`); 549 lines.push(` }));`); 550 lines.push(` },`); 551 lines.push(''); 552 lines.push(` '/metrics': (_req, res) => {`); 553 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 554 lines.push(` res.end(JSON.stringify({`); 555 lines.push(` ..._svcMetrics,`); 556 lines.push(` uptime_seconds: Math.floor((Date.now() - _svcMetrics.uptime_start) / 1000),`); 557 lines.push(` }, null, 2));`); 558 lines.push(` },`); 559 lines.push(''); 560 lines.push(` '/modules': (_req, res) => {`); 561 lines.push(` const info = Object.entries(_svcModules).map(([name, mod]) => {`); 562 lines.push(` const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined;`); 563 lines.push(` return {`); 564 lines.push(` name,`); 565 lines.push(` risk_tier: phoenix?.risk_tier ?? 'unknown',`); 566 lines.push(` exports: Object.keys(mod).filter(k => k !== '_phoenix'),`); 567 lines.push(` };`); 568 lines.push(` });`); 569 lines.push(` res.writeHead(200, { 'Content-Type': 'application/json' });`); 570 lines.push(` res.end(JSON.stringify(info, null, 2));`); 571 lines.push(` },`); 572 lines.push(`};`); 573 lines.push(''); 574 575 // Server factory 576 lines.push(`// ─── Server ──────────────────────────────────────────────────────────────────`); 577 lines.push(''); 578 lines.push(`function handleRequest(req: IncomingMessage, res: ServerResponse): void {`); 579 lines.push(` const url = req.url ?? '/';`); 580 lines.push(` const path = url.split('?')[0];`); 581 lines.push(''); 582 lines.push(` _svcMetrics.requests_total++;`); 583 lines.push(` _svcMetrics.requests_by_path[path] = (_svcMetrics.requests_by_path[path] ?? 0) + 1;`); 584 lines.push(''); 585 lines.push(` const handler = routes[path];`); 586 lines.push(` if (handler) {`); 587 lines.push(` try {`); 588 lines.push(` handler(req, res);`); 589 lines.push(` } catch (err) {`); 590 lines.push(` _svcMetrics.errors_total++;`); 591 lines.push(` res.writeHead(500, { 'Content-Type': 'application/json' });`); 592 lines.push(` res.end(JSON.stringify({ error: String(err) }));`); 593 lines.push(` }`); 594 lines.push(` } else {`); 595 lines.push(` res.writeHead(404, { 'Content-Type': 'application/json' });`); 596 lines.push(` res.end(JSON.stringify({`); 597 lines.push(` error: 'Not Found',`); 598 lines.push(` path,`); 599 lines.push(` available: Object.keys(routes),`); 600 lines.push(` }));`); 601 lines.push(` }`); 602 lines.push(`}`); 603 lines.push(''); 604 605 lines.push(`export function startServer(port?: number): { server: ReturnType<typeof createServer>; port: number; ready: Promise<void> } {`); 606 lines.push(` const requestedPort = port ?? parseInt(process.env.${portVar} ?? process.env.PORT ?? '${svc.port}', 10);`); 607 lines.push(` const server = createServer(handleRequest);`); 608 lines.push(` let actualPort = requestedPort;`); 609 lines.push(''); 610 lines.push(` const ready = new Promise<void>(resolve => {`); 611 lines.push(` server.listen(requestedPort, () => {`); 612 lines.push(` const addr = server.address();`); 613 lines.push(` if (addr && typeof addr === 'object') actualPort = addr.port;`); 614 lines.push(` result.port = actualPort;`); 615 lines.push(` console.log(\`${svc.name} listening on http://localhost:\${actualPort}\`);`); 616 lines.push(` console.log(\` / — web client\`);`); 617 lines.push(` console.log(\` /health — health check\`);`); 618 lines.push(` console.log(\` /metrics — request metrics\`);`); 619 lines.push(` console.log(\` /modules — registered modules\`);`); 620 lines.push(` resolve();`); 621 lines.push(` });`); 622 lines.push(` });`); 623 lines.push(''); 624 lines.push(` const result = { server, port: actualPort, ready };`); 625 lines.push(` return result;`); 626 lines.push(`}`); 627 lines.push(''); 628 629 // Main 630 lines.push(`// Start when run directly`); 631 lines.push(`const isMain = process.argv[1]?.endsWith('/${svc.dir}/server.js') ||`); 632 lines.push(` process.argv[1]?.endsWith('/${svc.dir}/server.ts');`); 633 lines.push(`if (isMain) {`); 634 lines.push(` startServer();`); 635 lines.push(`}`); 636 lines.push(''); 637 638 return lines.join('\n'); 639} 640 641// ─── Service Tests ─────────────────────────────────────────────────────────── 642 643function generateServiceTests(svc: ServiceDescriptor): string { 644 const lines: string[] = []; 645 646 lines.push(`/**`); 647 lines.push(` * ${svc.name} — Generated Tests`); 648 lines.push(` *`); 649 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 650 lines.push(` * Tests module structure, server health, and Phoenix traceability.`); 651 lines.push(` */`); 652 lines.push(''); 653 lines.push(`import { describe, it, expect, afterAll } from 'vitest';`); 654 lines.push(`import { startServer } from '../server.js';`); 655 lines.push(''); 656 657 // Import modules 658 for (const mod of svc.modules) { 659 const modName = mod.replace(/\.ts$/, ''); 660 const importName = toCamelCase(modName); 661 lines.push(`import * as ${importName} from '../${modName}.js';`); 662 } 663 lines.push(''); 664 665 // Module structure tests 666 lines.push(`describe('${svc.name} modules', () => {`); 667 for (const mod of svc.modules) { 668 const modName = mod.replace(/\.ts$/, ''); 669 const importName = toCamelCase(modName); 670 const iu = svc.ius.find(u => u.output_files.some(f => f.includes(modName))); 671 const displayName = iu?.name || modName; 672 673 lines.push(` describe('${displayName}', () => {`); 674 lines.push(` it('exports Phoenix traceability metadata', () => {`); 675 lines.push(` expect(${importName}._phoenix).toBeDefined();`); 676 lines.push(` expect(${importName}._phoenix.name).toBe('${displayName}');`); 677 lines.push(` expect(${importName}._phoenix.risk_tier).toBeTruthy();`); 678 lines.push(` });`); 679 lines.push(''); 680 lines.push(` it('has exported functions', () => {`); 681 lines.push(` const exports = Object.keys(${importName}).filter(k => k !== '_phoenix');`); 682 lines.push(` expect(exports.length).toBeGreaterThan(0);`); 683 lines.push(` });`); 684 lines.push(` });`); 685 lines.push(''); 686 } 687 lines.push(`});`); 688 lines.push(''); 689 690 // Server tests 691 lines.push(`describe('${svc.name} server', () => {`); 692 lines.push(` const instance = startServer(0); // random port`); 693 lines.push(''); 694 lines.push(` afterAll(() => new Promise<void>(resolve => instance.server.close(() => resolve())));`); 695 lines.push(''); 696 lines.push(` it('GET /health returns 200', async () => {`); 697 lines.push(` await instance.ready;`); 698 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/health\`);`); 699 lines.push(` expect(res.status).toBe(200);`); 700 lines.push(` const body = await res.json() as Record<string, unknown>;`); 701 lines.push(` expect(body.status).toBe('ok');`); 702 lines.push(` expect(body.service).toBe('${svc.name}');`); 703 lines.push(` });`); 704 lines.push(''); 705 lines.push(` it('GET /metrics returns request counts', async () => {`); 706 lines.push(` await instance.ready;`); 707 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/metrics\`);`); 708 lines.push(` expect(res.status).toBe(200);`); 709 lines.push(` const body = await res.json() as Record<string, unknown>;`); 710 lines.push(` expect(typeof body.requests_total).toBe('number');`); 711 lines.push(` });`); 712 lines.push(''); 713 lines.push(` it('GET /modules lists all registered modules', async () => {`); 714 lines.push(` await instance.ready;`); 715 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/modules\`);`); 716 lines.push(` expect(res.status).toBe(200);`); 717 lines.push(` const body = await res.json() as Array<Record<string, unknown>>;`); 718 lines.push(` expect(body.length).toBe(${svc.modules.length});`); 719 lines.push(` });`); 720 lines.push(''); 721 lines.push(` it('GET /unknown returns 404', async () => {`); 722 lines.push(` await instance.ready;`); 723 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/unknown\`);`); 724 lines.push(` expect(res.status).toBe(404);`); 725 lines.push(` });`); 726 727 // Web client: test GET / serves HTML 728 if (isWebClient(svc)) { 729 lines.push(''); 730 lines.push(` it('GET / serves HTML page', async () => {`); 731 lines.push(` await instance.ready;`); 732 lines.push(` const res = await fetch(\`http://localhost:\${instance.port}/\`);`); 733 lines.push(` expect(res.status).toBe(200);`); 734 lines.push(` const ct = res.headers.get('content-type') ?? '';`); 735 lines.push(` expect(ct).toContain('text/html');`); 736 lines.push(` const body = await res.text();`); 737 lines.push(` expect(body).toContain('<!DOCTYPE html>');`); 738 lines.push(` expect(body).toContain('<title>${svc.name}</title>');`); 739 lines.push(` });`); 740 } 741 742 lines.push(`});`); 743 lines.push(''); 744 745 return lines.join('\n'); 746} 747 748// ─── Root Index ────────────────────────────────────────────────────────────── 749 750function generateRootIndex(services: ServiceDescriptor[]): string { 751 const lines: string[] = []; 752 753 lines.push(`/**`); 754 lines.push(` * Phoenix VCS — Generated Service Registry`); 755 lines.push(` *`); 756 lines.push(` * AUTO-GENERATED by Phoenix VCS`); 757 lines.push(` */`); 758 lines.push(''); 759 760 for (const svc of services) { 761 const importName = toCamelCase(svc.dir); 762 lines.push(`export * as ${importName} from './${svc.dir}/index.js';`); 763 } 764 lines.push(''); 765 766 lines.push(`export const services = [`); 767 for (const svc of services) { 768 lines.push(` { name: '${svc.name}', dir: '${svc.dir}', port: ${svc.port}, modules: ${svc.modules.length} },`); 769 } 770 lines.push(`] as const;`); 771 lines.push(''); 772 773 return lines.join('\n'); 774} 775 776// ─── Project Config ────────────────────────────────────────────────────────── 777 778function generatePackageJson( 779 services: ServiceDescriptor[], 780 projectName: string, 781 target?: ResolvedTarget | null, 782): string { 783 let scripts: Record<string, string> = { 784 build: 'tsc', 785 typecheck: 'tsc --noEmit', 786 test: 'vitest run', 787 'test:watch': 'vitest', 788 }; 789 790 if (target) { 791 const arch = target.architecture; 792 const rt = target.runtime; 793 // Architecture provides its own scripts 794 const archScripts = (rt.packageExtras?.scripts ?? {}) as Record<string, string>; 795 scripts = { ...scripts, ...archScripts }; 796 } else { 797 // Add start script per service (build first, then run) 798 for (const svc of services) { 799 scripts[`start:${svc.dir}`] = `tsc && node dist/generated/${svc.dir}/server.js`; 800 } 801 if (services.length > 0) { 802 scripts.start = `tsc && node dist/generated/${services[0].dir}/server.js`; 803 } 804 } 805 806 const pkg: Record<string, unknown> = { 807 name: projectName, 808 version: '0.1.0', 809 description: `Generated by Phoenix VCS — ${services.length} services`, 810 type: 'module', 811 scripts, 812 }; 813 814 if (target) { 815 const arch = target.architecture; 816 const rt = target.runtime; 817 pkg.dependencies = rt.packages; 818 pkg.devDependencies = rt.devPackages; 819 } else { 820 pkg.devDependencies = { 821 typescript: '^5.4.0', 822 vitest: '^2.0.0', 823 '@types/node': '^22.0.0', 824 }; 825 } 826 827 return JSON.stringify(pkg, null, 2) + '\n'; 828} 829 830function generateTsConfig(): string { 831 const config = { 832 compilerOptions: { 833 target: 'ES2022', 834 module: 'ESNext', 835 moduleResolution: 'bundler', 836 declaration: true, 837 outDir: 'dist', 838 rootDir: 'src', 839 strict: true, 840 esModuleInterop: true, 841 skipLibCheck: true, 842 forceConsistentCasingInFileNames: true, 843 resolveJsonModule: true, 844 sourceMap: true, 845 }, 846 include: ['src/**/*'], 847 exclude: ['node_modules', 'dist'], 848 }; 849 850 return JSON.stringify(config, null, 2) + '\n'; 851} 852 853function generateVitestConfig(): string { 854 return `import { defineConfig } from 'vitest/config'; 855 856export default defineConfig({ 857 test: { 858 include: ['src/**/__tests__/**/*.test.ts'], 859 }, 860}); 861`; 862} 863 864// ─── Utilities ─────────────────────────────────────────────────────────────── 865 866function dirToName(dir: string): string { 867 return dir 868 .split('-') 869 .map(w => w.charAt(0).toUpperCase() + w.slice(1)) 870 .join(' '); 871} 872 873function toCamelCase(str: string): string { 874 return str 875 .replace(/[^a-zA-Z0-9]/g, ' ') 876 .split(/\s+/) 877 .filter(Boolean) 878 .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 879 .join(''); 880}