@lokascript/framework 2.1.0 → 2.2.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 (52) hide show
  1. package/dist/api/index.js +111 -24
  2. package/dist/api/index.js.map +1 -1
  3. package/dist/core/index.js +133 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/pattern-matching/index.js.map +1 -1
  6. package/dist/core/tokenization/index.js +133 -0
  7. package/dist/core/tokenization/index.js.map +1 -1
  8. package/dist/core/tokenization/morphology/base-normalizer.d.ts +94 -0
  9. package/dist/core/tokenization/morphology/base-normalizer.d.ts.map +1 -0
  10. package/dist/core/tokenization/morphology/index.d.ts +3 -1
  11. package/dist/core/tokenization/morphology/index.d.ts.map +1 -1
  12. package/dist/core/types.d.ts +3 -2
  13. package/dist/core/types.d.ts.map +1 -1
  14. package/dist/core/types.js.map +1 -1
  15. package/dist/generation/index.js.map +1 -1
  16. package/dist/index.cjs +564 -46
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +556 -46
  21. package/dist/index.js.map +1 -1
  22. package/dist/ir/explicit-parser.d.ts +62 -7
  23. package/dist/ir/explicit-parser.d.ts.map +1 -1
  24. package/dist/ir/explicit-renderer.d.ts +18 -1
  25. package/dist/ir/explicit-renderer.d.ts.map +1 -1
  26. package/dist/ir/index.d.ts +4 -2
  27. package/dist/ir/index.d.ts.map +1 -1
  28. package/dist/ir/index.js +423 -46
  29. package/dist/ir/index.js.map +1 -1
  30. package/dist/ir/protocol-json.d.ts.map +1 -1
  31. package/dist/ir/to-runtime-ast.d.ts +53 -0
  32. package/dist/ir/to-runtime-ast.d.ts.map +1 -0
  33. package/dist/ir/types.d.ts +14 -4
  34. package/dist/ir/types.d.ts.map +1 -1
  35. package/dist/parsing/index.js.map +1 -1
  36. package/dist/testing/index.js +8671 -8261
  37. package/dist/testing/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/core/tokenization/morphology/base-normalizer.ts +249 -0
  40. package/src/core/tokenization/morphology/index.ts +3 -1
  41. package/src/core/types.ts +4 -2
  42. package/src/index.ts +7 -0
  43. package/src/ir/conformance.test.ts +120 -0
  44. package/src/ir/explicit-parser.test.ts +307 -1
  45. package/src/ir/explicit-parser.ts +403 -29
  46. package/src/ir/explicit-renderer.test.ts +87 -2
  47. package/src/ir/explicit-renderer.ts +57 -5
  48. package/src/ir/index.ts +13 -2
  49. package/src/ir/protocol-json.test.ts +4 -2
  50. package/src/ir/protocol-json.ts +30 -24
  51. package/src/ir/to-runtime-ast.ts +203 -0
  52. package/src/ir/types.ts +14 -4
@@ -4,14 +4,15 @@
4
4
  * Parses the universal [command role:value ...] bracket syntax.
5
5
  * Domain-agnostic — schema validation is injectable via ParseExplicitOptions.
6
6
  *
7
+ * Three parsing levels matching the ABNF grammar:
8
+ * parseDocument() → parseCompound() → parseExplicit()
9
+ * (multi-line) (chain ops) (single bracket)
10
+ *
7
11
  * Syntax:
8
12
  * [command role1:value1 role2:value2 ...]
