Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 728 lines 26 kB view raw
1/** 2 * Regeneration Engine — generates code for each IU. 3 * 4 * Two modes: 5 * - Stub mode (no LLM): produces typed skeletons with throw stubs. 6 * - LLM mode: sends IU contract + canonical requirements to an LLM 7 * and produces real, working implementations. 8 * 9 * The LLM provider is pluggable (Anthropic, OpenAI, etc.) 10 * and auto-detected from env vars. 11 */ 12 13import { execSync } from 'node:child_process'; 14import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; 15import { join, dirname } from 'node:path'; 16import type { ImplementationUnit } from './models/iu.js'; 17import type { CanonicalNode } from './models/canonical.js'; 18import type { IUManifest, RegenMetadata, FileManifestEntry } from './models/manifest.js'; 19import type { LLMProvider } from './llm/provider.js'; 20import { buildPrompt, getSystemPrompt } from './llm/prompt.js'; 21import type { ResolvedTarget } from './models/architecture.js'; 22import { sha256 } from './semhash.js'; 23 24const TOOLCHAIN_VERSION = 'phoenix-regen/0.1.0'; 25 26export interface RegenResult { 27 iu_id: string; 28 files: Map<string, string>; // path → content 29 manifest: IUManifest; 30} 31 32export interface RegenContext { 33 /** LLM provider for real code generation. Omit for stub mode. */ 34 llm?: LLMProvider; 35 /** All canonical nodes (needed for LLM prompt context). */ 36 canonNodes?: CanonicalNode[]; 37 /** All IUs (for sibling module context). */ 38 allIUs?: ImplementationUnit[]; 39 /** Project root directory (for typecheck-and-retry). */ 40 projectRoot?: string; 41 /** Architecture target (e.g., sqlite-web-api). */ 42 target?: ResolvedTarget | null; 43 /** Callback for progress reporting. */ 44 onProgress?: (iu: ImplementationUnit, status: 'start' | 'done' | 'error', message?: string) => void; 45} 46 47/** 48 * Generate code for a single IU. 49 * Uses LLM if provided in context, otherwise falls back to stubs. 50 */ 51export async function generateIU(iu: ImplementationUnit, ctx?: RegenContext): Promise<RegenResult> { 52 const files = new Map<string, string>(); 53 const modelId = ctx?.llm ? `${ctx.llm.name}/${ctx.llm.model}` : 'stub-generator/1.0'; 54 55 for (const outputPath of iu.output_files) { 56 let content: string; 57 58 if (ctx?.llm && ctx.canonNodes) { 59 ctx.onProgress?.(iu, 'start', `Generating ${iu.name} via ${ctx.llm.name}`); 60 try { 61 content = await generateWithLLM(iu, ctx.llm, ctx.canonNodes, ctx.allIUs, ctx.projectRoot, ctx.target); 62 ctx.onProgress?.(iu, 'done'); 63 } catch (err) { 64 const msg = err instanceof Error ? err.message : String(err); 65 ctx.onProgress?.(iu, 'error', msg); 66 // Fall back to stub on LLM failure 67 content = ctx.target ? generateArchStub(iu) : generateModule(iu); 68 } 69 } else { 70 content = ctx?.target ? generateArchStub(iu) : generateModule(iu); 71 } 72 73 files.set(outputPath, content); 74 } 75 76 // Build manifest entries 77 const fileEntries: Record<string, FileManifestEntry> = {}; 78 for (const [path, content] of files) { 79 fileEntries[path] = { 80 path, 81 content_hash: sha256(content), 82 size: content.length, 83 }; 84 } 85 86 const now = new Date().toISOString(); 87 const promptpackHash = sha256(JSON.stringify(iu.contract)); 88 89 const metadata: RegenMetadata = { 90 model_id: modelId, 91 promptpack_hash: promptpackHash, 92 toolchain_version: TOOLCHAIN_VERSION, 93 generated_at: now, 94 }; 95 96 return { 97 iu_id: iu.iu_id, 98 files, 99 manifest: { 100 iu_id: iu.iu_id, 101 iu_name: iu.name, 102 files: fileEntries, 103 regen_metadata: metadata, 104 }, 105 }; 106} 107 108/** 109 * Generate code for all IUs. Runs sequentially to respect LLM rate limits. 110 */ 111export async function generateAll(ius: ImplementationUnit[], ctx?: RegenContext): Promise<RegenResult[]> { 112 const results: RegenResult[] = []; 113 for (const iu of ius) { 114 results.push(await generateIU(iu, ctx)); 115 } 116 return results; 117} 118 119// ─── LLM Generation ───────────────────────────────────────────────────────── 120 121const MAX_RETRIES = 2; 122 123/** 124 * Generate code for an IU using an LLM provider. 125 * 126 * Two modes: 127 * - Template mode (when runtime target provides moduleTemplate): LLM fills in 128 * marked sections only. Structure is guaranteed by the template. 129 * - Freeform mode (no template): LLM generates the entire module. 130 * 131 * Both modes include typecheck-and-retry. 132 */ 133async function generateWithLLM( 134 iu: ImplementationUnit, 135 llm: LLMProvider, 136 canonNodes: CanonicalNode[], 137 allIUs?: ImplementationUnit[], 138 projectRoot?: string, 139 target?: ResolvedTarget | null, 140): Promise<string> { 141 // Find sibling modules in the same service 142 const iuDir = iu.output_files[0]?.split('/').slice(0, -1).join('/'); 143 const siblings = allIUs 144 ?.filter(other => other.iu_id !== iu.iu_id && other.output_files[0]?.startsWith(iuDir || '')) 145 .map(other => other.name) ?? []; 146 147 const systemPrompt = getSystemPrompt(target); 148 const prompt = buildPrompt(iu, canonNodes, siblings, target); 149 const template = target?.runtime.moduleTemplate; 150 151 let code: string; 152 153 if (template) { 154 // Template mode: LLM fills in sections, we splice into template 155 const raw = await llm.generate(prompt, { 156 system: systemPrompt, 157 temperature: 0.1, // lower temp for more deterministic section filling 158 maxTokens: 8192, 159 }); 160 161 code = assembleFromTemplate(template, raw, iu); 162 } else { 163 // Freeform mode 164 code = cleanCodeResponse(await llm.generate(prompt, { 165 system: systemPrompt, 166 temperature: 0.2, 167 maxTokens: 8192, 168 })); 169 } 170 171 // Typecheck-and-retry loop 172 if (projectRoot && iu.output_files[0]) { 173 for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { 174 const errors = typecheckFile(projectRoot, iu.output_files[0], code); 175 if (!errors) break; // clean! 176 177 // Feed errors back to LLM with the current code 178 const fixPrompt = buildFixPrompt(code, errors); 179 const fixResponse = await llm.generate(fixPrompt, { 180 system: systemPrompt, 181 temperature: 0.1, 182 maxTokens: 8192, 183 }); 184 185 if (template) { 186 code = assembleFromTemplate(template, fixResponse, iu); 187 } else { 188 code = cleanCodeResponse(fixResponse); 189 } 190 } 191 } 192 193 return code; 194} 195 196/** 197 * Repair LLM-generated code using the template as a structural guarantee. 198 * 199 * The LLM generates a full module. This function: 200 * 1. Strips any imports the LLM wrote and replaces with template imports 201 * 2. Ensures `export default router` exists 202 * 3. Ensures `_phoenix` metadata exists 203 * 4. Ensures `const router = new Hono()` exists 204 * 205 * This is more robust than section parsing — accepts whatever the LLM 206 * generates and fixes the structural parts that must be exact. 207 */ 208function assembleFromTemplate(template: string, llmResponse: string, iu: ImplementationUnit): string { 209 let code = cleanCodeResponse(llmResponse); 210 211 // Extract the template's fixed header (imports) 212 const templateLines = template.split('\n'); 213 const headerEnd = templateLines.findIndex(l => l.includes('__MIGRATIONS__')); 214 const templateHeader = templateLines.slice(0, Math.max(headerEnd, 0)).join('\n'); 215 216 // Strip LLM's import lines — we'll use the template's 217 const codeLines = code.split('\n'); 218 const bodyLines = codeLines.filter(line => { 219 const trimmed = line.trim(); 220 // Remove import statements that the template already provides 221 if (trimmed.startsWith('import ') && ( 222 trimmed.includes('hono') || 223 trimmed.includes('db.js') || 224 trimmed.includes('better-sqlite3') || 225 trimmed.includes('zod') 226 )) return false; 227 return true; 228 }); 229 let body = bodyLines.join('\n').trim(); 230 231 // Remove any duplicate "const router = new Hono()" — template has one, LLM might add another 232 const routerDecls = (body.match(/const router\s*=\s*new Hono\(\)/g) ?? []).length; 233 if (routerDecls > 1) { 234 // Keep only the first occurrence 235 let found = false; 236 body = body.split('\n').filter(line => { 237 if (line.includes('const router') && line.includes('new Hono()')) { 238 if (found) return false; 239 found = true; 240 } 241 return true; 242 }).join('\n'); 243 } 244 245 // Remove any "export default router" — we'll add it at the end 246 body = body.replace(/\nexport\s+default\s+router\s*;?\s*/g, '\n'); 247 248 // Remove any existing _phoenix export 249 body = body.replace(/\/\*\*[^]*?_phoenix[^]*?\*\/\s*export\s+const\s+_phoenix\s*=\s*\{[^}]*\}\s*as\s+const\s*;?\s*/g, ''); 250 body = body.replace(/export\s+const\s+_phoenix\s*=\s*\{[^}]*\}\s*as\s+const\s*;?\s*/g, ''); 251 252 // Ensure router declaration exists 253 if (!body.includes('const router') && !body.includes('new Hono()')) { 254 body = 'const router = new Hono();\n\n' + body; 255 } 256 257 // Build the phoenix metadata 258 const phoenixMeta = `/** @internal Phoenix VCS traceability — do not remove. */ 259export const _phoenix = { 260 iu_id: '${iu.iu_id}', 261 name: '${iu.name}', 262 risk_tier: '${iu.risk_tier}', 263 canon_ids: [${iu.source_canon_ids.length} as const], 264} as const;`; 265 266 // Fix SQL double-quote issue globally: SQLite treats "x" as column name, needs 'x' 267 // Replace ALL double-quoted SQL keywords that should be single-quoted 268 body = body.replace(/datetime\("now"\)/g, "datetime('now')"); 269 body = body.replace(/date\("now"\)/g, "date('now')"); 270 body = body.replace(/WHEN "(\w+)" THEN/g, "WHEN '$1' THEN"); 271 body = body.replace(/DEFAULT "([^"]+)"/g, "DEFAULT '$1'"); 272 body = body.replace(/< datetime\("now"\)/g, "< datetime('now')"); 273 body = body.replace(/< date\("now"\)/g, "< date('now')"); 274 // Catch any remaining datetime/date with double quotes 275 body = body.replace(/datetime\s*\(\s*"now"\s*\)/g, "datetime('now')"); 276 body = body.replace(/date\s*\(\s*"now"\s*\)/g, "date('now')"); 277 278 // Assemble: template header + LLM body + exports + metadata 279 return `${templateHeader}\n\n${body}\n\nexport default router;\n\n${phoenixMeta}\n`; 280} 281 282const MINIMAL_TSCONFIG = JSON.stringify({ 283 compilerOptions: { 284 target: 'ES2022', 285 module: 'Node16', 286 moduleResolution: 'Node16', 287 strict: true, 288 esModuleInterop: true, 289 skipLibCheck: true, 290 outDir: 'dist', 291 rootDir: 'src', 292 }, 293 include: ['src'], 294}, null, 2); 295 296/** 297 * Typecheck a single file by writing it to disk and running tsc. 298 * Returns error output or null if clean. 299 */ 300function typecheckFile(projectRoot: string, filePath: string, content: string): string | null { 301 const fullPath = join(projectRoot, filePath); 302 mkdirSync(dirname(fullPath), { recursive: true }); 303 writeFileSync(fullPath, content, 'utf8'); 304 305 // Ensure tsconfig.json exists for tsc 306 const tsconfigPath = join(projectRoot, 'tsconfig.json'); 307 const hadTsconfig = existsSync(tsconfigPath); 308 if (!hadTsconfig) { 309 writeFileSync(tsconfigPath, MINIMAL_TSCONFIG, 'utf8'); 310 } 311 312 try { 313 execSync('npx tsc --noEmit 2>&1', { 314 cwd: projectRoot, 315 timeout: 30000, 316 stdio: 'pipe', 317 }); 318 return null; // clean 319 } catch (err: unknown) { 320 const execErr = err as { stdout?: Buffer; stderr?: Buffer }; 321 const output = (execErr.stdout?.toString() || '') + (execErr.stderr?.toString() || ''); 322 // Filter to only errors from this file 323 const fileErrors = output 324 .split('\n') 325 .filter(line => line.includes(filePath)) 326 .join('\n') 327 .trim(); 328 return fileErrors || output.trim(); 329 } 330} 331 332/** 333 * Build a prompt asking the LLM to fix typecheck errors. 334 */ 335function buildFixPrompt(code: string, errors: string): string { 336 return `The following TypeScript module has compilation errors. Fix them. 337 338## Current code: 339\`\`\`typescript 340${code} 341\`\`\` 342 343## TypeScript errors: 344${errors} 345 346## Rules: 347- Output ONLY the fixed TypeScript module. No markdown fences, no explanation. 348- Do NOT import external packages. Use only Node.js built-in modules. 349- For WebSocket features, use node:http — do NOT import 'ws'. 350- For DOM/browser code, use string HTML templates — no DOM APIs. 351- The code must compile under strict mode. 352- Keep all existing exports and the _phoenix metadata constant. 353 354Output the complete fixed TypeScript module now.`; 355} 356 357/** 358 * Strip markdown code fences from LLM response. 359 */ 360function cleanCodeResponse(raw: string): string { 361 let code = raw.trim(); 362 363 // Remove ```typescript ... ``` or ```ts ... ``` or ``` ... ``` 364 const fenceMatch = code.match(/^```(?:typescript|ts)?\s*\n([\s\S]*?)\n```\s*$/); 365 if (fenceMatch) { 366 code = fenceMatch[1]; 367 } 368 369 // Also handle case where there's text before/after the fence 370 const innerMatch = code.match(/```(?:typescript|ts)?\s*\n([\s\S]*?)\n```/); 371 if (innerMatch && innerMatch[1].includes('export')) { 372 code = innerMatch[1]; 373 } 374 375 return code; 376} 377 378// ─── Module Generation ─────────────────────────────────────────────────────── 379 380/** 381 * Generate a minimal Hono router stub for architecture mode. 382 * Ensures fallback code still produces a valid default-export router. 383 */ 384function generateArchStub(iu: ImplementationUnit): string { 385 return `import { Hono } from 'hono'; 386 387const router = new Hono(); 388 389router.get('/', (c) => c.json({ stub: true, module: '${iu.name}', message: 'Not yet implemented' })); 390 391export default router; 392 393/** @internal Phoenix VCS traceability — do not remove. */ 394export const _phoenix = { 395 iu_id: '${iu.iu_id}', 396 name: '${iu.name}', 397 risk_tier: '${iu.risk_tier}', 398 canon_ids: [${iu.source_canon_ids.length} as const], 399} as const; 400`; 401} 402 403/** 404 * Generate a natural TypeScript module from an IU contract. 405 */ 406function generateModule(iu: ImplementationUnit): string { 407 const lines: string[] = []; 408 const moduleName = toPascalCase(iu.name); 409 const configName = `${moduleName}Config`; 410 411 // Header 412 lines.push(`/**`); 413 lines.push(` * ${iu.name}`); 414 lines.push(` *`); 415 lines.push(` * AUTO-GENERATED by Phoenix VCS — DO NOT EDIT DIRECTLY`); 416 lines.push(` * Risk Tier: ${iu.risk_tier}`); 417 lines.push(` */`); 418 lines.push(''); 419 420 // Config interface from constraints/invariants 421 if (iu.contract.invariants.length > 0) { 422 const fields = iu.contract.invariants 423 .map(inv => ({ inv, field: constraintToConfigField(inv) })) 424 .filter((x): x is { inv: string; field: { name: string; type: string } } => x.field !== null); 425 426 if (fields.length > 0) { 427 lines.push(`/**`); 428 lines.push(` * Configuration and constraints for ${iu.name}.`); 429 lines.push(` */`); 430 lines.push(`export interface ${configName} {`); 431 for (const { inv, field } of fields) { 432 lines.push(` /** ${inv} */`); 433 lines.push(` ${field.name}: ${field.type};`); 434 } 435 lines.push('}'); 436 lines.push(''); 437 } 438 } 439 440 // Input/output interfaces 441 const inputTypeName = `${moduleName}Input`; 442 const outputTypeName = `${moduleName}Result`; 443 444 if (iu.contract.inputs.length > 0) { 445 lines.push(`export interface ${inputTypeName} {`); 446 for (const inp of iu.contract.inputs) { 447 lines.push(` ${inp}: unknown;`); 448 } 449 lines.push('}'); 450 lines.push(''); 451 } 452 453 if (iu.contract.outputs.length > 0) { 454 lines.push(`export interface ${outputTypeName} {`); 455 for (const out of iu.contract.outputs) { 456 lines.push(` ${out}: unknown;`); 457 } 458 lines.push('}'); 459 lines.push(''); 460 } 461 462 // Extract distinct operations from requirement statements 463 const operations = extractOperations(iu); 464 465 // Collect and emit placeholder types referenced by operations 466 if (operations.length > 0) { 467 const builtinTypes = new Set(['unknown', 'void', 'boolean', 'string', 'number', 'object', 468 inputTypeName, outputTypeName, configName]); 469 const placeholders = new Set<string>(); 470 for (const op of operations) { 471 for (const t of extractTypeRefs(op.params, op.returnType)) { 472 if (!builtinTypes.has(t)) placeholders.add(t); 473 } 474 } 475 if (placeholders.size > 0) { 476 for (const t of placeholders) { 477 lines.push(`/** Placeholder type — replace with your domain model. */`); 478 lines.push(`export type ${t} = Record<string, unknown>;`); 479 lines.push(''); 480 } 481 } 482 } 483 484 if (operations.length > 0) { 485 for (const op of operations) { 486 lines.push(`/**`); 487 lines.push(` * ${op.description}`); 488 lines.push(` */`); 489 lines.push(`export function ${op.name}(${op.params}): ${op.returnType} {`); 490 lines.push(` // TODO: implement`); 491 lines.push(` throw new Error('Not implemented: ${op.name}');`); 492 lines.push('}'); 493 lines.push(''); 494 } 495 } else { 496 // Fallback: single entry-point function 497 const funcName = toCamelCase(iu.name); 498 const params = iu.contract.inputs.length > 0 499 ? `input: ${inputTypeName}` 500 : ''; 501 const ret = iu.contract.outputs.length > 0 ? outputTypeName : 'void'; 502 lines.push(`/**`); 503 lines.push(` * ${iu.contract.description.split('.')[0] || iu.name}.`); 504 lines.push(` */`); 505 lines.push(`export function ${funcName}(${params}): ${ret} {`); 506 lines.push(` // TODO: implement`); 507 lines.push(` throw new Error('Not implemented: ${funcName}');`); 508 lines.push('}'); 509 lines.push(''); 510 } 511 512 // Phoenix metadata (compact) 513 lines.push(`/** @internal Phoenix VCS traceability — do not remove. */`); 514 lines.push(`export const _phoenix = {`); 515 lines.push(` iu_id: '${iu.iu_id}',`); 516 lines.push(` name: '${iu.name}',`); 517 lines.push(` risk_tier: '${iu.risk_tier}',`); 518 lines.push(` canon_ids: [${iu.source_canon_ids.length} as const],`); 519 lines.push('} as const;'); 520 lines.push(''); 521 522 return lines.join('\n'); 523} 524 525// ─── Operation Extraction ──────────────────────────────────────────────────── 526 527interface Operation { 528 name: string; 529 description: string; 530 params: string; 531 returnType: string; 532} 533 534/** 535 * Extract distinct function operations from an IU's canonical requirements. 536 * Looks for verb patterns in requirement statements and deduplicates. 537 */ 538function extractOperations(iu: ImplementationUnit): Operation[] { 539 const ops: Operation[] = []; 540 const seenNames = new Set<string>(); 541 542 // Parse requirements for action verbs 543 const patterns: { pattern: RegExp; verb: string }[] = [ 544 { pattern: /\bmust (?:support |handle )?creat(?:e|ing)\b/i, verb: 'create' }, 545 { pattern: /\bmust (?:support |handle )?validat(?:e|ing)\b/i, verb: 'validate' }, 546 { pattern: /\bmust (?:support |handle )?verif(?:y|ying)\b/i, verb: 'verify' }, 547 { pattern: /\bmust (?:support |handle )?authenticat(?:e|ing)\b/i, verb: 'authenticate' }, 548 { pattern: /\bmust (?:support |handle )?delet(?:e|ing)\b/i, verb: 'delete' }, 549 { pattern: /\bmust (?:support |handle )?updat(?:e|ing)\b/i, verb: 'update' }, 550 { pattern: /\bmust (?:support |handle )?search(?:ing)?\b/i, verb: 'search' }, 551 { pattern: /\bmust (?:support |handle )?send(?:ing)?\b/i, verb: 'send' }, 552 { pattern: /\bmust (?:support |handle )?deliver(?:y|ing)?\b/i, verb: 'deliver' }, 553 { pattern: /\bmust (?:support |handle )?publish(?:ing)?\b/i, verb: 'publish' }, 554 { pattern: /\bmust (?:support |handle )?rout(?:e|ing)\b/i, verb: 'route' }, 555 { pattern: /\bmust (?:support |handle )?log(?:ging)?\b/i, verb: 'log' }, 556 { pattern: /\bmust (?:support |handle )?reject(?:ed|ing)?\b/i, verb: 'reject' }, 557 { pattern: /\bmust (?:be )?rate.?limit(?:ed|ing)?\b/i, verb: 'rateLimit' }, 558 { pattern: /\bmust (?:support |handle )?retr(?:y|ying|ied)\b/i, verb: 'retry' }, 559 { pattern: /\bmust (?:support |handle )?configur(?:e|ing|able)\b/i, verb: 'configure' }, 560 { pattern: /\bmust (?:support |handle )?expos(?:e|ing)\b/i, verb: 'expose' }, 561 { pattern: /\bmust (?:support |handle )?implement(?:ing)?\b/i, verb: 'handle' }, 562 { pattern: /\bmust (?:support |handle )?inject(?:ing)?\b/i, verb: 'inject' }, 563 { pattern: /\bmust (?:support |handle )?stor(?:e|ing)\b/i, verb: 'store' }, 564 { pattern: /\bmust (?:support |handle )?archiv(?:e|ing)\b/i, verb: 'archive' }, 565 { pattern: /\bmust (?:support |handle )?mark(?:ing)?\b/i, verb: 'mark' }, 566 { pattern: /\bmust (?:support |handle )?process(?:ing|ed)?\b/i, verb: 'process' }, 567 ]; 568 569 // Group requirements by detected verb 570 const verbGroups = new Map<string, string[]>(); 571 const moduleName = toPascalCase(iu.name); 572 573 for (const statement of iu.contract.description.split('. ').filter(Boolean)) { 574 for (const { pattern, verb } of patterns) { 575 if (pattern.test(statement)) { 576 const list = verbGroups.get(verb) ?? []; 577 list.push(statement); 578 verbGroups.set(verb, list); 579 break; // one verb per statement 580 } 581 } 582 } 583 584 // Generate one function per unique verb 585 for (const [verb, statements] of verbGroups) { 586 if (seenNames.has(verb)) continue; 587 seenNames.add(verb); 588 589 // Derive params from the object being acted on 590 const subject = extractSubject(statements[0], verb); 591 const paramName = subject ? toCamelCase(subject) : 'input'; 592 const paramType = subject ? toPascalCase(subject) : 'unknown'; 593 594 ops.push({ 595 name: verb, 596 description: statements[0], 597 params: `${paramName}: ${paramType}`, 598 returnType: verb === 'validate' || verb === 'verify' 599 ? 'boolean' 600 : verb === 'search' 601 ? `${paramType}[]` 602 : verb === 'delete' || verb === 'log' || verb === 'archive' || verb === 'mark' 603 ? 'void' 604 : paramType, 605 }); 606 } 607 608 // Limit to reasonable number 609 return ops.slice(0, 8); 610} 611 612/** 613 * Try to extract the object/subject from a requirement statement. 614 * "the service must validate JWT tokens" → "token" 615 * "the gateway must reject expired tokens" → "token" 616 */ 617function extractSubject(statement: string, verb: string): string | null { 618 // Pattern: "must <verb> <object>" 619 const regex = new RegExp(`must\\s+(?:support\\s+|handle\\s+)?${verb}\\w*\\s+(.+?)(?:\\s+(?:with|from|to|for|on|in|at|by|using|via|when|after|before)\\b|[.;,]|$)`, 'i'); 620 const match = statement.match(regex); 621 if (match) { 622 const raw = match[1] 623 .replace(/^(?:a|an|the|all|each|every|new)\s+/i, '') 624 .replace(/\s*\(.*?\)/g, '') 625 .trim(); 626 // Take the core noun — typically 1-2 meaningful words 627 const words = raw.split(/\s+/) 628 .filter(w => w.length > 1) 629 .slice(0, 2); 630 if (words.length > 0) { 631 // Singularize simple plurals 632 const noun = words[words.length - 1].replace(/s$/, ''); 633 words[words.length - 1] = noun; 634 return words.join(' '); 635 } 636 } 637 return null; 638} 639 640/** 641 * Convert a constraint statement to a config field. 642 * Returns null for constraints that are better expressed as code logic 643 * rather than configuration. 644 */ 645function constraintToConfigField(constraint: string): { name: string; type: string } | null { 646 // Numeric limits: "rate limited to 5 per minute", "limited to 100 characters" 647 const numMatch = constraint.match(/(\d+)\s*(per\s+\w+|characters|bytes|kb|mb|seconds?|minutes?|hours?|days?|retries|attempts)/i); 648 if (numMatch) { 649 const unit = numMatch[2].replace(/\s+/g, '').toLowerCase(); 650 const subject = extractConstraintSubject(constraint); 651 if (/rate.?limit/i.test(constraint)) { 652 return { name: `${subject}RateLimitPer${capitalize(unit)}`, type: 'number' }; 653 } 654 if (/expir|ttl|window/i.test(constraint)) { 655 return { name: `${subject}Ttl${capitalize(unit)}`, type: 'number' }; 656 } 657 return { name: `${subject}Max${capitalize(unit)}`, type: 'number' }; 658 } 659 660 // Configurable things: "CORS headers must be configurable per route" 661 if (/\bconfigurable\b/i.test(constraint)) { 662 const subject = extractConstraintSubject(constraint); 663 return { name: `${subject}Config`, type: 'Record<string, unknown>' }; 664 } 665 666 // Skip vague "must not" / "never" constraints — they're invariants, not config 667 return null; 668} 669 670/** 671 * Extract a short subject identifier from a constraint. 672 * "the service must not send more than 10 emails" → "email" 673 */ 674function extractConstraintSubject(statement: string): string { 675 // Find the most specific noun near the numbers/keywords 676 const words = statement 677 .toLowerCase() 678 .replace(/\b(?:the|a|an|must|be|is|are|not|no|shall|never|always|service|gateway|system)\b/g, '') 679 .replace(/[^a-z0-9\s]/g, '') 680 .trim() 681 .split(/\s+/) 682 .filter(w => w.length > 2); 683 684 // Pick the most meaningful word (skip common verbs) 685 const skip = new Set(['send', 'store', 'access', 'more', 'than', 'per', 'with', 'for', 'from', 'limited', 'exceed', 'larger']); 686 const meaningful = words.filter(w => !skip.has(w)); 687 return toCamelCase(meaningful.slice(0, 2).join(' ')) || 'value'; 688} 689 690function capitalize(s: string): string { 691 return s.charAt(0).toUpperCase() + s.slice(1); 692} 693 694/** 695 * Extract type references from param and return type strings. 696 * "jwtToken: JwtToken" → ["JwtToken"] 697 * "User[]" → ["User"] 698 */ 699function extractTypeRefs(params: string, returnType: string): string[] { 700 const types: string[] = []; 701 // From params: "name: Type" patterns 702 const paramMatches = params.matchAll(/:\s*([A-Z][A-Za-z0-9]*)/g); 703 for (const m of paramMatches) types.push(m[1]); 704 // From return type 705 const retMatch = returnType.replace(/\[\]$/, ''); 706 if (/^[A-Z]/.test(retMatch)) types.push(retMatch); 707 return types; 708} 709 710// ─── Naming Utilities ──────────────────────────────────────────────────────── 711 712function toCamelCase(str: string): string { 713 return str 714 .replace(/[^a-zA-Z0-9 ]/g, ' ') 715 .split(/\s+/) 716 .filter(Boolean) 717 .map((w, i) => i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 718 .join(''); 719} 720 721function toPascalCase(str: string): string { 722 return str 723 .replace(/[^a-zA-Z0-9 ]/g, ' ') 724 .split(/\s+/) 725 .filter(Boolean) 726 .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) 727 .join(''); 728}