Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
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}