@zenithbuild/core 1.2.1 → 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
  /**
@@ -61,7 +67,7 @@ function lowerNode(
61
67
  if (node.tag === 'html-content') {
62
68
  return lowerHtmlContentElement(node, filePath, expressions)
63
69
  }
64
-
70
+
65
71
  return {
66
72
  ...node,
67
73
  children: lowerFragments(node.children, filePath, expressions)
@@ -107,7 +113,15 @@ function lowerExpressionNode(
107
113
  filePath: string,
108
114
  expressions: ExpressionIR[]
109
115
  ): TemplateNode {
110
- 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)
111
125
 
112
126
  // Primitive expressions pass through unchanged
113
127
  if (classification.type === 'primitive') {
@@ -185,13 +199,16 @@ function lowerConditionalExpression(
185
199
  filePath: string,
186
200
  expressions: ExpressionIR[]
187
201
  ): ConditionalFragmentNode {
202
+ // Register condition
203
+ const conditionId = registerExpression(condition, node.location, expressions)
204
+
188
205
  // Parse both branches as JSX fragments
189
206
  const consequent = parseJSXToNodes(consequentCode, node.location, filePath, expressions, node.loopContext)
190
207
  const alternate = parseJSXToNodes(alternateCode, node.location, filePath, expressions, node.loopContext)
191
208
 
192
209
  return {
193
210
  type: 'conditional-fragment',
194
- condition,
211
+ condition: conditionId,
195
212
  consequent,
196
213
  alternate,
197
214
  location: node.location,
@@ -211,11 +228,14 @@ function lowerOptionalExpression(
211
228
  filePath: string,
212
229
  expressions: ExpressionIR[]
213
230
  ): OptionalFragmentNode {
231
+ // Register condition
232
+ const conditionId = registerExpression(condition, node.location, expressions)
233
+
214
234
  const fragment = parseJSXToNodes(fragmentCode, node.location, filePath, expressions, node.loopContext)
215
235
 
216
236
  return {
217
237
  type: 'optional-fragment',
218
- condition,
238
+ condition: conditionId,
219
239
  fragment,
220
240
  location: node.location,
221
241
  loopContext: node.loopContext
@@ -236,6 +256,9 @@ function lowerLoopExpression(
236
256
  filePath: string,
237
257
  expressions: ExpressionIR[]
238
258
  ): LoopFragmentNode {
259
+ // Register loop source as an expression ID
260
+ const sourceId = registerExpression(source, node.location, expressions)
261
+
239
262
  // Create loop context for the body
240
263
  const loopVariables = [itemVar]
241
264
  if (indexVar) {
@@ -246,7 +269,7 @@ function lowerLoopExpression(
246
269
  variables: node.loopContext
247
270
  ? [...node.loopContext.variables, ...loopVariables]
248
271
  : loopVariables,
249
- mapSource: source
272
+ mapSource: sourceId // Use expression ID here
250
273
  }
251
274
 
252
275
  // Parse body with loop context
@@ -254,7 +277,7 @@ function lowerLoopExpression(
254
277
 
255
278
  return {
256
279
  type: 'loop-fragment',
257
- source,
280
+ source: sourceId, // Use expression ID here
258
281
  itemVar,
259
282
  indexVar,
260
283
  body,
@@ -297,7 +320,7 @@ function lowerHtmlContentElement(
297
320
  ): TemplateNode {
298
321
  // Extract 'content' attribute
299
322
  const contentAttr = node.attributes.find(a => a.name === 'content')
300
-
323
+
301
324
  if (!contentAttr || typeof contentAttr.value !== 'string') {
302
325
  throw new InvariantError(
303
326
  'ZEN001',
@@ -308,9 +331,9 @@ function lowerHtmlContentElement(
308
331
  node.location.column
309
332
  )
310
333
  }
311
-
334
+
312
335
  const exprCode = contentAttr.value.trim()
313
-
336
+
314
337
  // Generate expression ID and register the expression
315
338
  const exprId = `expr_${expressions.length}`
316
339
  const exprIR: ExpressionIR = {
@@ -319,7 +342,7 @@ function lowerHtmlContentElement(
319
342
  location: node.location
320
343
  }
321
344
  expressions.push(exprIR)
322
-
345
+
323
346
  // Create a span element with data-zen-html attribute for raw HTML binding
324
347
  return {
325
348
  type: 'element',
@@ -334,6 +357,101 @@ function lowerHtmlContentElement(
334
357
  }
335
358
  }
336
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
+
337
455
  /**
338
456
  * Parse JSX code string into TemplateNode[]
339
457
  *
@@ -443,9 +561,11 @@ function parseJSXChildren(
443
561
 
444
562
  const exprCode = content.slice(i + 1, endBrace - 1).trim()
445
563
  if (exprCode) {
564
+ // Register inner expression
565
+ const exprId = registerExpression(exprCode, baseLocation, expressions)
446
566
  nodes.push({
447
567
  type: 'expression',
448
- expression: exprCode,
568
+ expression: exprId,
449
569
  location: baseLocation,
450
570
  loopContext
451
571
  })
@@ -504,7 +624,7 @@ function parseJSXElementWithEnd(
504
624
  let i = startIndex + tagMatch[0].length
505
625
 
506
626
  // Parse attributes (simplified)
507
- const attributes: Array<{ name: string; value: string; location: SourceLocation }> = []
627
+ const attributes: AttributeIR[] = []
508
628
 
509
629
  // Skip whitespace and parse attributes until > or />
510
630
  while (i < code.length) {
@@ -573,7 +693,14 @@ function parseJSXElementWithEnd(
573
693
  } else if (code[i] === '{') {
574
694
  const endBrace = findBalancedBraceEnd(code, i)
575
695
  if (endBrace !== -1) {
576
- 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
+ })
577
704
  i = endBrace
578
705
  }
579
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
 
@@ -153,7 +156,7 @@ export function transformNode(
153
156
  // For SSR, we render ONE visible instance of the body as a template/placeholder
154
157
  // The runtime will clone this for each item in the array
155
158
  const bodyHtml = loopNode.body.map(child => transform(child, activeLoopContext)).join('')
156
-
159
+
157
160
  // Render container with body visible for SSR (not in hidden <template>)
158
161
  // Runtime will clear and re-render with actual data
159
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>`
@@ -208,7 +211,7 @@ export function transformNode(
208
211
  // This is a fallback for unresolved components
209
212
  const compNode = node as ComponentNode
210
213
  console.warn(`[Zenith] Unresolved component in transformNode: ${compNode.name}`)
211
-
214
+
212
215
  // Render children as a fragment
213
216
  const childrenHtml = compNode.children.map(child => transform(child, loopContext)).join('')
214
217
  return `<!-- unresolved: ${compNode.name} -->${childrenHtml}`
@@ -223,9 +226,80 @@ export function transformNode(
223
226
  }
224
227
 
225
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
+
226
233
  return { html, bindings }
227
234
  }
228
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
+
229
303
  /**
230
304
  * Escape HTML special characters
231
305
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/core",
3
- "version": "1.2.1",
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
+ }