@lokascript/framework 2.0.0 → 2.1.0

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.
Files changed (126) hide show
  1. package/dist/aot/index.d.ts +1 -0
  2. package/dist/aot/index.d.ts.map +1 -1
  3. package/dist/aot/registry-bridge.d.ts +27 -0
  4. package/dist/aot/registry-bridge.d.ts.map +1 -0
  5. package/dist/api/create-dsl.d.ts.map +1 -1
  6. package/dist/api/dispatcher.d.ts +22 -0
  7. package/dist/api/dispatcher.d.ts.map +1 -1
  8. package/dist/api/domain-registry.d.ts +41 -0
  9. package/dist/api/domain-registry.d.ts.map +1 -1
  10. package/dist/api/index.js +1351 -21
  11. package/dist/api/index.js.map +1 -1
  12. package/dist/core/index.js +114 -10
  13. package/dist/core/index.js.map +1 -1
  14. package/dist/core/pattern-matching/index.js +74 -10
  15. package/dist/core/pattern-matching/index.js.map +1 -1
  16. package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
  17. package/dist/core/types.d.ts +80 -2
  18. package/dist/core/types.d.ts.map +1 -1
  19. package/dist/core/types.js +40 -0
  20. package/dist/core/types.js.map +1 -1
  21. package/dist/feedback/confidence-gate.d.ts +28 -0
  22. package/dist/feedback/confidence-gate.d.ts.map +1 -0
  23. package/dist/feedback/feedback-formatter.d.ts +21 -0
  24. package/dist/feedback/feedback-formatter.d.ts.map +1 -0
  25. package/dist/feedback/index.d.ts +11 -0
  26. package/dist/feedback/index.d.ts.map +1 -0
  27. package/dist/feedback/pattern-tracker.d.ts +47 -0
  28. package/dist/feedback/pattern-tracker.d.ts.map +1 -0
  29. package/dist/feedback/types.d.ts +113 -0
  30. package/dist/feedback/types.d.ts.map +1 -0
  31. package/dist/generation/index.js +2 -1
  32. package/dist/generation/index.js.map +1 -1
  33. package/dist/index.cjs +2685 -71
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.ts +6 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2652 -71
  38. package/dist/index.js.map +1 -1
  39. package/dist/ir/explicit-parser.d.ts +31 -0
  40. package/dist/ir/explicit-parser.d.ts.map +1 -0
  41. package/dist/ir/explicit-renderer.d.ts +17 -0
  42. package/dist/ir/explicit-renderer.d.ts.map +1 -0
  43. package/dist/ir/from-interchange.d.ts +51 -0
  44. package/dist/ir/from-interchange.d.ts.map +1 -0
  45. package/dist/ir/index.d.ts +29 -0
  46. package/dist/ir/index.d.ts.map +1 -0
  47. package/dist/ir/index.js +1557 -0
  48. package/dist/ir/index.js.map +1 -0
  49. package/dist/ir/json-schema.d.ts +40 -0
  50. package/dist/ir/json-schema.d.ts.map +1 -0
  51. package/dist/ir/protocol-json.d.ts +73 -0
  52. package/dist/ir/protocol-json.d.ts.map +1 -0
  53. package/dist/ir/references.d.ts +19 -0
  54. package/dist/ir/references.d.ts.map +1 -0
  55. package/dist/ir/types.d.ts +141 -0
  56. package/dist/ir/types.d.ts.map +1 -0
  57. package/dist/parsing/index.js +74 -10
  58. package/dist/parsing/index.js.map +1 -1
  59. package/dist/prompts/index.d.ts +9 -0
  60. package/dist/prompts/index.d.ts.map +1 -0
  61. package/dist/prompts/prompt-generator.d.ts +49 -0
  62. package/dist/prompts/prompt-generator.d.ts.map +1 -0
  63. package/dist/prompts/types.d.ts +64 -0
  64. package/dist/prompts/types.d.ts.map +1 -0
  65. package/dist/schema/command-schema.d.ts +17 -0
  66. package/dist/schema/command-schema.d.ts.map +1 -1
  67. package/dist/schema/index.js.map +1 -1
  68. package/dist/testing/index.js +3 -0
  69. package/dist/testing/index.js.map +1 -1
  70. package/dist/training/index.d.ts +11 -0
  71. package/dist/training/index.d.ts.map +1 -0
  72. package/dist/training/jsonl-writer.d.ts +42 -0
  73. package/dist/training/jsonl-writer.d.ts.map +1 -0
  74. package/dist/training/schema-synthesizer.d.ts +20 -0
  75. package/dist/training/schema-synthesizer.d.ts.map +1 -0
  76. package/dist/training/types.d.ts +73 -0
  77. package/dist/training/types.d.ts.map +1 -0
  78. package/package.json +5 -1
  79. package/src/aot/index.ts +1 -0
  80. package/src/aot/registry-bridge.test.ts +104 -0
  81. package/src/aot/registry-bridge.ts +66 -0
  82. package/src/api/create-dsl.test.ts +306 -0
  83. package/src/api/create-dsl.ts +21 -0
  84. package/src/api/dispatcher.test.ts +66 -0
  85. package/src/api/dispatcher.ts +77 -9
  86. package/src/api/domain-registry.ts +120 -4
  87. package/src/core/pattern-matching/pattern-matcher.test.ts +131 -0
  88. package/src/core/pattern-matching/pattern-matcher.ts +102 -19
  89. package/src/core/types.ts +145 -2
  90. package/src/feedback/confidence-gate.test.ts +111 -0
  91. package/src/feedback/confidence-gate.ts +70 -0
  92. package/src/feedback/feedback-formatter.test.ts +198 -0
  93. package/src/feedback/feedback-formatter.ts +298 -0
  94. package/src/feedback/index.ts +24 -0
  95. package/src/feedback/pattern-tracker.test.ts +198 -0
  96. package/src/feedback/pattern-tracker.ts +140 -0
  97. package/src/feedback/types.ts +149 -0
  98. package/src/generation/pattern-generator.ts +2 -2
  99. package/src/index.ts +22 -0
  100. package/src/integration/llm-lse-integration.test.ts +457 -0
  101. package/src/ir/explicit-parser.test.ts +306 -0
  102. package/src/ir/explicit-parser.ts +419 -0
  103. package/src/ir/explicit-renderer.test.ts +144 -0
  104. package/src/ir/explicit-renderer.ts +146 -0
  105. package/src/ir/from-interchange.test.ts +853 -0
  106. package/src/ir/from-interchange.ts +594 -0
  107. package/src/ir/index.ts +66 -0
  108. package/src/ir/json-schema.test.ts +281 -0
  109. package/src/ir/json-schema.ts +299 -0
  110. package/src/ir/protocol-json.test.ts +1185 -0
  111. package/src/ir/protocol-json.ts +745 -0
  112. package/src/ir/references.test.ts +38 -0
  113. package/src/ir/references.ts +33 -0
  114. package/src/ir/round-trip.test.ts +557 -0
  115. package/src/ir/types.ts +181 -0
  116. package/src/prompts/index.ts +15 -0
  117. package/src/prompts/prompt-generator.test.ts +386 -0
  118. package/src/prompts/prompt-generator.ts +531 -0
  119. package/src/prompts/types.ts +89 -0
  120. package/src/schema/command-schema.ts +19 -0
  121. package/src/training/index.ts +13 -0
  122. package/src/training/jsonl-writer.test.ts +138 -0
  123. package/src/training/jsonl-writer.ts +67 -0
  124. package/src/training/schema-synthesizer.test.ts +250 -0
  125. package/src/training/schema-synthesizer.ts +306 -0
  126. package/src/training/types.ts +103 -0
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isValidReference, DEFAULT_REFERENCES } from './references';
3
+
4
+ describe('isValidReference', () => {
5
+ it('recognizes default references', () => {
6
+ expect(isValidReference('me')).toBe(true);
7
+ expect(isValidReference('you')).toBe(true);
8
+ expect(isValidReference('it')).toBe(true);
9
+ expect(isValidReference('result')).toBe(true);
10
+ expect(isValidReference('event')).toBe(true);
11
+ expect(isValidReference('target')).toBe(true);
12
+ expect(isValidReference('body')).toBe(true);
13
+ });
14
+
15
+ it('rejects non-references', () => {
16
+ expect(isValidReference('foo')).toBe(false);
17
+ expect(isValidReference('hello')).toBe(false);
18
+ expect(isValidReference('')).toBe(false);
19
+ });
20
+
21
+ it('is case-sensitive', () => {
22
+ expect(isValidReference('Me')).toBe(false);
23
+ expect(isValidReference('IT')).toBe(false);
24
+ expect(isValidReference('Result')).toBe(false);
25
+ });
26
+
27
+ it('accepts custom reference set', () => {
28
+ const custom = new Set(['self', 'other', 'context']);
29
+ expect(isValidReference('self', custom)).toBe(true);
30
+ expect(isValidReference('other', custom)).toBe(true);
31
+ expect(isValidReference('me', custom)).toBe(false);
32
+ expect(isValidReference('it', custom)).toBe(false);
33
+ });
34
+
35
+ it('DEFAULT_REFERENCES contains exactly 7 entries', () => {
36
+ expect(DEFAULT_REFERENCES.size).toBe(7);
37
+ });
38
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reference Validation
3
+ *
4
+ * Configurable validation for reference values (me, you, it, result, etc.).
5
+ * Domain DSLs can provide custom reference sets.
6
+ */
7
+
8
+ /**
9
+ * Default set of valid references.
10
+ * Covers the common hyperscript references; domain DSLs can extend or replace.
11
+ */
12
+ export const DEFAULT_REFERENCES: ReadonlySet<string> = new Set([
13
+ 'me',
14
+ 'you',
15
+ 'it',
16
+ 'result',
17
+ 'event',
18
+ 'target',
19
+ 'body',
20
+ ]);
21
+
22
+ /**
23
+ * Check if a value is a valid reference name.
24
+ *
25
+ * @param value - The string to check
26
+ * @param referenceSet - Custom reference set (defaults to DEFAULT_REFERENCES)
27
+ */
28
+ export function isValidReference(
29
+ value: string,
30
+ referenceSet: ReadonlySet<string> = DEFAULT_REFERENCES
31
+ ): boolean {
32
+ return referenceSet.has(value);
33
+ }
@@ -0,0 +1,557 @@
1
+ /**
2
+ * LLM Round-Trip Pipeline Tests
3
+ *
4
+ * Validates the end-to-end pipeline:
5
+ * InterchangeNode → SemanticNode → LSE bracket → parseExplicit → semantic equivalence
6
+ * InterchangeNode → SemanticNode → protocol JSON → fromProtocolJSON → semantic equivalence
7
+ *
8
+ * With the kind-aware parseExplicit, bracket round-trips now preserve:
9
+ * - `kind` for event-handler, conditional, and loop nodes
10
+ * - `selectorKind` (inferred from selector prefix)
11
+ * - `thenBranch`/`elseBranch` for conditionals
12
+ * - `loopVariant`/`body` for loops
13
+ *
14
+ * These tests verify that the action, roles, and structural kind survive the trip.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { fromInterchangeNode } from './from-interchange';
19
+ import { renderExplicit } from './explicit-renderer';
20
+ import { parseExplicit } from './explicit-parser';
21
+ import { toProtocolJSON, fromProtocolJSON } from './protocol-json';
22
+
23
+ // =============================================================================
24
+ // Helpers
25
+ // =============================================================================
26
+
27
+ /** Round-trip through LSE bracket syntax */
28
+ function bracketRoundTrip(interchange: Record<string, unknown>) {
29
+ const semantic = fromInterchangeNode(interchange);
30
+ const lse = renderExplicit(semantic);
31
+ const parsed = parseExplicit(lse);
32
+ return { semantic, lse, parsed };
33
+ }
34
+
35
+ /** Round-trip through protocol JSON */
36
+ function protocolRoundTrip(interchange: Record<string, unknown>) {
37
+ const semantic = fromInterchangeNode(interchange);
38
+ const json = toProtocolJSON(semantic);
39
+ const restored = fromProtocolJSON(json);
40
+ return { semantic, json, restored };
41
+ }
42
+
43
+ /** Compare values by type and value only (ignores selectorKind, dataType, etc.) */
44
+ function valuesMatch(a: any, b: any): boolean {
45
+ if (!a || !b) return a === b;
46
+ return a.type === b.type && a.value === b.value;
47
+ }
48
+
49
+ // =============================================================================
50
+ // 1. Simple Commands
51
+ // =============================================================================
52
+
53
+ describe('LLM round-trip: simple commands', () => {
54
+ it('toggle .active — bracket round-trip preserves action and patient', () => {
55
+ const { semantic, parsed } = bracketRoundTrip({
56
+ type: 'command',
57
+ name: 'toggle',
58
+ args: [{ type: 'selector', value: '.active' }],
59
+ });
60
+
61
+ expect(parsed.action).toBe('toggle');
62
+ expect(parsed.kind).toBe('command');
63
+ expect(valuesMatch(parsed.roles.get('patient'), semantic.roles.get('patient'))).toBe(true);
64
+ });
65
+
66
+ it('toggle .active — protocol JSON round-trip preserves full structure', () => {
67
+ const { semantic, restored } = protocolRoundTrip({
68
+ type: 'command',
69
+ name: 'toggle',
70
+ args: [{ type: 'selector', value: '.active' }],
71
+ });
72
+
73
+ expect(restored.action).toBe('toggle');
74
+ expect(restored.roles.get('patient')).toEqual(semantic.roles.get('patient'));
75
+ });
76
+
77
+ it('add .highlight to #button — bracket round-trip', () => {
78
+ const { parsed } = bracketRoundTrip({
79
+ type: 'command',
80
+ name: 'add',
81
+ args: [{ type: 'selector', value: '.highlight' }],
82
+ target: { type: 'selector', value: '#button' },
83
+ });
84
+
85
+ expect(parsed.action).toBe('add');
86
+ // Patient and destination survive the trip
87
+ const patient = parsed.roles.get('patient');
88
+ const dest = parsed.roles.get('destination');
89
+ expect(patient?.type).toBe('selector');
90
+ expect(patient?.value).toBe('.highlight');
91
+ expect(dest?.type).toBe('selector');
92
+ expect(dest?.value).toBe('#button');
93
+ });
94
+
95
+ it('set :count to 42 — bracket round-trip', () => {
96
+ const { parsed } = bracketRoundTrip({
97
+ type: 'command',
98
+ name: 'set',
99
+ args: [
100
+ { type: 'variable', name: 'count', scope: 'element' },
101
+ { type: 'literal', value: 42 },
102
+ ],
103
+ });
104
+
105
+ expect(parsed.action).toBe('set');
106
+ });
107
+
108
+ it('remove .error from me — bracket round-trip preserves roles', () => {
109
+ const { parsed } = bracketRoundTrip({
110
+ type: 'command',
111
+ name: 'remove',
112
+ args: [{ type: 'selector', value: '.error' }],
113
+ target: { type: 'identifier', value: 'me' },
114
+ });
115
+
116
+ expect(parsed.action).toBe('remove');
117
+ expect(parsed.roles.get('patient')?.value).toBe('.error');
118
+ });
119
+
120
+ it('bracket round-trip preserves selectorKind', () => {
121
+ const { semantic, parsed } = bracketRoundTrip({
122
+ type: 'command',
123
+ name: 'toggle',
124
+ args: [{ type: 'selector', value: '.active' }],
125
+ });
126
+
127
+ const originalPatient = semantic.roles.get('patient');
128
+ const parsedPatient = parsed.roles.get('patient');
129
+ expect(originalPatient?.type).toBe('selector');
130
+ expect(parsedPatient?.type).toBe('selector');
131
+ // selectorKind now preserved through bracket round-trip
132
+ if (originalPatient?.type === 'selector' && parsedPatient?.type === 'selector') {
133
+ expect(parsedPatient.selectorKind).toBe(originalPatient.selectorKind);
134
+ }
135
+ });
136
+ });
137
+
138
+ // =============================================================================
139
+ // 2. Event Handlers
140
+ // =============================================================================
141
+
142
+ describe('LLM round-trip: event handlers', () => {
143
+ it('on click toggle .active — bracket round-trip preserves event-handler kind', () => {
144
+ const { lse, parsed } = bracketRoundTrip({
145
+ type: 'event',
146
+ event: 'click',
147
+ body: [
148
+ {
149
+ type: 'command',
150
+ name: 'toggle',
151
+ args: [{ type: 'selector', value: '.active' }],
152
+ },
153
+ ],
154
+ });
155
+
156
+ // The LSE bracket form includes the event name and command
157
+ expect(lse).toContain('on');
158
+ expect(lse).toContain('click');
159
+ expect(lse).toContain('toggle');
160
+ expect(lse).toContain('.active');
161
+
162
+ // Kind-aware parser produces event-handler node
163
+ expect(parsed.kind).toBe('event-handler');
164
+ expect(parsed.action).toBe('on');
165
+ });
166
+
167
+ it('on click toggle .active — protocol JSON preserves event-handler kind', () => {
168
+ const { semantic, restored } = protocolRoundTrip({
169
+ type: 'event',
170
+ event: 'click',
171
+ body: [
172
+ {
173
+ type: 'command',
174
+ name: 'toggle',
175
+ args: [{ type: 'selector', value: '.active' }],
176
+ },
177
+ ],
178
+ });
179
+
180
+ expect(restored.kind).toBe('event-handler');
181
+ // Protocol JSON preserves the full structure
182
+ expect(restored.action).toBe(semantic.action);
183
+ });
184
+
185
+ it('multi-command event handler LSE contains all commands', () => {
186
+ const { lse } = bracketRoundTrip({
187
+ type: 'event',
188
+ event: 'click',
189
+ body: [
190
+ {
191
+ type: 'command',
192
+ name: 'add',
193
+ args: [{ type: 'selector', value: '.loading' }],
194
+ },
195
+ {
196
+ type: 'command',
197
+ name: 'fetch',
198
+ args: [{ type: 'literal', value: '/api/data' }],
199
+ },
200
+ {
201
+ type: 'command',
202
+ name: 'remove',
203
+ args: [{ type: 'selector', value: '.loading' }],
204
+ },
205
+ ],
206
+ });
207
+
208
+ expect(lse).toContain('add');
209
+ expect(lse).toContain('fetch');
210
+ expect(lse).toContain('remove');
211
+ });
212
+ });
213
+
214
+ // =============================================================================
215
+ // 3. Control Flow
216
+ // =============================================================================
217
+
218
+ describe('LLM round-trip: control flow', () => {
219
+ it('if condition — bracket round-trip preserves conditional kind', () => {
220
+ // Use a simple condition (no operators) that round-trips cleanly
221
+ const { parsed } = bracketRoundTrip({
222
+ type: 'if',
223
+ condition: { type: 'identifier', value: 'visible' },
224
+ thenBranch: [
225
+ {
226
+ type: 'command',
227
+ name: 'show',
228
+ args: [{ type: 'selector', value: '#message' }],
229
+ },
230
+ ],
231
+ elseBranch: [],
232
+ });
233
+
234
+ // Kind-aware parser produces conditional node
235
+ expect(parsed.kind).toBe('conditional');
236
+ expect(parsed.action).toBe('if');
237
+ });
238
+
239
+ it('if condition with complex expression — renders but complex exprs need protocol JSON', () => {
240
+ // Note: bracket syntax for complex conditions (containing operators like ==)
241
+ // doesn't cleanly round-trip through parseExplicit because the parser treats
242
+ // tokens like "==" as role-format markers. Use protocol JSON for lossless round-trip.
243
+ const semantic = fromInterchangeNode({
244
+ type: 'if',
245
+ condition: {
246
+ type: 'binary',
247
+ operator: '==',
248
+ left: { type: 'identifier', value: 'x' },
249
+ right: { type: 'literal', value: 0 },
250
+ },
251
+ thenBranch: [
252
+ {
253
+ type: 'command',
254
+ name: 'show',
255
+ args: [{ type: 'selector', value: '#message' }],
256
+ },
257
+ ],
258
+ elseBranch: [],
259
+ });
260
+
261
+ // Verify it renders to bracket syntax (for LLM display)
262
+ const lse = renderExplicit(semantic);
263
+ expect(lse).toContain('if');
264
+ expect(lse).toContain('show');
265
+ });
266
+
267
+ it('if condition — protocol JSON preserves conditional kind', () => {
268
+ const { restored } = protocolRoundTrip({
269
+ type: 'if',
270
+ condition: {
271
+ type: 'binary',
272
+ operator: '==',
273
+ left: { type: 'identifier', value: 'x' },
274
+ right: { type: 'literal', value: 0 },
275
+ },
276
+ thenBranch: [
277
+ {
278
+ type: 'command',
279
+ name: 'show',
280
+ args: [{ type: 'selector', value: '#message' }],
281
+ },
282
+ ],
283
+ elseBranch: [],
284
+ });
285
+
286
+ expect(restored.kind).toBe('conditional');
287
+ });
288
+
289
+ it('repeat loop — bracket round-trip preserves loop kind', () => {
290
+ const { lse, parsed } = bracketRoundTrip({
291
+ type: 'repeat',
292
+ count: 5,
293
+ body: [
294
+ {
295
+ type: 'command',
296
+ name: 'log',
297
+ args: [{ type: 'literal', value: 'tick' }],
298
+ },
299
+ ],
300
+ });
301
+
302
+ expect(lse).toContain('repeat');
303
+ expect(lse).toContain('log');
304
+
305
+ // Kind-aware parser produces loop node
306
+ expect(parsed.kind).toBe('loop');
307
+ expect(parsed.action).toBe('repeat');
308
+ });
309
+
310
+ it('foreach loop — protocol JSON preserves loop kind', () => {
311
+ const { restored } = protocolRoundTrip({
312
+ type: 'foreach',
313
+ itemName: 'item',
314
+ collection: { type: 'selector', value: '.items' },
315
+ body: [
316
+ {
317
+ type: 'command',
318
+ name: 'add',
319
+ args: [{ type: 'selector', value: '.highlight' }],
320
+ },
321
+ ],
322
+ });
323
+
324
+ expect(restored.kind).toBe('loop');
325
+ });
326
+ });
327
+
328
+ // =============================================================================
329
+ // 4. Protocol JSON Round-Trip (lossless)
330
+ // =============================================================================
331
+
332
+ describe('LLM round-trip: protocol JSON is lossless', () => {
333
+ const testCases = [
334
+ {
335
+ name: 'toggle .active',
336
+ node: {
337
+ type: 'command',
338
+ name: 'toggle',
339
+ args: [{ type: 'selector', value: '.active' }],
340
+ },
341
+ },
342
+ {
343
+ name: 'show #modal',
344
+ node: {
345
+ type: 'command',
346
+ name: 'show',
347
+ args: [{ type: 'selector', value: '#modal' }],
348
+ },
349
+ },
350
+ ];
351
+
352
+ for (const tc of testCases) {
353
+ it(`${tc.name}: protocol JSON → fromProtocolJSON → toProtocolJSON is identity`, () => {
354
+ const semantic = fromInterchangeNode(tc.node);
355
+ const json1 = toProtocolJSON(semantic);
356
+ const restored = fromProtocolJSON(json1);
357
+ const json2 = toProtocolJSON(restored);
358
+
359
+ expect(json2).toEqual(json1);
360
+ });
361
+ }
362
+ });
363
+
364
+ // =============================================================================
365
+ // 5. Idempotency
366
+ // =============================================================================
367
+
368
+ describe('LLM round-trip: idempotency', () => {
369
+ it('bracket render → parse → render is idempotent for commands', () => {
370
+ const semantic = fromInterchangeNode({
371
+ type: 'command',
372
+ name: 'toggle',
373
+ args: [{ type: 'selector', value: '.active' }],
374
+ });
375
+
376
+ const lse1 = renderExplicit(semantic);
377
+ const parsed = parseExplicit(lse1);
378
+ const lse2 = renderExplicit(parsed);
379
+ const parsed2 = parseExplicit(lse2);
380
+ const lse3 = renderExplicit(parsed2);
381
+
382
+ expect(lse2).toBe(lse1);
383
+ expect(lse3).toBe(lse1);
384
+ });
385
+
386
+ it('bracket render → parse → render is idempotent for event handlers', () => {
387
+ const semantic = fromInterchangeNode({
388
+ type: 'event',
389
+ event: 'click',
390
+ body: [
391
+ {
392
+ type: 'command',
393
+ name: 'toggle',
394
+ args: [{ type: 'selector', value: '.active' }],
395
+ },
396
+ ],
397
+ });
398
+
399
+ const lse1 = renderExplicit(semantic);
400
+ const parsed = parseExplicit(lse1);
401
+ const lse2 = renderExplicit(parsed);
402
+
403
+ expect(lse2).toBe(lse1);
404
+ expect(parsed.kind).toBe('event-handler');
405
+ });
406
+
407
+ it('bracket render → parse → render is idempotent for loops', () => {
408
+ const semantic = fromInterchangeNode({
409
+ type: 'repeat',
410
+ count: 3,
411
+ body: [
412
+ {
413
+ type: 'command',
414
+ name: 'log',
415
+ args: [{ type: 'literal', value: 'tick' }],
416
+ },
417
+ ],
418
+ });
419
+
420
+ const lse1 = renderExplicit(semantic);
421
+ const parsed = parseExplicit(lse1);
422
+ const lse2 = renderExplicit(parsed);
423
+
424
+ expect(lse2).toBe(lse1);
425
+ expect(parsed.kind).toBe('loop');
426
+ });
427
+
428
+ it('protocol JSON serialize → deserialize → serialize is idempotent', () => {
429
+ const semantic = fromInterchangeNode({
430
+ type: 'event',
431
+ event: 'click',
432
+ body: [
433
+ {
434
+ type: 'command',
435
+ name: 'add',
436
+ args: [{ type: 'selector', value: '.clicked' }],
437
+ },
438
+ ],
439
+ });
440
+
441
+ const json1 = toProtocolJSON(semantic);
442
+ const restored = fromProtocolJSON(json1);
443
+ const json2 = toProtocolJSON(restored);
444
+
445
+ expect(json2).toEqual(json1);
446
+ });
447
+ });
448
+
449
+ // =============================================================================
450
+ // 6. End-to-end: same input through both paths
451
+ // =============================================================================
452
+
453
+ describe('LLM round-trip: both paths from same input', () => {
454
+ it('simple command produces valid LSE through both bracket and JSON paths', () => {
455
+ const input = {
456
+ type: 'command',
457
+ name: 'toggle',
458
+ args: [{ type: 'selector', value: '.active' }],
459
+ };
460
+
461
+ // Path A: bracket
462
+ const semantic = fromInterchangeNode(input);
463
+ const lse = renderExplicit(semantic);
464
+ expect(lse).toContain('toggle');
465
+ expect(lse).toContain('.active');
466
+
467
+ // Path B: protocol JSON
468
+ const json = toProtocolJSON(semantic);
469
+ expect(json.action).toBe('toggle');
470
+ expect(json.kind).toBe('command');
471
+
472
+ // Both produce parseable output
473
+ const fromBracket = parseExplicit(lse);
474
+ const fromJSON = fromProtocolJSON(json);
475
+ expect(fromBracket.action).toBe('toggle');
476
+ expect(fromJSON.action).toBe('toggle');
477
+ });
478
+
479
+ it('event handler produces consistent kind through both paths', () => {
480
+ const input = {
481
+ type: 'event',
482
+ event: 'click',
483
+ body: [
484
+ {
485
+ type: 'command',
486
+ name: 'toggle',
487
+ args: [{ type: 'selector', value: '.active' }],
488
+ },
489
+ ],
490
+ };
491
+
492
+ const semantic = fromInterchangeNode(input);
493
+
494
+ // Bracket path
495
+ const lse = renderExplicit(semantic);
496
+ const fromBracket = parseExplicit(lse);
497
+ expect(fromBracket.kind).toBe('event-handler');
498
+
499
+ // Protocol JSON path
500
+ const json = toProtocolJSON(semantic);
501
+ const fromJSON = fromProtocolJSON(json);
502
+ expect(fromJSON.kind).toBe('event-handler');
503
+ });
504
+ });
505
+
506
+ // =============================================================================
507
+ // 7. Direct parseExplicit kind-awareness
508
+ // =============================================================================
509
+
510
+ describe('parseExplicit kind-awareness', () => {
511
+ it('parses [on ...] as event-handler', () => {
512
+ const node = parseExplicit('[on event:click body:[toggle patient:.active]]');
513
+ expect(node.kind).toBe('event-handler');
514
+ expect(node.action).toBe('on');
515
+ expect(node.roles.get('event')?.value).toBe('click');
516
+ });
517
+
518
+ it('parses [if ...] as conditional', () => {
519
+ const node = parseExplicit('[if condition:visible then:[show patient:#msg]]');
520
+ expect(node.kind).toBe('conditional');
521
+ expect(node.action).toBe('if');
522
+ });
523
+
524
+ it('parses [repeat ...] as loop with times variant', () => {
525
+ const node = parseExplicit('[repeat quantity:5 loop-body:[log patient:"tick"]]');
526
+ expect(node.kind).toBe('loop');
527
+ expect(node.action).toBe('repeat');
528
+ if (node.kind === 'loop') {
529
+ expect((node as any).loopVariant).toBe('times');
530
+ }
531
+ });
532
+
533
+ it('parses [repeat ...] as loop with for variant', () => {
534
+ const node = parseExplicit('[repeat source:.items variable:item loop-body:[add patient:.hl]]');
535
+ expect(node.kind).toBe('loop');
536
+ if (node.kind === 'loop') {
537
+ expect((node as any).loopVariant).toBe('for');
538
+ expect((node as any).loopVariable).toBe('item');
539
+ }
540
+ });
541
+
542
+ it('parses [repeat ...] with explicit loop-variant', () => {
543
+ const node = parseExplicit(
544
+ '[repeat loop-variant:while condition:running loop-body:[log patient:"waiting"]]'
545
+ );
546
+ expect(node.kind).toBe('loop');
547
+ if (node.kind === 'loop') {
548
+ expect((node as any).loopVariant).toBe('while');
549
+ }
550
+ });
551
+
552
+ it('parses regular commands as command kind', () => {
553
+ const node = parseExplicit('[toggle patient:.active]');
554
+ expect(node.kind).toBe('command');
555
+ expect(node.action).toBe('toggle');
556
+ });
557
+ });