@zenithbuild/core 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import { parseTemplate } from '../parse/parseTemplate'
2
+ import { transformTemplate } from '../transform/transformTemplate'
3
+ import { lowerFragments } from '../transform/fragmentLowering'
4
+ import { transformNode } from '../transform/transformNode'
5
+ import type { ZenIR, TemplateNode, ExpressionNode, LoopFragmentNode } from '../ir/types'
6
+
7
+ async function testMapLowering() {
8
+ console.log('--- Testing JSX .map() Lowering ---')
9
+
10
+ const source = `
11
+ <div>
12
+ {items.map((item, index) => (
13
+ <li key={index} class={item.active ? 'active' : ''}>
14
+ {item.text}
15
+ </li>
16
+ ))}
17
+ </div>
18
+ `
19
+
20
+ try {
21
+ const template = parseTemplate(source, 'test.zen')
22
+
23
+ // Recursive search for LoopFragmentNode
24
+ function findNode(nodes: TemplateNode[], type: string): any {
25
+ for (const node of nodes) {
26
+ if (node.type === type) return node
27
+ if ('children' in node) {
28
+ const found = findNode(node.children, type)
29
+ if (found) return found
30
+ }
31
+ if (node.type === 'loop-fragment') {
32
+ const found = findNode(node.body, type)
33
+ if (found) return found
34
+ }
35
+ if (node.type === 'conditional-fragment') {
36
+ const foundC = findNode(node.consequent, type)
37
+ if (foundC) return foundC
38
+ const foundA = findNode(node.alternate, type)
39
+ if (foundA) return foundA
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ const loopNode = findNode(template.nodes, 'loop-fragment') as LoopFragmentNode
46
+
47
+ if (!loopNode) {
48
+ console.log('Template structure:', JSON.stringify(template.nodes, null, 2))
49
+ throw new Error('LoopFragmentNode not found in template')
50
+ }
51
+
52
+ console.assert(loopNode.source.startsWith('expr_'), 'Loop source should be an expression ID')
53
+ const sourceExpr = template.expressions.find(e => e.id === loopNode.source)
54
+ console.assert(sourceExpr?.code === 'items', `Expected source code "items", got "${sourceExpr?.code}"`)
55
+
56
+ // Check if inner expressions are registered
57
+ const li = loopNode.body.find(n => n.type === 'element' && n.tag === 'li') as any
58
+ const classAttr = li?.attributes.find((a: any) => a.name === 'class')
59
+ console.assert(classAttr?.value.id.startsWith('expr_'), 'Inner attribute expression should be an ID')
60
+
61
+ const textExpr = li?.children.find((n: any) => n.type === 'expression') as ExpressionNode
62
+ console.assert(textExpr?.expression.startsWith('expr_'), 'Inner text expression should be an ID')
63
+
64
+ // Final transformation
65
+ const ir: ZenIR = {
66
+ filePath: 'test.zen',
67
+ template,
68
+ script: null,
69
+ styles: []
70
+ }
71
+
72
+ const compiled = transformTemplate(ir)
73
+ console.assert(compiled.html.includes('data-zen-loop="loop_0"'), 'HTML should contain loop marker')
74
+ console.assert(compiled.html.includes(`data-zen-source="${loopNode.source}"`), 'HTML should contain source expression ID')
75
+
76
+ console.log('✅ JSX .map() lowering test passed')
77
+ } catch (e) {
78
+ console.error('❌ JSX .map() lowering test failed:', e)
79
+ process.exit(1)
80
+ }
81
+ }
82
+
83
+ async function testInvariantEnforcement() {
84
+ console.log('--- Testing INV-EXPR-REG-001 Invariant Enforcement ---')
85
+
86
+ const expressions: any[] = []
87
+ const badNode: ExpressionNode = {
88
+ type: 'expression',
89
+ expression: 'raw.code.here', // Should be expr_N
90
+ location: { line: 1, column: 1 }
91
+ }
92
+
93
+ try {
94
+ // We need to call lowerFragments but it calls assertNoRawExpressions
95
+ lowerFragments([badNode], 'test.zen', expressions)
96
+ console.error('❌ Invariant enforcement failed to catch raw expression')
97
+ process.exit(1)
98
+ } catch (e: any) {
99
+ console.assert(e.name === 'InvariantError', 'Expected InvariantError')
100
+ console.assert(e.message.includes('INV-EXPR-REG-001'), 'Expected INV-EXPR-REG-001 in error message')
101
+ console.log('✅ Invariant enforcement caught raw expression')
102
+ }
103
+
104
+ // Test transformNode safety
105
+ try {
106
+ const expressionsList: any[] = []
107
+ const badLoopNode: any = {
108
+ type: 'loop-fragment',
109
+ source: 'raw_source',
110
+ itemVar: 'item',
111
+ body: [],
112
+ location: { line: 1, column: 1 }
113
+ }
114
+ transformNode(badLoopNode, expressionsList)
115
+ console.error('❌ transformNode failed to catch raw loop source')
116
+ process.exit(1)
117
+ } catch (e: any) {
118
+ console.assert(e.name === 'InvariantError', 'Expected InvariantError in transformNode')
119
+ console.assert(e.message.includes('Raw loop source found'), 'Expected Loop source error')
120
+ console.log('✅ transformNode caught raw loop source')
121
+ }
122
+ }
123
+
124
+ async function runTests() {
125
+ await testMapLowering()
126
+ await testInvariantEnforcement()
127
+ console.log('--- All tests completed successfully ---')
128
+ }
129
+
130
+ runTests()
@@ -18,6 +18,7 @@ import type {
18
18
  OptionalFragmentNode,
19
19
  LoopFragmentNode,
20
20
  LoopContext,
21
+ AttributeIR,
21
22
  SourceLocation,
22
23
  ExpressionIR
23
24
  } from '../ir/types'
@@ -41,7 +42,12 @@ export function lowerFragments(
41
42
  filePath: string,
42
43
  expressions: ExpressionIR[]
43
44
  ): TemplateNode[] {
44
- return nodes.map(node => lowerNode(node, filePath, expressions))
45
+ const lowered = nodes.map(node => lowerNode(node, filePath, expressions))
46
+
47
+ // INV-EXPR-REG-001 Enforcement
48
+ lowered.forEach(node => assertNoRawExpressions(node, filePath))
49
+
50
+ return lowered
45
51
  }
46
52
 
47
53
  /**
@@ -57,16 +63,11 @@ function lowerNode(
57
63
  return lowerExpressionNode(node, filePath, expressions)
58
64
 
59
65
  case 'element': {
60
- // Check if this is a <for> element directive
61
- if (node.tag === 'for') {
62
- return lowerForElement(node, filePath, expressions)
63
- }
64
-
65
66
  // Check if this is an <html-content> element directive
66
67
  if (node.tag === 'html-content') {
67
68
  return lowerHtmlContentElement(node, filePath, expressions)
68
69
  }
69
-
70
+
70
71
  return {
71
72
  ...node,
72
73
  children: lowerFragments(node.children, filePath, expressions)
@@ -112,7 +113,15 @@ function lowerExpressionNode(
112
113
  filePath: string,
113
114
  expressions: ExpressionIR[]
114
115
  ): TemplateNode {
115
- const classification = classifyExpression(node.expression)
116
+ // Resolve the expression ID to its original code for classification
117
+ const exprIR = expressions.find(e => e.id === node.expression)
118
+ if (!exprIR) {
119
+ // If not found in registry, it might already be an ID or an error
120
+ // But for classification we need the code.
121
+ return node
122
+ }
123
+
124
+ const classification = classifyExpression(exprIR.code)
116
125
 
117
126
  // Primitive expressions pass through unchanged
118
127
  if (classification.type === 'primitive') {
@@ -190,13 +199,16 @@ function lowerConditionalExpression(
190
199
  filePath: string,
191
200
  expressions: ExpressionIR[]
192
201
  ): ConditionalFragmentNode {
202
+ // Register condition
203
+ const conditionId = registerExpression(condition, node.location, expressions)
204
+
193
205
  // Parse both branches as JSX fragments
194
206
  const consequent = parseJSXToNodes(consequentCode, node.location, filePath, expressions, node.loopContext)
195
207
  const alternate = parseJSXToNodes(alternateCode, node.location, filePath, expressions, node.loopContext)
196
208
 
197
209
  return {
198
210
  type: 'conditional-fragment',
199
- condition,
211
+ condition: conditionId,
200
212
  consequent,
201
213
  alternate,
202
214
  location: node.location,
@@ -216,11 +228,14 @@ function lowerOptionalExpression(
216
228
  filePath: string,
217
229
  expressions: ExpressionIR[]
218
230
  ): OptionalFragmentNode {
231
+ // Register condition
232
+ const conditionId = registerExpression(condition, node.location, expressions)
233
+
219
234
  const fragment = parseJSXToNodes(fragmentCode, node.location, filePath, expressions, node.loopContext)
220
235
 
221
236
  return {
222
237
  type: 'optional-fragment',
223
- condition,
238
+ condition: conditionId,
224
239
  fragment,
225
240
  location: node.location,
226
241
  loopContext: node.loopContext
@@ -241,6 +256,9 @@ function lowerLoopExpression(
241
256
  filePath: string,
242
257
  expressions: ExpressionIR[]
243
258
  ): LoopFragmentNode {
259
+ // Register loop source as an expression ID
260
+ const sourceId = registerExpression(source, node.location, expressions)
261
+
244
262
  // Create loop context for the body
245
263
  const loopVariables = [itemVar]
246
264
  if (indexVar) {
@@ -251,7 +269,7 @@ function lowerLoopExpression(
251
269
  variables: node.loopContext
252
270
  ? [...node.loopContext.variables, ...loopVariables]
253
271
  : loopVariables,
254
- mapSource: source
272
+ mapSource: sourceId // Use expression ID here
255
273
  }
256
274
 
257
275
  // Parse body with loop context
@@ -259,7 +277,7 @@ function lowerLoopExpression(
259
277
 
260
278
  return {
261
279
  type: 'loop-fragment',
262
- source,
280
+ source: sourceId, // Use expression ID here
263
281
  itemVar,
264
282
  indexVar,
265
283
  body,
@@ -287,95 +305,6 @@ function lowerInlineFragment(
287
305
  return node
288
306
  }
289
307
 
290
- /**
291
- * Lower <for> element directive to LoopFragmentNode
292
- *
293
- * Syntax: <for each="item" in="items">...body...</for>
294
- * Or: <for each="item, index" in="items">...body...</for>
295
- *
296
- * This is compile-time sugar for {items.map(item => ...)}
297
- */
298
- function lowerForElement(
299
- node: import('../ir/types').ElementNode,
300
- filePath: string,
301
- expressions: ExpressionIR[]
302
- ): LoopFragmentNode {
303
- // Extract 'each' and 'in' attributes
304
- const eachAttr = node.attributes.find(a => a.name === 'each')
305
- const inAttr = node.attributes.find(a => a.name === 'in')
306
-
307
- if (!eachAttr || typeof eachAttr.value !== 'string') {
308
- throw new InvariantError(
309
- 'ZEN001',
310
- `<for> element requires an 'each' attribute specifying the item variable`,
311
- 'Usage: <for each="item" in="items">...body...</for>',
312
- filePath,
313
- node.location.line,
314
- node.location.column
315
- )
316
- }
317
-
318
- if (!inAttr || typeof inAttr.value !== 'string') {
319
- throw new InvariantError(
320
- 'ZEN001',
321
- `<for> element requires an 'in' attribute specifying the source array`,
322
- 'Usage: <for each="item" in="items">...body...</for>',
323
- filePath,
324
- node.location.line,
325
- node.location.column
326
- )
327
- }
328
-
329
- // Parse item variable (may include index: "item, index" or "item, i")
330
- const eachValue = eachAttr.value.trim()
331
- let itemVar: string
332
- let indexVar: string | undefined
333
-
334
- if (eachValue.includes(',')) {
335
- const parts = eachValue.split(',').map(p => p.trim())
336
- itemVar = parts[0]!
337
- indexVar = parts[1]
338
- } else {
339
- itemVar = eachValue
340
- }
341
-
342
- const source = inAttr.value.trim()
343
-
344
- // Create loop context for the body
345
- const loopVariables = [itemVar]
346
- if (indexVar) {
347
- loopVariables.push(indexVar)
348
- }
349
-
350
- const bodyLoopContext: LoopContext = {
351
- variables: node.loopContext
352
- ? [...node.loopContext.variables, ...loopVariables]
353
- : loopVariables,
354
- mapSource: source
355
- }
356
-
357
- // Lower children with loop context
358
- const body = node.children.map(child => {
359
- // Recursively lower children
360
- const lowered = lowerNode(child, filePath, expressions)
361
- // Attach loop context to children that need it
362
- if ('loopContext' in lowered) {
363
- return { ...lowered, loopContext: bodyLoopContext }
364
- }
365
- return lowered
366
- })
367
-
368
- return {
369
- type: 'loop-fragment',
370
- source,
371
- itemVar,
372
- indexVar,
373
- body,
374
- location: node.location,
375
- loopContext: bodyLoopContext
376
- }
377
- }
378
-
379
308
  /**
380
309
  * Lower <html-content> element directive
381
310
  *
@@ -391,7 +320,7 @@ function lowerHtmlContentElement(
391
320
  ): TemplateNode {
392
321
  // Extract 'content' attribute
393
322
  const contentAttr = node.attributes.find(a => a.name === 'content')
394
-
323
+
395
324
  if (!contentAttr || typeof contentAttr.value !== 'string') {
396
325
  throw new InvariantError(
397
326
  'ZEN001',
@@ -402,9 +331,9 @@ function lowerHtmlContentElement(
402
331
  node.location.column
403
332
  )
404
333
  }
405
-
334
+
406
335
  const exprCode = contentAttr.value.trim()
407
-
336
+
408
337
  // Generate expression ID and register the expression
409
338
  const exprId = `expr_${expressions.length}`
410
339
  const exprIR: ExpressionIR = {
@@ -413,7 +342,7 @@ function lowerHtmlContentElement(
413
342
  location: node.location
414
343
  }
415
344
  expressions.push(exprIR)
416
-
345
+
417
346
  // Create a span element with data-zen-html attribute for raw HTML binding
418
347
  return {
419
348
  type: 'element',
@@ -428,6 +357,101 @@ function lowerHtmlContentElement(
428
357
  }
429
358
  }
430
359
 
360
+ /**
361
+ * Register a raw expression code string and return its ID
362
+ */
363
+ function registerExpression(
364
+ code: string,
365
+ location: SourceLocation,
366
+ expressions: ExpressionIR[]
367
+ ): string {
368
+ const id = `expr_${expressions.length}`
369
+ expressions.push({ id, code, location })
370
+ return id
371
+ }
372
+
373
+ /**
374
+ * INV-EXPR-REG-001: Assert no raw expressions exist beyond parse phase
375
+ */
376
+ function assertNoRawExpressions(node: TemplateNode, filePath: string): void {
377
+ const errorPrefix = `Compiler invariant violated: runtime expression used without registration. This is a compiler bug, not a user error.`
378
+
379
+ switch (node.type) {
380
+ case 'expression':
381
+ if (!node.expression.startsWith('expr_')) {
382
+ throw new InvariantError(
383
+ 'INV-EXPR-REG-001',
384
+ `${errorPrefix}\nRaw expression found: "${node.expression}"`,
385
+ 'Every expression must be registered and referenced by ID.',
386
+ filePath,
387
+ node.location.line,
388
+ node.location.column
389
+ )
390
+ }
391
+ break
392
+
393
+ case 'loop-fragment':
394
+ if (!node.source.startsWith('expr_')) {
395
+ throw new InvariantError(
396
+ 'INV-EXPR-REG-001',
397
+ `${errorPrefix}\nRaw loop source found: "${node.source}"`,
398
+ 'Loop sources must be registered and referenced by ID.',
399
+ filePath,
400
+ node.location.line,
401
+ node.location.column
402
+ )
403
+ }
404
+ node.body.forEach(child => assertNoRawExpressions(child, filePath))
405
+ break
406
+
407
+ case 'conditional-fragment':
408
+ if (!node.condition.startsWith('expr_')) {
409
+ throw new InvariantError(
410
+ 'INV-EXPR-REG-001',
411
+ `${errorPrefix}\nRaw condition found: "${node.condition}"`,
412
+ 'Conditional expressions must be registered and referenced by ID.',
413
+ filePath,
414
+ node.location.line,
415
+ node.location.column
416
+ )
417
+ }
418
+ node.consequent.forEach(child => assertNoRawExpressions(child, filePath))
419
+ node.alternate.forEach(child => assertNoRawExpressions(child, filePath))
420
+ break
421
+
422
+ case 'optional-fragment':
423
+ if (!node.condition.startsWith('expr_')) {
424
+ throw new InvariantError(
425
+ 'INV-EXPR-REG-001',
426
+ `${errorPrefix}\nRaw condition found: "${node.condition}"`,
427
+ 'Optional expressions must be registered and referenced by ID.',
428
+ filePath,
429
+ node.location.line,
430
+ node.location.column
431
+ )
432
+ }
433
+ node.fragment.forEach(child => assertNoRawExpressions(child, filePath))
434
+ break
435
+
436
+ case 'element':
437
+ case 'component':
438
+ node.attributes.forEach(attr => {
439
+ if (typeof attr.value !== 'string' && !attr.value.id.startsWith('expr_')) {
440
+ throw new InvariantError(
441
+ 'INV-EXPR-REG-001',
442
+ `${errorPrefix}\nRaw attribute expression found: "${(attr.value as any).code}"`,
443
+ 'Attribute expressions must be registered and referenced by ID.',
444
+ filePath,
445
+ attr.location.line,
446
+ attr.location.column
447
+ )
448
+ }
449
+ })
450
+ node.children.forEach(child => assertNoRawExpressions(child, filePath))
451
+ break
452
+ }
453
+ }
454
+
431
455
  /**
432
456
  * Parse JSX code string into TemplateNode[]
433
457
  *
@@ -537,9 +561,11 @@ function parseJSXChildren(
537
561
 
538
562
  const exprCode = content.slice(i + 1, endBrace - 1).trim()
539
563
  if (exprCode) {
564
+ // Register inner expression
565
+ const exprId = registerExpression(exprCode, baseLocation, expressions)
540
566
  nodes.push({
541
567
  type: 'expression',
542
- expression: exprCode,
568
+ expression: exprId,
543
569
  location: baseLocation,
544
570
  loopContext
545
571
  })
@@ -598,7 +624,7 @@ function parseJSXElementWithEnd(
598
624
  let i = startIndex + tagMatch[0].length
599
625
 
600
626
  // Parse attributes (simplified)
601
- const attributes: Array<{ name: string; value: string; location: SourceLocation }> = []
627
+ const attributes: AttributeIR[] = []
602
628
 
603
629
  // Skip whitespace and parse attributes until > or />
604
630
  while (i < code.length) {
@@ -667,7 +693,14 @@ function parseJSXElementWithEnd(
667
693
  } else if (code[i] === '{') {
668
694
  const endBrace = findBalancedBraceEnd(code, i)
669
695
  if (endBrace !== -1) {
670
- attributes.push({ name: attrName, value: code.slice(i, endBrace), location: baseLocation })
696
+ const exprCode = code.slice(i + 1, endBrace - 1).trim()
697
+ const exprId = registerExpression(exprCode, baseLocation, expressions)
698
+
699
+ attributes.push({
700
+ name: attrName,
701
+ value: { id: exprId, code: exprCode, location: baseLocation },
702
+ location: baseLocation
703
+ })
671
704
  i = endBrace
672
705
  }
673
706
  }
@@ -6,19 +6,22 @@
6
6
  * Phase 8: Supports fragment node types (loop-fragment, conditional-fragment, optional-fragment)
7
7
  */
8
8
 
9
- import type {
10
- TemplateNode,
11
- ElementNode,
12
- TextNode,
13
- ExpressionNode,
14
- ExpressionIR,
9
+ import type {
10
+ TemplateNode,
11
+ ElementNode,
12
+ TextNode,
13
+ ExpressionNode,
14
+ ExpressionIR,
15
15
  LoopContext,
16
16
  LoopFragmentNode,
17
17
  ConditionalFragmentNode,
18
18
  OptionalFragmentNode,
19
- ComponentNode
19
+ ComponentNode,
20
+ SourceLocation
20
21
  } from '../ir/types'
21
22
  import type { Binding } from '../output/types'
23
+ import { InvariantError } from '../errors/compilerError'
24
+ import { INVARIANT } from '../validate/invariants'
22
25
 
23
26
  let loopIdCounter = 0
24
27
 
@@ -126,7 +129,8 @@ export function transformNode(
126
129
  }
127
130
 
128
131
  case 'loop-fragment': {
129
- // Loop fragment: {items.map(item => <li>...</li>)} or <for each="item" in="items">
132
+ // Loop fragment: {items.map(item => <li>...</li>)}
133
+ // .map() is compile-time sugar, lowered to LoopFragmentNode
130
134
  // For SSR/SSG, we render one instance of the body as a template
131
135
  // The runtime will hydrate and expand this for each actual item
132
136
  const loopNode = node as LoopFragmentNode
@@ -152,7 +156,7 @@ export function transformNode(
152
156
  // For SSR, we render ONE visible instance of the body as a template/placeholder
153
157
  // The runtime will clone this for each item in the array
154
158
  const bodyHtml = loopNode.body.map(child => transform(child, activeLoopContext)).join('')
155
-
159
+
156
160
  // Render container with body visible for SSR (not in hidden <template>)
157
161
  // Runtime will clear and re-render with actual data
158
162
  return `<div data-zen-loop="${loopId}" data-zen-source="${escapeHtml(loopNode.source)}" data-zen-item="${loopNode.itemVar}"${loopNode.indexVar ? ` data-zen-index="${loopNode.indexVar}"` : ''} style="display: contents;">${bodyHtml}</div>`
@@ -207,7 +211,7 @@ export function transformNode(
207
211
  // This is a fallback for unresolved components
208
212
  const compNode = node as ComponentNode
209
213
  console.warn(`[Zenith] Unresolved component in transformNode: ${compNode.name}`)
210
-
214
+
211
215
  // Render children as a fragment
212
216
  const childrenHtml = compNode.children.map(child => transform(child, loopContext)).join('')
213
217
  return `<!-- unresolved: ${compNode.name} -->${childrenHtml}`
@@ -222,9 +226,80 @@ export function transformNode(
222
226
  }
223
227
 
224
228
  const html = transform(node, parentLoopContext)
229
+
230
+ // INV-EXPR-REG-001 Enforcement: Before emitting HTML, assert all bindings resolve
231
+ checkEveryBindingResolves(bindings, expressions)
232
+
225
233
  return { html, bindings }
226
234
  }
227
235
 
236
+ /**
237
+ * INV-EXPR-REG-001: Assert every data-zen-* reference resolves to a registered expression
238
+ */
239
+ function checkEveryBindingResolves(bindings: Binding[], expressions: ExpressionIR[]): void {
240
+ const errorPrefix = `Compiler invariant violated: runtime expression used without registration. This is a compiler bug, not a user error.`
241
+
242
+ for (const binding of bindings) {
243
+ const exprId = binding.id
244
+ const resolved = expressions.find(e => e.id === exprId)
245
+
246
+ if (!resolved && binding.type !== 'loop') {
247
+ // Loop bindings might have a generated loopId that doesn't
248
+ // exist in the expressions array, but they have a source expression ID
249
+ }
250
+
251
+ // Check loop source specifically
252
+ if (binding.type === 'loop') {
253
+ const sourceExprId = binding.expression
254
+ if (!sourceExprId.startsWith('expr_')) {
255
+ throw new InvariantError(
256
+ 'INV-EXPR-REG-001',
257
+ `${errorPrefix}\nLoop fragment references invalid source expression: "${sourceExprId}"`,
258
+ 'Loop fragments must reference a valid registered expression ID.',
259
+ 'unknown', // transformNode doesn't have filePath, but we can't easily get it here without changing signature
260
+ binding.location?.line || 1,
261
+ binding.location?.column || 1
262
+ )
263
+ }
264
+
265
+ const sourceResolved = expressions.find(e => e.id === sourceExprId)
266
+ if (!sourceResolved) {
267
+ throw new InvariantError(
268
+ 'INV-EXPR-REG-001',
269
+ `${errorPrefix}\nLoop source expression "${sourceExprId}" not found in registry.`,
270
+ 'Every loop source must be registered as an ExpressionIR.',
271
+ 'unknown',
272
+ binding.location?.line || 1,
273
+ binding.location?.column || 1
274
+ )
275
+ }
276
+ } else {
277
+ // General expression check
278
+ if (!exprId.startsWith('expr_')) {
279
+ throw new InvariantError(
280
+ 'INV-EXPR-REG-001',
281
+ `${errorPrefix}\nBinding references invalid expression ID: "${exprId}"`,
282
+ 'Every binding must reference a valid registered expression ID.',
283
+ 'unknown',
284
+ binding.location?.line || 1,
285
+ binding.location?.column || 1
286
+ )
287
+ }
288
+
289
+ if (!resolved) {
290
+ throw new InvariantError(
291
+ 'INV-EXPR-REG-001',
292
+ `${errorPrefix}\nExpression ID "${exprId}" not found in registry.`,
293
+ 'Every binding must resolve to a registered expression.',
294
+ 'unknown',
295
+ binding.location?.line || 1,
296
+ binding.location?.column || 1
297
+ )
298
+ }
299
+ }
300
+ }
301
+ }
302
+
228
303
  /**
229
304
  * Escape HTML special characters
230
305
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/core",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Core library for the Zenith framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -77,4 +77,4 @@
77
77
  "parse5": "^8.0.0",
78
78
  "picocolors": "^1.1.1"
79
79
  }
80
- }
80
+ }