9
- *
10
- * Examples:
11
- * [toggle patient:.active destination:#button]
12
- * [put patient:"hello" destination:#output]
13
- * [on event:click body:[toggle patient:.active]]
14
- * [select patient:name source:users condition:age>25]
13
+ * [cmd1 ...] then [cmd2 ...]
14
+ * @annotation(value) [command ...]
15
+ * #!lse 1.2\n[cmd1 ...]\n[cmd2 ...]
15
16
  */
16
17
 
17
18
  import type {
@@ -20,17 +21,22 @@ import type {
20
21
  SemanticRole,
21
22
  ActionType,
22
23
  LoopVariant,
24
+ Annotation,
25
+ LSEEnvelope,
26
+ CompoundSemanticNode,
23
27
  } from '../core/types';
24
28
  import {
25
29
  createCommandNode,
26
30
  createEventHandlerNode,
27
31
  createConditionalNode,
28
32
  createLoopNode,
33
+ createCompoundNode,
29
34
  createSelector,
30
35
  createLiteral,
31
36
  createReference,
32
37
  createFlag,
33
38
  } from '../core/types';
39
+ import type { Diagnostic } from '../generation/diagnostics';
34
40
  import type { ParseExplicitOptions } from './types';
35
41
  import { isValidReference, DEFAULT_REFERENCES } from './references';
36
42
 
@@ -74,6 +80,8 @@ function isNestedCommand(value: string): boolean {
74
80
  */
75
81
  export function parseExplicit(input: string, options: ParseExplicitOptions = {}): SemanticNode {
76
82
  const trimmed = input.trim();
83
+ const collect = options.collectDiagnostics === true;
84
+ const diagnostics: Diagnostic[] = [];
77
85
 
78
86
  if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
79
87
  throw new Error('Explicit syntax must be wrapped in brackets: [command role:value ...]');
@@ -106,10 +114,31 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
106
114
  const enabled = token.startsWith('+');
107
115
  const flagName = token.slice(1);
108
116
  if (!flagName) {
117
+ if (collect) {
118
+ diagnostics.push({
119
+ severity: 'error',
120
+ code: 'EMPTY_FLAG',
121
+ message: `Empty flag name: "${token}"`,
122
+ source: 'schema',
123
+ });
124
+ continue;
125
+ }
109
126
  throw new Error(`Empty flag name: "${token}"`);
110
127
  }
111
128
  if (validRoleNames && !validRoleNames.has(flagName as SemanticRole)) {
112
129
  const roleList = [...validRoleNames].join(', ');
130
+ if (collect) {
131
+ diagnostics.push({
132
+ severity: 'error',
133
+ code: 'UNKNOWN_FLAG',
134
+ message: `Unknown flag "${flagName}" for command "${command}"`,
135
+ source: 'schema',
136
+ suggestions: [...validRoleNames].map(r => `+${r}`),
137
+ });
138
+ // Still add the flag so the node is usable
139
+ roles.set(flagName as SemanticRole, createFlag(flagName, enabled));
140
+ continue;
141
+ }
113
142
  throw new Error(
114
143
  `Unknown flag "${flagName}" for command "${command}". Valid roles: ${roleList}`
115
144
  );
@@ -121,6 +150,15 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
121
150
  const colonIndex = token.indexOf(':');
122
151
 
123
152
  if (colonIndex === -1) {
153
+ if (collect) {
154
+ diagnostics.push({
155
+ severity: 'error',
156
+ code: 'INVALID_ROLE_FORMAT',
157
+ message: `Invalid role format: "${token}". Expected role:value or +flag`,
158
+ source: 'schema',
159
+ });
160
+ continue;
161
+ }
124
162
  throw new Error(`Invalid role format: "${token}". Expected role:value or +flag`);
125
163
  }
126
164
 
@@ -133,9 +171,20 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
133
171
  !validRoleNames.has(roleName as SemanticRole)
134
172
  ) {
135
173
  const roleList = [...validRoleNames].join(', ');
136
- throw new Error(
137
- `Unknown role "${roleName}" for command "${command}". Valid roles: ${roleList}`
138
- );
174
+ if (collect) {
175
+ diagnostics.push({
176
+ severity: 'error',
177
+ code: 'UNKNOWN_ROLE',
178
+ message: `Unknown role "${roleName}" for command "${command}"`,
179
+ source: 'schema',
180
+ suggestions: [...validRoleNames],
181
+ });
182
+ // Still parse and add the role so the node is usable
183
+ } else {
184
+ throw new Error(
185
+ `Unknown role "${roleName}" for command "${command}". Valid roles: ${roleList}`
186
+ );
187
+ }
139
188
  }
140
189
 
141
190
  const role = roleName as SemanticRole;
@@ -157,18 +206,47 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
157
206
  if (schema && command !== 'on') {
158
207
  for (const roleSpec of schema.roles) {
159
208
  if (roleSpec.required && !roles.has(roleSpec.role as SemanticRole) && !roleSpec.default) {
160
- throw new Error(
161
- `Missing required role "${roleSpec.role}" for command "${command}": ${roleSpec.description}`
162
- );
209
+ if (collect) {
210
+ diagnostics.push({
211
+ severity: 'error',
212
+ code: 'MISSING_REQUIRED_ROLE',
213
+ message: `Missing required role "${roleSpec.role}" for command "${command}": ${roleSpec.description}`,
214
+ source: 'schema',
215
+ });
216
+ } else {
217
+ throw new Error(
218
+ `Missing required role "${roleSpec.role}" for command "${command}": ${roleSpec.description}`
219
+ );
220
+ }
163
221
  }
164
222
  }
165
223
  }
166
224
 
225
+ // Helper: attach diagnostics to a node if any were collected
226
+ const attachDiagnostics = (node: SemanticNode): SemanticNode => {
227
+ if (diagnostics.length === 0) return node;
228
+ return { ...node, diagnostics };
229
+ };
230
+
167
231
  // Build appropriate node type based on action name
168
232
  switch (command) {
169
233
  case 'on': {
170
234
  const eventValue = roles.get('event');
171
235
  if (!eventValue) {
236
+ if (collect) {
237
+ diagnostics.push({
238
+ severity: 'error',
239
+ code: 'MISSING_EVENT_ROLE',
240
+ message: 'Event handler requires event role: [on event:click ...]',
241
+ source: 'schema',
242
+ });
243
+ // Return a stub event handler with diagnostics
244
+ return attachDiagnostics(
245
+ createEventHandlerNode('on', roles, [], {
246
+ sourceLanguage: 'explicit',
247
+ })
248
+ );
249
+ }
172
250
  throw new Error('Event handler requires event role: [on event:click ...]');
173
251
  }
174
252
 
@@ -176,9 +254,11 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
176
254
  const body = extractStructuralBody(roles, 'body', options);
177
255
  roles.delete('body' as SemanticRole);
178
256
 
179
- return createEventHandlerNode('on', roles, body, {
180
- sourceLanguage: 'explicit',
181
- });
257
+ return attachDiagnostics(
258
+ createEventHandlerNode('on', roles, body, {
259
+ sourceLanguage: 'explicit',
260
+ })
261
+ );
182
262
  }
183
263
 
184
264
  case 'if': {
@@ -188,14 +268,16 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
188
268
  roles.delete('then' as SemanticRole);
189
269
  roles.delete('else' as SemanticRole);
190
270
 
191
- return createConditionalNode(
192
- command,
193
- roles,
194
- thenBranch,
195
- elseBranch.length > 0 ? elseBranch : undefined,
196
- {
197
- sourceLanguage: 'explicit',
198
- }
271
+ return attachDiagnostics(
272
+ createConditionalNode(
273
+ command,
274
+ roles,
275
+ thenBranch,
276
+ elseBranch.length > 0 ? elseBranch : undefined,
277
+ {
278
+ sourceLanguage: 'explicit',
279
+ }
280
+ )
199
281
  );
200
282
  }
201
283
 
@@ -240,15 +322,19 @@ export function parseExplicit(input: string, options: ParseExplicitOptions = {})
240
322
  ? indexVariableValue.value
241
323
  : undefined;
242
324
 
243
- return createLoopNode(command, roles, variant, loopBody, loopVariable, indexVariable, {
244
- sourceLanguage: 'explicit',
245
- });
325
+ return attachDiagnostics(
326
+ createLoopNode(command, roles, variant, loopBody, loopVariable, indexVariable, {
327
+ sourceLanguage: 'explicit',
328
+ })
329
+ );
246
330
  }
247
331
 
248
332
  default:
249
- return createCommandNode(command, roles, {
250
- sourceLanguage: 'explicit',
251
- });
333
+ return attachDiagnostics(
334
+ createCommandNode(command, roles, {
335
+ sourceLanguage: 'explicit',
336
+ })
337
+ );
252
338
  }
253
339
  }
254
340
 
@@ -260,6 +346,294 @@ export function isExplicitSyntax(input: string): boolean {
260
346
  return trimmed.startsWith('[') && trimmed.endsWith(']');
261
347
  }
262
348
 
349
+ // =============================================================================
350
+ // Compound Statement Parsing
351
+ // =============================================================================
352
+
353
+ /** Chain operators per ABNF: "then" / "and" / "async" / "sequential" / "|>" */
354
+ const CHAIN_KEYWORDS = new Set(['then', 'and', 'async', 'sequential']);
355
+
356
+ /**
357
+ * Split input into bracket-command segments separated by chain operators.
358
+ * Returns segments and the chain operator found (first one wins for mixed).
359
+ */
360
+ function splitCompoundSegments(input: string): {
361
+ segments: string[];
362
+ chainType: CompoundSemanticNode['chainType'] | null;
363
+ } {
364
+ const segments: string[] = [];
365
+ let chainType: CompoundSemanticNode['chainType'] | null = null;
366
+ let current = '';
367
+ let bracketDepth = 0;
368
+ let inString = false;
369
+ let stringChar = '';
370
+
371
+ for (let i = 0; i < input.length; i++) {
372
+ const char = input[i];
373
+
374
+ if (inString) {
375
+ current += char;
376
+ if (char === stringChar && input[i - 1] !== '\\') {
377
+ inString = false;
378
+ }
379
+ continue;
380
+ }
381
+
382
+ if (char === '"' || char === "'") {
383
+ inString = true;
384
+ stringChar = char;
385
+ current += char;
386
+ continue;
387
+ }
388
+
389
+ if (char === '[') {
390
+ bracketDepth++;
391
+ current += char;
392
+ continue;
393
+ }
394
+
395
+ if (char === ']') {
396
+ bracketDepth--;
397
+ current += char;
398
+
399
+ // At depth 0 after closing bracket, check for chain operator
400
+ if (bracketDepth === 0) {
401
+ const rest = input.slice(i + 1);
402
+ // Check for |> pipe operator
403
+ const pipeMatch = rest.match(/^\s*\|>\s*/);
404
+ if (pipeMatch) {
405
+ segments.push(current.trim());
406
+ current = '';
407
+ chainType ??= 'pipe';
408
+ i += pipeMatch[0].length;
409
+ continue;
410
+ }
411
+ // Check for keyword chain operators
412
+ const keywordMatch = rest.match(/^\s+(then|and|async|sequential)\s+/);
413
+ if (keywordMatch && CHAIN_KEYWORDS.has(keywordMatch[1])) {
414
+ segments.push(current.trim());
415
+ current = '';
416
+ chainType ??= keywordMatch[1] as CompoundSemanticNode['chainType'];
417
+ i += keywordMatch[0].length;
418
+ continue;
419
+ }
420
+ }
421
+ continue;
422
+ }
423
+
424
+ current += char;
425
+ }
426
+
427
+ if (current.trim()) {
428
+ segments.push(current.trim());
429
+ }
430
+
431
+ return { segments, chainType };
432
+ }
433
+
434
+ /**
435
+ * Parse a compound statement (one or more bracket commands chained with operators).
436
+ *
437
+ * ABNF: compound-stmt = bracket-cmd *( chain-op bracket-cmd )
438
+ *
439
+ * Single commands pass through to parseExplicit(). Multiple commands produce
440
+ * a CompoundSemanticNode.
441
+ *
442
+ * Also handles annotations (@name or @name(value)) before the first bracket.
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * // Single command — passthrough
447
+ * parseCompound('[toggle patient:.active]')
448
+ *
449
+ * // Compound statement
450
+ * parseCompound('[add patient:.loading] then [fetch source:/api/data]')
451
+ *
452
+ * // With annotation
453
+ * parseCompound('@timeout(5s) [fetch source:/api/users]')
454
+ * ```
455
+ */
456
+ export function parseCompound(input: string, options?: ParseExplicitOptions): SemanticNode {
457
+ const trimmed = input.trim();
458
+
459
+ // Extract annotations before the first bracket
460
+ const { annotations, remainder } = extractAnnotations(trimmed);
461
+
462
+ const { segments, chainType } = splitCompoundSegments(remainder);
463
+
464
+ let node: SemanticNode;
465
+ if (segments.length <= 1) {
466
+ // Single command — delegate to parseExplicit
467
+ node = parseExplicit(segments[0] || remainder, options);
468
+ } else {
469
+ // Multiple commands — create compound node
470
+ const statements = segments.map(seg => parseExplicit(seg, options));
471
+ node = createCompoundNode(statements, chainType ?? 'sequential');
472
+ }
473
+
474
+ // Attach annotations if any
475
+ if (annotations.length > 0) {
476
+ return { ...node, annotations };
477
+ }
478
+ return node;
479
+ }
480
+
481
+ // =============================================================================
482
+ // Annotation Parsing
483
+ // =============================================================================
484
+
485
+ /**
486
+ * Extract @name and @name(value) annotations from before the first bracket.
487
+ * Per ABNF, annotations precede the compound statement on the same line.
488
+ */
489
+ function extractAnnotations(input: string): { annotations: Annotation[]; remainder: string } {
490
+ const annotations: Annotation[] = [];
491
+ let pos = 0;
492
+
493
+ while (pos < input.length) {
494
+ // Skip whitespace
495
+ while (pos < input.length && (input[pos] === ' ' || input[pos] === '\t')) {
496
+ pos++;
497
+ }
498
+
499
+ // If we hit '[' or end of string, we're done with annotations
500
+ if (pos >= input.length || input[pos] !== '@') {
501
+ break;
502
+ }
503
+
504
+ // Parse @name
505
+ pos++; // skip @
506
+ const nameStart = pos;
507
+ while (pos < input.length && /[A-Za-z0-9\-_]/.test(input[pos])) {
508
+ pos++;
509
+ }
510
+ const name = input.slice(nameStart, pos);
511
+ if (!name) break;
512
+
513
+ // Check for (value)
514
+ let value: string | undefined;
515
+ if (pos < input.length && input[pos] === '(') {
516
+ pos++; // skip (
517
+ const valueStart = pos;
518
+ // Handle quoted strings inside annotation values
519
+ if (pos < input.length && (input[pos] === '"' || input[pos] === "'")) {
520
+ const quote = input[pos];
521
+ pos++; // skip opening quote
522
+ while (pos < input.length && input[pos] !== quote) {
523
+ if (input[pos] === '\\') pos++; // skip escape
524
+ pos++;
525
+ }
526
+ pos++; // skip closing quote
527
+ value = input.slice(valueStart + 1, pos - 1); // strip quotes
528
+ } else {
529
+ // Plain value chars: ALPHA / DIGIT / "-" / "_" / "." / "/" / ":"
530
+ while (pos < input.length && input[pos] !== ')') {
531
+ pos++;
532
+ }
533
+ value = input.slice(valueStart, pos);
534
+ }
535
+ if (pos < input.length && input[pos] === ')') {
536
+ pos++; // skip )
537
+ }
538
+ }
539
+
540
+ annotations.push(value !== undefined ? { name, value } : { name });
541
+ }
542
+
543
+ return { annotations, remainder: input.slice(pos).trim() };
544
+ }
545
+
546
+ // =============================================================================
547
+ // Document Parsing
548
+ // =============================================================================
549
+
550
+ /** Version header regex: #!lse <version> */
551
+ const VERSION_HEADER_RE = /^#!lse\s+(\d+\.\d+(?:\.\d+)?)\s*$/;
552
+
553
+ /**
554
+ * Parse a multi-line LSE document into an LSEEnvelope.
555
+ *
556
+ * ABNF: document = [ version-header LF ] *( line LF ) [ line ]
557
+ *
558
+ * Handles version headers (#!lse 1.2), comments (// or #), blank lines,
559
+ * and statement lines (one compound statement per line).
560
+ *
561
+ * @example
562
+ * ```typescript
563
+ * const envelope = parseDocument(`
564
+ * #!lse 1.2
565
+ * // Toggle active state
566
+ * [toggle patient:.active]
567
+ * [add patient:.highlight]
568
+ * `);
569
+ * // envelope.lseVersion === '1.2'
570
+ * // envelope.nodes.length === 2
571
+ * ```
572
+ */
573
+ export function parseDocument(input: string, options?: ParseExplicitOptions): LSEEnvelope {
574
+ const lines = input.split('\n');
575
+ let lseVersion = '1.0';
576
+ const nodes: SemanticNode[] = [];
577
+ let startLine = 0;
578
+
579
+ // Check for version header on first non-blank line
580
+ for (let i = 0; i < lines.length; i++) {
581
+ const trimmed = lines[i].trim();
582
+ if (!trimmed) continue; // skip leading blank lines
583
+ const versionMatch = trimmed.match(VERSION_HEADER_RE);
584
+ if (versionMatch) {
585
+ lseVersion = versionMatch[1];
586
+ startLine = i + 1;
587
+ }
588
+ break;
589
+ }
590
+
591
+ // Parse remaining lines
592
+ for (let i = startLine; i < lines.length; i++) {
593
+ const trimmed = lines[i].trim();
594
+
595
+ // Skip blank lines
596
+ if (!trimmed) continue;
597
+
598
+ // Skip comments (// or #, but NOT #! which is version header)
599
+ if (trimmed.startsWith('//') || (trimmed.startsWith('#') && !trimmed.startsWith('#!'))) {
600
+ continue;
601
+ }
602
+
603
+ // Parse statement line
604
+ nodes.push(parseCompound(trimmed, options));
605
+ }
606
+
607
+ return { lseVersion, nodes };
608
+ }
609
+
610
+ // =============================================================================
611
+ // Detection Helpers
612
+ // =============================================================================
613
+
614
+ /**
615
+ * Check if input contains compound chaining (multiple bracket commands
616
+ * with chain operators at bracket-depth 0).
617
+ */
618
+ export function isCompoundSyntax(input: string): boolean {
619
+ const { segments } = splitCompoundSegments(input.trim());
620
+ return segments.length > 1;
621
+ }
622
+
623
+ /**
624
+ * Check if input is a multi-line LSE document (contains newlines,
625
+ * version header, or comment lines).
626
+ */
627
+ export function isDocumentSyntax(input: string): boolean {
628
+ const trimmed = input.trim();
629
+ if (trimmed.includes('\n')) return true;
630
+ if (VERSION_HEADER_RE.test(trimmed)) return true;
631
+ if (trimmed.startsWith('//') || (trimmed.startsWith('#') && !trimmed.startsWith('#!'))) {
632
+ return true;
633
+ }
634
+ return false;
635
+ }
636
+
263
637
  // =============================================================================
264
638
  // Internal Helpers
265
639
  // =============================================================================
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { renderExplicit } from './explicit-renderer';
3
- import { parseExplicit } from './explicit-parser';
2
+ import { renderExplicit, renderDocument } from './explicit-renderer';
3
+ import { parseExplicit, parseCompound } from './explicit-parser';
4
4
  import {
5
5
  createCommandNode,
6
6
  createSelector,
@@ -10,6 +10,7 @@ import {
10
10
  createEventHandlerNode,
11
11
  createCompoundNode,
12
12
  } from '../core/types';
13
+ import type { SemanticNode, LSEEnvelope } from '../core/types';
13
14
 
14
15
  describe('renderExplicit', () => {
15
16
  it('renders a basic command', () => {
@@ -142,3 +143,87 @@ describe('round-trip: parse → render', () => {
142
143
  });
143
144
  }
144
145
  });
146
+
147
+ describe('renderExplicit — annotations', () => {
148
+ it('renders a node with a single annotation', () => {
149
+ const node: SemanticNode = {
150
+ ...createCommandNode('fetch', {
151
+ source: createLiteral('/api/users', 'string'),
152
+ }),
153
+ annotations: [{ name: 'timeout', value: '5s' }],
154
+ };
155
+ expect(renderExplicit(node)).toBe('@timeout(5s) [fetch source:"/api/users"]');
156
+ });
157
+
158
+ it('renders annotation without value', () => {
159
+ const node: SemanticNode = {
160
+ ...createCommandNode('toggle', {
161
+ patient: createSelector('.active'),
162
+ }),
163
+ annotations: [{ name: 'deprecated' }],
164
+ };
165
+ expect(renderExplicit(node)).toBe('@deprecated [toggle patient:.active]');
166
+ });
167
+
168
+ it('renders multiple annotations preserving order', () => {
169
+ const node: SemanticNode = {
170
+ ...createCommandNode('fetch', {
171
+ source: createLiteral('/api/data', 'string'),
172
+ }),
173
+ annotations: [
174
+ { name: 'retry', value: '3' },
175
+ { name: 'timeout', value: '10s' },
176
+ ],
177
+ };
178
+ const result = renderExplicit(node);
179
+ expect(result).toBe('@retry(3) @timeout(10s) [fetch source:"/api/data"]');
180
+ });
181
+
182
+ it('renders no prefix when annotations are absent', () => {
183
+ const node = createCommandNode('toggle', {
184
+ patient: createSelector('.active'),
185
+ });
186
+ expect(renderExplicit(node)).toBe('[toggle patient:.active]');
187
+ });
188
+
189
+ it('annotation round-trip: render → parse → render', () => {
190
+ const node: SemanticNode = {
191
+ ...createCommandNode('fetch', {
192
+ source: createLiteral('/api/users', 'string'),
193
+ }),
194
+ annotations: [{ name: 'timeout', value: '5s' }],
195
+ };
196
+ const rendered = renderExplicit(node);
197
+ const reparsed = parseCompound(rendered);
198
+ const rerendered = renderExplicit(reparsed);
199
+ expect(rerendered).toBe(rendered);
200
+ });
201
+ });
202
+
203
+ describe('renderDocument', () => {
204
+ it('renders an envelope with version header', () => {
205
+ const envelope: LSEEnvelope = {
206
+ lseVersion: '1.2',
207
+ nodes: [
208
+ createCommandNode('toggle', { patient: createSelector('.active') }),
209
+ createCommandNode('add', { patient: createSelector('.highlight') }),
210
+ ],
211
+ };
212
+ const result = renderDocument(envelope);
213
+ expect(result).toBe('#!lse 1.2\n[toggle patient:.active]\n[add patient:.highlight]');
214
+ });
215
+
216
+ it('omits version header for version 1.0', () => {
217
+ const envelope: LSEEnvelope = {
218
+ lseVersion: '1.0',
219
+ nodes: [createCommandNode('toggle', { patient: createSelector('.active') })],
220
+ };
221
+ const result = renderDocument(envelope);
222
+ expect(result).toBe('[toggle patient:.active]');
223
+ });
224
+
225
+ it('renders empty envelope', () => {
226
+ const envelope: LSEEnvelope = { lseVersion: '1.2', nodes: [] };
227
+ expect(renderDocument(envelope)).toBe('#!lse 1.2');
228
+ });
229
+ });