Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 219 lines 7.7 kB view raw
1/** 2 * IU Planner — maps canonical nodes to Implementation Unit proposals. 3 * 4 * Groups related requirements into module-level IUs based on: 5 * - Source document (service boundary) 6 * - Source section within a document (module boundary) 7 * 8 * Naming produces natural developer-facing identifiers: 9 * spec/api-gateway.md, section "Rate Limiting" 10 * → name: "Rate Limiting" 11 * → file: src/generated/api-gateway/rate-limiting.ts 12 */ 13 14import type { CanonicalNode } from './models/canonical.js'; 15import { CanonicalType } from './models/canonical.js'; 16import type { Clause } from './models/clause.js'; 17import type { ImplementationUnit } from './models/iu.js'; 18import { defaultBoundaryPolicy, defaultEnforcement } from './models/iu.js'; 19import { sha256 } from './semhash.js'; 20 21/** 22 * Plan IUs from canonical nodes, grouping by source document + section. 23 * 24 * Each top-level section of each spec document becomes one IU. 25 * Canon nodes are assigned to the IU of their source clause's section. 26 * CONTEXT nodes are excluded from IU generation (they don't produce code). 27 */ 28export function planIUs( 29 canonNodes: CanonicalNode[], 30 clauses: Clause[], 31): ImplementationUnit[] { 32 // Filter out CONTEXT nodes — they don't generate code 33 canonNodes = canonNodes.filter(n => n.type !== CanonicalType.CONTEXT); 34 if (canonNodes.length === 0) return []; 35 36 // Index clauses by ID 37 const clauseMap = new Map(clauses.map(c => [c.clause_id, c])); 38 39 // Group canonical nodes by (doc, top-level section) 40 const buckets = new Map<string, { nodes: CanonicalNode[]; docId: string; sectionName: string }>(); 41 42 for (const node of canonNodes) { 43 const clause = node.source_clause_ids 44 .map(id => clauseMap.get(id)) 45 .find(c => c !== undefined); 46 47 if (!clause) continue; 48 49 const docId = clause.source_doc_id; 50 // Use the second level of section_path as the grouping key. 51 // section_path[0] is typically the doc title, section_path[1] is the first real section. 52 // If there's only one level, use that. 53 const sectionName = clause.section_path.length > 1 54 ? clause.section_path[1] 55 : clause.section_path[0] || 'main'; 56 57 const key = `${docId}::${sectionName}`; 58 let bucket = buckets.get(key); 59 if (!bucket) { 60 bucket = { nodes: [], docId, sectionName }; 61 buckets.set(key, bucket); 62 } 63 bucket.nodes.push(node); 64 } 65 66 // Merge small buckets (≤1 node) into their document's largest bucket 67 const docBuckets = new Map<string, string[]>(); // docId → keys 68 for (const [key, bucket] of buckets) { 69 const list = docBuckets.get(bucket.docId) ?? []; 70 list.push(key); 71 docBuckets.set(bucket.docId, list); 72 } 73 74 for (const [docId, keys] of docBuckets) { 75 const small = keys.filter(k => buckets.get(k)!.nodes.length <= 1); 76 const large = keys.filter(k => buckets.get(k)!.nodes.length > 1); 77 78 if (small.length > 0 && large.length > 0) { 79 // Find the largest bucket in this doc 80 const targetKey = large.sort((a, b) => 81 buckets.get(b)!.nodes.length - buckets.get(a)!.nodes.length 82 )[0]; 83 const target = buckets.get(targetKey)!; 84 for (const smallKey of small) { 85 target.nodes.push(...buckets.get(smallKey)!.nodes); 86 buckets.delete(smallKey); 87 } 88 } 89 } 90 91 // Convert buckets to IUs 92 const ius: ImplementationUnit[] = []; 93 94 for (const [, bucket] of buckets) { 95 const { nodes, docId, sectionName } = bucket; 96 if (nodes.length === 0) continue; 97 98 const name = cleanName(sectionName); 99 const serviceName = deriveServiceName(docId); 100 const fileName = slugify(name); 101 const riskTier = deriveRiskTier(nodes); 102 const canonIds = nodes.map(n => n.canon_id); 103 104 // Build a readable description from the requirements (not a wall of text) 105 const requirements = nodes.filter(n => n.type === 'REQUIREMENT').slice(0, 5); 106 const constraints = nodes.filter(n => n.type === 'CONSTRAINT' || n.type === 'INVARIANT'); 107 const description = requirements.map(n => n.statement).join('. '); 108 109 const iuId = sha256(['iu', serviceName, name, ...canonIds.sort()].join('\x00')); 110 111 // Derive typed inputs/outputs from node statements 112 const { inputs, outputs } = deriveContract(nodes, name); 113 114 ius.push({ 115 iu_id: iuId, 116 kind: 'module' as const, 117 name, 118 risk_tier: riskTier, 119 contract: { 120 description, 121 inputs, 122 outputs, 123 invariants: constraints.map(n => n.statement), 124 }, 125 source_canon_ids: canonIds, 126 dependencies: [], 127 boundary_policy: defaultBoundaryPolicy(), 128 enforcement: defaultEnforcement(), 129 evidence_policy: { 130 required: evidenceForTier(riskTier), 131 }, 132 output_files: [`src/generated/${serviceName}/${fileName}.ts`], 133 }); 134 } 135 136 // Sort for deterministic output 137 ius.sort((a, b) => a.output_files[0].localeCompare(b.output_files[0])); 138 139 return ius; 140} 141 142/** 143 * Derive a service name from a document ID. 144 * "spec/api-gateway.md" → "api-gateway" 145 * "spec/deep/user-service.md" → "user-service" 146 * "test.md" → "test" 147 */ 148function deriveServiceName(docId: string): string { 149 const base = docId.split('/').pop() || docId; 150 return slugify(base.replace(/\.md$/i, '')); 151} 152 153/** 154 * Clean up a section name to be a natural IU name. 155 * "Security Constraints" → "Security Constraints" 156 * "3.2 Authentication" → "Authentication" 157 */ 158function cleanName(raw: string): string { 159 return raw 160 .replace(/^\d+(\.\d+)*\s*/, '') // strip leading numbers 161 .replace(/\s+/g, ' ') 162 .trim() || 'Main'; 163} 164 165/** 166 * Derive typed contract inputs/outputs from canonical nodes. 167 */ 168function deriveContract( 169 nodes: CanonicalNode[], 170 sectionName: string, 171): { inputs: string[]; outputs: string[] } { 172 const inputs: string[] = []; 173 const outputs: string[] = []; 174 175 // Look for common patterns in statements 176 const allStatements = nodes.map(n => n.statement).join(' '); 177 178 if (/\brequest\b/i.test(allStatements)) inputs.push('request'); 179 if (/\buser\b/i.test(allStatements) && /\b(?:create|account|authenticate)\b/i.test(allStatements)) inputs.push('user'); 180 if (/\btoken\b/i.test(allStatements)) inputs.push('token'); 181 if (/\btemplate\b/i.test(allStatements)) inputs.push('template'); 182 if (/\bnotification|message\b/i.test(allStatements)) inputs.push('notification'); 183 if (/\bconfig\b/i.test(allStatements)) inputs.push('config'); 184 185 if (/\bresponse\b/i.test(allStatements)) outputs.push('response'); 186 if (/\bresult\b/i.test(allStatements)) outputs.push('result'); 187 if (/\bevent\b/i.test(allStatements)) outputs.push('event'); 188 189 return { inputs, outputs }; 190} 191 192function deriveRiskTier(nodes: CanonicalNode[]): 'low' | 'medium' | 'high' | 'critical' { 193 const hasConstraint = nodes.some(n => n.type === 'CONSTRAINT'); 194 const hasInvariant = nodes.some(n => n.type === 'INVARIANT'); 195 const size = nodes.length; 196 197 if (hasInvariant) return 'high'; 198 if (hasConstraint && size > 2) return 'high'; 199 if (hasConstraint) return 'medium'; 200 if (size > 3) return 'medium'; 201 return 'low'; 202} 203 204function evidenceForTier(tier: string): string[] { 205 switch (tier) { 206 case 'low': return ['typecheck', 'lint', 'boundary_validation']; 207 case 'medium': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests']; 208 case 'high': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests', 'property_tests', 'static_analysis']; 209 case 'critical': return ['typecheck', 'lint', 'boundary_validation', 'unit_tests', 'property_tests', 'static_analysis', 'human_signoff']; 210 default: return ['typecheck']; 211 } 212} 213 214function slugify(name: string): string { 215 return name 216 .toLowerCase() 217 .replace(/[^a-z0-9]+/g, '-') 218 .replace(/^-|-$/g, ''); 219}