@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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|