@sprig-and-prose/sprig-universe 0.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 (45) hide show
  1. package/PHILOSOPHY.md +201 -0
  2. package/README.md +168 -0
  3. package/REFERENCE.md +355 -0
  4. package/biome.json +24 -0
  5. package/package.json +30 -0
  6. package/repositories/sprig-repository-github/index.js +29 -0
  7. package/src/ast.js +257 -0
  8. package/src/cli.js +1510 -0
  9. package/src/graph.js +950 -0
  10. package/src/index.js +46 -0
  11. package/src/ir.js +121 -0
  12. package/src/parser.js +1656 -0
  13. package/src/scanner.js +255 -0
  14. package/src/scene-manifest.js +856 -0
  15. package/src/util/span.js +46 -0
  16. package/src/util/text.js +126 -0
  17. package/src/validator.js +862 -0
  18. package/src/validators/mysql/connection.js +154 -0
  19. package/src/validators/mysql/schema.js +209 -0
  20. package/src/validators/mysql/type-compat.js +219 -0
  21. package/src/validators/mysql/validator.js +332 -0
  22. package/test/fixtures/amaranthine-mini.prose +53 -0
  23. package/test/fixtures/conflicting-universes-a.prose +8 -0
  24. package/test/fixtures/conflicting-universes-b.prose +8 -0
  25. package/test/fixtures/duplicate-names.prose +20 -0
  26. package/test/fixtures/first-line-aware.prose +32 -0
  27. package/test/fixtures/indented-describe.prose +18 -0
  28. package/test/fixtures/multi-file-universe-a.prose +15 -0
  29. package/test/fixtures/multi-file-universe-b.prose +15 -0
  30. package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
  31. package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
  32. package/test/fixtures/multi-file-universe-with-title.prose +10 -0
  33. package/test/fixtures/named-document.prose +17 -0
  34. package/test/fixtures/named-duplicate.prose +22 -0
  35. package/test/fixtures/named-reference.prose +17 -0
  36. package/test/fixtures/relates-errors.prose +38 -0
  37. package/test/fixtures/relates-tier1.prose +14 -0
  38. package/test/fixtures/relates-tier2.prose +16 -0
  39. package/test/fixtures/relates-tier3.prose +21 -0
  40. package/test/fixtures/sprig-meta-mini.prose +62 -0
  41. package/test/fixtures/unresolved-relates.prose +15 -0
  42. package/test/fixtures/using-in-references.prose +35 -0
  43. package/test/fixtures/using-unknown.prose +8 -0
  44. package/test/universe-basic.test.js +804 -0
  45. package/tsconfig.json +15 -0
package/src/parser.js ADDED
@@ -0,0 +1,1656 @@
1
+ /**
2
+ * @fileoverview Recursive descent parser for Sprig universe syntax
3
+ */
4
+
5
+ import { mergeSpans } from './util/span.js';
6
+
7
+ /**
8
+ * @typedef {import('./ast.js').FileAST} FileAST
9
+ * @typedef {import('./ast.js').UniverseDecl} UniverseDecl
10
+ * @typedef {import('./ast.js').AnthologyDecl} AnthologyDecl
11
+ * @typedef {import('./ast.js').SeriesDecl} SeriesDecl
12
+ * @typedef {import('./ast.js').BookDecl} BookDecl
13
+ * @typedef {import('./ast.js').ChapterDecl} ChapterDecl
14
+ * @typedef {import('./ast.js').ConceptDecl} ConceptDecl
15
+ * @typedef {import('./ast.js').RelatesDecl} RelatesDecl
16
+ * @typedef {import('./ast.js').DescribeBlock} DescribeBlock
17
+ * @typedef {import('./ast.js').UnknownBlock} UnknownBlock
18
+ * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
19
+ * @typedef {import('./ast.js').ReferenceBlock} ReferenceBlock
20
+ * @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
21
+ * @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
22
+ * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
23
+ * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
24
+ * @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
25
+ * @typedef {import('./scanner.js').Token} Token
26
+ */
27
+
28
+ /**
29
+ * Parses tokens into an AST
30
+ * @param {Token[]} tokens - Tokens from scanner
31
+ * @param {string} file - File path
32
+ * @param {string} sourceText - Original source text for raw content extraction
33
+ * @returns {FileAST}
34
+ */
35
+ export function parse(tokens, file, sourceText) {
36
+ const parser = new Parser(tokens, file, sourceText);
37
+ return parser.parseFile();
38
+ }
39
+
40
+ class Parser {
41
+ /**
42
+ * @param {Token[]} tokens
43
+ * @param {string} file
44
+ * @param {string} sourceText
45
+ */
46
+ constructor(tokens, file, sourceText) {
47
+ this.tokens = tokens;
48
+ this.file = file;
49
+ this.sourceText = sourceText;
50
+ this.pos = 0;
51
+ }
52
+
53
+ /**
54
+ * @returns {Token | null}
55
+ */
56
+ peek() {
57
+ if (this.pos >= this.tokens.length) {
58
+ return null;
59
+ }
60
+ return this.tokens[this.pos];
61
+ }
62
+
63
+ /**
64
+ * @returns {Token | null}
65
+ */
66
+ advance() {
67
+ if (this.pos >= this.tokens.length) {
68
+ return null;
69
+ }
70
+ return this.tokens[this.pos++];
71
+ }
72
+
73
+ /**
74
+ * @param {string} type
75
+ * @returns {boolean}
76
+ */
77
+ match(type) {
78
+ const token = this.peek();
79
+ return token !== null && token.type === type;
80
+ }
81
+
82
+ /**
83
+ * @param {string} type
84
+ * @param {string} [value]
85
+ * @returns {Token}
86
+ * @throws {Error}
87
+ */
88
+ expect(type, value) {
89
+ const token = this.advance();
90
+ if (!token || token.type !== type) {
91
+ throw new Error(
92
+ `Expected ${type}, got ${token ? token.type : 'EOF'} at ${this.file}:${token ? token.span.start.line : '?'}`,
93
+ );
94
+ }
95
+ if (value !== undefined && token.value !== value) {
96
+ throw new Error(
97
+ `Expected ${type} with value "${value}", got "${token.value}" at ${this.file}:${token.span.start.line}`,
98
+ );
99
+ }
100
+ return token;
101
+ }
102
+
103
+ /**
104
+ * @returns {FileAST}
105
+ */
106
+ parseFile() {
107
+ const universes = [];
108
+ const scenes = [];
109
+ const startToken = this.peek();
110
+
111
+ while (!this.match('EOF')) {
112
+ if (this.match('KEYWORD') && this.peek()?.value === 'universe') {
113
+ universes.push(this.parseUniverse());
114
+ } else if (this.match('KEYWORD') && this.peek()?.value === 'scene') {
115
+ scenes.push(this.parseScene());
116
+ } else {
117
+ // Skip unknown top-level content (tolerant parsing)
118
+ const token = this.advance();
119
+ if (token && token.type !== 'EOF') {
120
+ // Could emit warning here, but for now just skip
121
+ }
122
+ }
123
+ }
124
+
125
+ return {
126
+ file: this.file,
127
+ universes,
128
+ scenes,
129
+ source: startToken
130
+ ? {
131
+ file: this.file,
132
+ start: startToken.span.start,
133
+ end: this.tokens[this.tokens.length - 1]?.span.end || startToken.span.end,
134
+ }
135
+ : undefined,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * @returns {UniverseDecl}
141
+ */
142
+ parseUniverse() {
143
+ const startToken = this.expect('KEYWORD', 'universe');
144
+ const nameToken = this.expect('IDENTIFIER');
145
+ const lbrace = this.expect('LBRACE');
146
+ const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository']);
147
+ const rbrace = this.expect('RBRACE');
148
+
149
+ return {
150
+ kind: 'universe',
151
+ name: nameToken.value,
152
+ body,
153
+ source: {
154
+ file: this.file,
155
+ start: startToken.span.start,
156
+ end: rbrace.span.end,
157
+ },
158
+ };
159
+ }
160
+
161
+ /**
162
+ * @param {string[]} allowedKeywords - Keywords allowed in this body
163
+ * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>}
164
+ */
165
+ parseBlockBody(allowedKeywords) {
166
+ const body = [];
167
+
168
+ while (!this.match('RBRACE') && !this.match('EOF')) {
169
+ // Check for keywords or identifiers that might start a block
170
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
171
+ const keyword = this.peek()?.value;
172
+ if (!keyword) break;
173
+
174
+ // Check for named reference: reference <IDENTIFIER> { ... }
175
+ if (keyword === 'reference') {
176
+ const nextPos = this.pos + 1;
177
+ if (nextPos < this.tokens.length &&
178
+ this.tokens[nextPos].type === 'IDENTIFIER' &&
179
+ nextPos + 1 < this.tokens.length &&
180
+ this.tokens[nextPos + 1].type === 'LBRACE') {
181
+ // This is a named reference block
182
+ body.push(this.parseNamedReference());
183
+ continue;
184
+ }
185
+ // Otherwise, it's an inline reference (only valid inside references block)
186
+ // Fall through to unknown block handling
187
+ }
188
+
189
+ // Check for named document: document <IDENTIFIER> { ... }
190
+ if (keyword === 'document') {
191
+ const nextPos = this.pos + 1;
192
+ if (nextPos < this.tokens.length &&
193
+ this.tokens[nextPos].type === 'IDENTIFIER' &&
194
+ nextPos + 1 < this.tokens.length &&
195
+ this.tokens[nextPos + 1].type === 'LBRACE') {
196
+ // This is a named document block
197
+ body.push(this.parseNamedDocument());
198
+ continue;
199
+ }
200
+ // Otherwise, it's an inline document (only valid inside documentation block)
201
+ // Fall through to unknown block handling
202
+ }
203
+
204
+ if (keyword === 'anthology' && allowedKeywords.includes('anthology')) {
205
+ body.push(this.parseAnthology());
206
+ } else if (keyword === 'series' && allowedKeywords.includes('series')) {
207
+ body.push(this.parseSeries());
208
+ } else if (keyword === 'book' && allowedKeywords.includes('book')) {
209
+ body.push(this.parseBook());
210
+ } else if (keyword === 'chapter' && allowedKeywords.includes('chapter')) {
211
+ body.push(this.parseChapter());
212
+ } else if (keyword === 'concept' && allowedKeywords.includes('concept')) {
213
+ body.push(this.parseConcept());
214
+ } else if (keyword === 'relates' && allowedKeywords.includes('relates')) {
215
+ body.push(this.parseRelates());
216
+ } else if (keyword === 'describe' && allowedKeywords.includes('describe')) {
217
+ body.push(this.parseDescribe());
218
+ } else if (keyword === 'title' && allowedKeywords.includes('title')) {
219
+ body.push(this.parseTitle());
220
+ } else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
221
+ body.push(this.parseRepository());
222
+ } else if (keyword === 'from' && allowedKeywords.includes('from')) {
223
+ body.push(this.parseFrom());
224
+ } else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
225
+ body.push(this.parseRelationships());
226
+ } else if (keyword === 'references') {
227
+ body.push(this.parseReferences());
228
+ } else if (keyword === 'documentation') {
229
+ body.push(this.parseDocumentation());
230
+ } else {
231
+ // Unknown keyword/identifier followed by brace - parse as UnknownBlock
232
+ // Check if next token is LBRACE
233
+ const nextPos = this.pos + 1;
234
+ if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
235
+ body.push(this.parseUnknownBlock());
236
+ } else {
237
+ // Not a block, just skip this token (tolerant parsing)
238
+ this.advance();
239
+ }
240
+ }
241
+ } else {
242
+ // Unexpected token in body - skip (tolerant)
243
+ this.advance();
244
+ }
245
+ }
246
+
247
+ return body;
248
+ }
249
+
250
+ /**
251
+ * @returns {AnthologyDecl}
252
+ */
253
+ parseAnthology() {
254
+ const startToken = this.expect('KEYWORD', 'anthology');
255
+ const nameToken = this.expect('IDENTIFIER');
256
+ const lbrace = this.expect('LBRACE');
257
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
258
+ const rbrace = this.expect('RBRACE');
259
+
260
+ return {
261
+ kind: 'anthology',
262
+ name: nameToken.value,
263
+ body,
264
+ source: {
265
+ file: this.file,
266
+ start: startToken.span.start,
267
+ end: rbrace.span.end,
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * @returns {SeriesDecl}
274
+ */
275
+ parseSeries() {
276
+ const startToken = this.expect('KEYWORD', 'series');
277
+ const nameToken = this.expect('IDENTIFIER');
278
+
279
+ // Optional "in <AnthologyName>" syntax
280
+ let parentName = undefined;
281
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
282
+ this.expect('KEYWORD', 'in');
283
+ const parentToken = this.expect('IDENTIFIER');
284
+ parentName = parentToken.value;
285
+ }
286
+
287
+ const lbrace = this.expect('LBRACE');
288
+ const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
289
+ const rbrace = this.expect('RBRACE');
290
+
291
+ return {
292
+ kind: 'series',
293
+ name: nameToken.value,
294
+ parentName,
295
+ body,
296
+ source: {
297
+ file: this.file,
298
+ start: startToken.span.start,
299
+ end: rbrace.span.end,
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * @returns {BookDecl}
306
+ */
307
+ parseBook() {
308
+ const startToken = this.expect('KEYWORD', 'book');
309
+ const nameToken = this.expect('IDENTIFIER');
310
+ this.expect('KEYWORD', 'in');
311
+ const parentToken = this.expect('IDENTIFIER');
312
+ const lbrace = this.expect('LBRACE');
313
+ const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
314
+ const rbrace = this.expect('RBRACE');
315
+
316
+ return {
317
+ kind: 'book',
318
+ name: nameToken.value,
319
+ parentName: parentToken.value,
320
+ body,
321
+ source: {
322
+ file: this.file,
323
+ start: startToken.span.start,
324
+ end: rbrace.span.end,
325
+ },
326
+ };
327
+ }
328
+
329
+ /**
330
+ * @returns {ChapterDecl}
331
+ */
332
+ parseChapter() {
333
+ const startToken = this.expect('KEYWORD', 'chapter');
334
+ const nameToken = this.expect('IDENTIFIER');
335
+ this.expect('KEYWORD', 'in');
336
+ const parentToken = this.expect('IDENTIFIER');
337
+ const lbrace = this.expect('LBRACE');
338
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
339
+ const rbrace = this.expect('RBRACE');
340
+
341
+ return {
342
+ kind: 'chapter',
343
+ name: nameToken.value,
344
+ parentName: parentToken.value,
345
+ body,
346
+ source: {
347
+ file: this.file,
348
+ start: startToken.span.start,
349
+ end: rbrace.span.end,
350
+ },
351
+ };
352
+ }
353
+
354
+ /**
355
+ * @returns {ConceptDecl}
356
+ */
357
+ parseConcept() {
358
+ const startToken = this.expect('KEYWORD', 'concept');
359
+ const nameToken = this.expect('IDENTIFIER');
360
+
361
+ // Optional "in <ParentName>" syntax
362
+ let parentName = undefined;
363
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
364
+ this.expect('KEYWORD', 'in');
365
+ const parentToken = this.expect('IDENTIFIER');
366
+ parentName = parentToken.value;
367
+ }
368
+
369
+ const lbrace = this.expect('LBRACE');
370
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
371
+ const rbrace = this.expect('RBRACE');
372
+
373
+ return {
374
+ kind: 'concept',
375
+ name: nameToken.value,
376
+ parentName,
377
+ body,
378
+ source: {
379
+ file: this.file,
380
+ start: startToken.span.start,
381
+ end: rbrace.span.end,
382
+ },
383
+ };
384
+ }
385
+
386
+ /**
387
+ * @returns {RelatesDecl}
388
+ */
389
+ parseRelates() {
390
+ const startToken = this.expect('KEYWORD', 'relates');
391
+ const aToken = this.expect('IDENTIFIER');
392
+ this.expect('KEYWORD', 'and');
393
+ const bToken = this.expect('IDENTIFIER');
394
+ const lbrace = this.expect('LBRACE');
395
+ const body = this.parseBlockBody(['describe', 'title', 'from']);
396
+ const rbrace = this.expect('RBRACE');
397
+
398
+ return {
399
+ kind: 'relates',
400
+ a: aToken.value,
401
+ b: bToken.value,
402
+ body,
403
+ source: {
404
+ file: this.file,
405
+ start: startToken.span.start,
406
+ end: rbrace.span.end,
407
+ },
408
+ };
409
+ }
410
+
411
+ /**
412
+ * @returns {FromBlock}
413
+ */
414
+ parseFrom() {
415
+ const startToken = this.expect('KEYWORD', 'from');
416
+ const endpointToken = this.expect('IDENTIFIER');
417
+ const lbrace = this.expect('LBRACE');
418
+ const body = this.parseBlockBody(['relationships', 'describe', 'title']);
419
+ const rbrace = this.expect('RBRACE');
420
+
421
+ return {
422
+ kind: 'from',
423
+ endpoint: endpointToken.value,
424
+ body,
425
+ source: {
426
+ file: this.file,
427
+ start: startToken.span.start,
428
+ end: rbrace.span.end,
429
+ },
430
+ };
431
+ }
432
+
433
+ /**
434
+ * @returns {RelationshipsBlock}
435
+ */
436
+ parseRelationships() {
437
+ const startToken = this.expect('KEYWORD', 'relationships');
438
+ const lbrace = this.expect('LBRACE');
439
+ const values = [];
440
+ let relationshipsSource = {
441
+ file: this.file,
442
+ start: lbrace.span.end,
443
+ end: lbrace.span.end,
444
+ };
445
+
446
+ // Parse string literals (commas are whitespace, so scanner skips them)
447
+ // We parse consecutive STRING tokens until we hit RBRACE
448
+ while (!this.match('RBRACE') && !this.match('EOF')) {
449
+ if (this.match('STRING')) {
450
+ const stringToken = this.advance();
451
+ if (stringToken) {
452
+ values.push(stringToken.value);
453
+ relationshipsSource.end = stringToken.span.end;
454
+ }
455
+ } else {
456
+ // Skip non-string tokens (whitespace/comments already skipped by scanner)
457
+ // This handles commas and other unexpected tokens gracefully
458
+ this.advance();
459
+ }
460
+ }
461
+
462
+ const rbrace = this.expect('RBRACE');
463
+ relationshipsSource.end = rbrace.span.start;
464
+
465
+ return {
466
+ kind: 'relationships',
467
+ values,
468
+ source: relationshipsSource,
469
+ };
470
+ }
471
+
472
+ /**
473
+ * Parses a describe block, consuming raw text until matching closing brace
474
+ * Treats braces inside as plain text (doesn't parse nested structures)
475
+ * @returns {DescribeBlock}
476
+ */
477
+ parseDescribe() {
478
+ const startToken = this.expect('KEYWORD', 'describe');
479
+ const lbrace = this.expect('LBRACE');
480
+
481
+ // Find the matching closing brace by tracking depth
482
+ // We start at depth 1 (the opening brace we just consumed)
483
+ let depth = 1;
484
+ const startOffset = lbrace.span.end.offset;
485
+ let endOffset = startOffset;
486
+ let endToken = null;
487
+
488
+ // Consume tokens until we find the matching closing brace
489
+ while (depth > 0 && this.pos < this.tokens.length) {
490
+ const token = this.tokens[this.pos];
491
+ if (token.type === 'EOF') break;
492
+
493
+ if (token.type === 'LBRACE') {
494
+ depth++;
495
+ this.pos++;
496
+ } else if (token.type === 'RBRACE') {
497
+ depth--;
498
+ if (depth === 0) {
499
+ // This is our closing brace
500
+ endToken = token;
501
+ endOffset = token.span.start.offset;
502
+ this.pos++;
503
+ break;
504
+ } else {
505
+ this.pos++;
506
+ }
507
+ } else {
508
+ this.pos++;
509
+ }
510
+ }
511
+
512
+ if (depth > 0) {
513
+ throw new Error(`Unclosed describe block at ${this.file}:${startToken.span.start.line}`);
514
+ }
515
+
516
+ // Extract raw content from source text
517
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
518
+
519
+ return {
520
+ kind: 'describe',
521
+ raw: rawContent,
522
+ source: {
523
+ file: this.file,
524
+ start: lbrace.span.end,
525
+ end: endToken ? endToken.span.start : lbrace.span.end,
526
+ },
527
+ };
528
+ }
529
+
530
+ /**
531
+ * Parses a title block containing a string literal
532
+ * @returns {TitleBlock}
533
+ */
534
+ parseTitle() {
535
+ const startToken = this.expect('IDENTIFIER', 'title');
536
+ const lbrace = this.expect('LBRACE');
537
+
538
+ // Find the matching closing brace by tracking depth
539
+ // We start at depth 1 (the opening brace we just consumed)
540
+ let depth = 1;
541
+ const startOffset = lbrace.span.end.offset;
542
+ let endOffset = startOffset;
543
+ let endToken = null;
544
+
545
+ // Consume tokens until we find the matching closing brace
546
+ while (depth > 0 && this.pos < this.tokens.length) {
547
+ const token = this.tokens[this.pos];
548
+ if (token.type === 'EOF') break;
549
+
550
+ if (token.type === 'LBRACE') {
551
+ depth++;
552
+ this.pos++;
553
+ } else if (token.type === 'RBRACE') {
554
+ depth--;
555
+ if (depth === 0) {
556
+ // This is our closing brace
557
+ endToken = token;
558
+ endOffset = token.span.start.offset;
559
+ this.pos++;
560
+ break;
561
+ } else {
562
+ this.pos++;
563
+ }
564
+ } else {
565
+ this.pos++;
566
+ }
567
+ }
568
+
569
+ if (depth > 0) {
570
+ throw new Error(`Unclosed title block at ${this.file}:${startToken.span.start.line}`);
571
+ }
572
+
573
+ // Extract raw content from source text
574
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
575
+
576
+ return {
577
+ kind: 'title',
578
+ raw: rawContent,
579
+ source: {
580
+ file: this.file,
581
+ start: lbrace.span.end,
582
+ end: endToken ? endToken.span.start : lbrace.span.end,
583
+ },
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Parses a references block containing nested reference blocks and using blocks
589
+ * @returns {ReferencesBlock}
590
+ */
591
+ parseReferences() {
592
+ const startToken = this.expect('IDENTIFIER', 'references');
593
+ const lbrace = this.expect('LBRACE');
594
+ const references = [];
595
+
596
+ // Parse nested reference blocks and using blocks
597
+ while (!this.match('RBRACE') && !this.match('EOF')) {
598
+ // Check for 'using' keyword first (consistent with Scene layer)
599
+ if (this.match('KEYWORD') && this.peek()?.value === 'using') {
600
+ references.push(this.parseUsingInReferences());
601
+ } else if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'reference') {
602
+ // Look for 'reference' identifier
603
+ references.push(this.parseReference());
604
+ } else {
605
+ // Skip unexpected tokens (tolerant parsing)
606
+ this.advance();
607
+ }
608
+ }
609
+
610
+ const rbrace = this.expect('RBRACE');
611
+
612
+ return {
613
+ kind: 'references',
614
+ references,
615
+ source: {
616
+ file: this.file,
617
+ start: startToken.span.start,
618
+ end: rbrace.span.end,
619
+ },
620
+ };
621
+ }
622
+
623
+ /**
624
+ * Parses a using block inside a references block: using { IdentifierList }
625
+ * @returns {UsingInReferencesBlock}
626
+ */
627
+ parseUsingInReferences() {
628
+ const startToken = this.expect('KEYWORD', 'using');
629
+ const lbrace = this.expect('LBRACE');
630
+ const names = [];
631
+
632
+ while (!this.match('RBRACE') && !this.match('EOF')) {
633
+ if (this.match('IDENTIFIER')) {
634
+ names.push(this.expect('IDENTIFIER').value);
635
+ // Skip optional comma
636
+ if (this.match('COMMA')) {
637
+ this.expect('COMMA');
638
+ }
639
+ } else if (this.match('COMMA')) {
640
+ // Skip stray commas
641
+ this.expect('COMMA');
642
+ } else {
643
+ // Skip non-identifiers (tolerant parsing)
644
+ this.advance();
645
+ }
646
+ }
647
+
648
+ const rbrace = this.expect('RBRACE');
649
+
650
+ return {
651
+ kind: 'using-in-references',
652
+ names,
653
+ source: {
654
+ file: this.file,
655
+ start: startToken.span.start,
656
+ end: rbrace.span.end,
657
+ },
658
+ };
659
+ }
660
+
661
+ /**
662
+ * Parses a single reference block
663
+ * @returns {ReferenceBlock}
664
+ */
665
+ parseReference() {
666
+ const startToken = this.expect('IDENTIFIER', 'reference');
667
+ const lbrace = this.expect('LBRACE');
668
+ let repository = null;
669
+ let paths = [];
670
+ let describe = null;
671
+ let kind = null;
672
+
673
+ // Parse repository, paths, optional kind, and optional describe
674
+ while (!this.match('RBRACE') && !this.match('EOF')) {
675
+ if (this.match('RBRACE')) break;
676
+
677
+ // Check for identifier or keyword (describe is a keyword)
678
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
679
+ const identifier = this.peek()?.value;
680
+ if (identifier === 'repository') {
681
+ repository = this.parseStringBlock('repository');
682
+ } else if (identifier === 'paths') {
683
+ paths = this.parsePathsBlock();
684
+ } else if (identifier === 'kind') {
685
+ kind = this.parseStringBlock('kind');
686
+ } else if (identifier === 'describe') {
687
+ describe = this.parseDescribe();
688
+ } else {
689
+ // Skip unknown identifier/keyword
690
+ this.advance();
691
+ }
692
+ } else {
693
+ // Skip unexpected tokens
694
+ this.advance();
695
+ }
696
+ }
697
+
698
+ const rbrace = this.expect('RBRACE');
699
+
700
+ if (!repository) {
701
+ throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
702
+ }
703
+ if (paths.length === 0) {
704
+ throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
705
+ }
706
+
707
+ return {
708
+ kind: 'reference',
709
+ repository,
710
+ paths,
711
+ referenceKind: kind,
712
+ describe,
713
+ source: {
714
+ file: this.file,
715
+ start: startToken.span.start,
716
+ end: rbrace.span.end,
717
+ },
718
+ };
719
+ }
720
+
721
+ /**
722
+ * Parses a named reference block: reference <Name> { ... }
723
+ * @returns {NamedReferenceBlock}
724
+ */
725
+ parseNamedReference() {
726
+ const startToken = this.expect('IDENTIFIER', 'reference');
727
+ const nameToken = this.expect('IDENTIFIER');
728
+ const lbrace = this.expect('LBRACE');
729
+ let repository = null;
730
+ let paths = [];
731
+ let describe = null;
732
+ let kind = null;
733
+
734
+ // Parse repository, paths, optional kind, and optional describe (same as parseReference)
735
+ while (!this.match('RBRACE') && !this.match('EOF')) {
736
+ if (this.match('RBRACE')) break;
737
+
738
+ // Check for identifier or keyword (describe is a keyword)
739
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
740
+ const identifier = this.peek()?.value;
741
+ if (identifier === 'repository') {
742
+ repository = this.parseStringBlock('repository');
743
+ } else if (identifier === 'paths') {
744
+ paths = this.parsePathsBlock();
745
+ } else if (identifier === 'kind') {
746
+ kind = this.parseStringBlock('kind');
747
+ } else if (identifier === 'describe') {
748
+ describe = this.parseDescribe();
749
+ } else {
750
+ // Skip unknown identifier/keyword
751
+ this.advance();
752
+ }
753
+ } else {
754
+ // Skip unexpected tokens
755
+ this.advance();
756
+ }
757
+ }
758
+
759
+ const rbrace = this.expect('RBRACE');
760
+
761
+ if (!repository) {
762
+ throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
763
+ }
764
+ if (paths.length === 0) {
765
+ throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
766
+ }
767
+
768
+ return {
769
+ kind: 'named-reference',
770
+ name: nameToken.value,
771
+ repository,
772
+ paths,
773
+ referenceKind: kind,
774
+ describe,
775
+ source: {
776
+ file: this.file,
777
+ start: startToken.span.start,
778
+ end: rbrace.span.end,
779
+ },
780
+ };
781
+ }
782
+
783
+ /**
784
+ * Parses a string block (e.g., repository { 'string' } or repository { Identifier })
785
+ * @param {string} fieldName - Field name for error messages
786
+ * @returns {string}
787
+ */
788
+ parseStringBlock(fieldName) {
789
+ // Accept either IDENTIFIER or KEYWORD (since 'repository' is now a keyword)
790
+ if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
791
+ throw new Error(`Expected IDENTIFIER or KEYWORD '${fieldName}' at ${this.file}:${this.peek()?.span.start.line}`);
792
+ }
793
+ const fieldToken = this.advance();
794
+ if (fieldToken.value !== fieldName) {
795
+ throw new Error(`Expected '${fieldName}', got '${fieldToken.value}' at ${this.file}:${fieldToken.span.start.line}`);
796
+ }
797
+ const lbrace = this.expect('LBRACE');
798
+
799
+ // Find string literal or identifier
800
+ let stringValue = null;
801
+ while (!this.match('RBRACE') && !this.match('EOF')) {
802
+ if (this.match('STRING')) {
803
+ const stringToken = this.advance();
804
+ if (stringValue !== null) {
805
+ throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
806
+ }
807
+ stringValue = stringToken.value;
808
+ } else if (this.match('IDENTIFIER')) {
809
+ const identifierToken = this.advance();
810
+ if (stringValue !== null) {
811
+ throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
812
+ }
813
+ stringValue = identifierToken.value;
814
+ } else {
815
+ // Skip whitespace and other tokens
816
+ this.advance();
817
+ }
818
+ }
819
+
820
+ const rbrace = this.expect('RBRACE');
821
+
822
+ if (stringValue === null) {
823
+ throw new Error(`Expected string literal or identifier in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
824
+ }
825
+
826
+ return stringValue;
827
+ }
828
+
829
+ /**
830
+ * Parses a value block that can contain a string, identifier, or number
831
+ * @param {string} fieldName - Field name for error messages
832
+ * @returns {string | number}
833
+ */
834
+ parseValueBlock(fieldName) {
835
+ this.expect('IDENTIFIER', fieldName);
836
+ const lbrace = this.expect('LBRACE');
837
+
838
+ let value = null;
839
+ while (!this.match('RBRACE') && !this.match('EOF')) {
840
+ if (this.match('STRING')) {
841
+ const stringToken = this.advance();
842
+ if (value !== null) {
843
+ throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
844
+ }
845
+ value = stringToken.value;
846
+ } else if (this.match('IDENTIFIER')) {
847
+ const identifierToken = this.advance();
848
+ if (value !== null) {
849
+ throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
850
+ }
851
+ value = identifierToken.value;
852
+ } else if (this.match('NUMBER')) {
853
+ const numberToken = this.advance();
854
+ if (value !== null) {
855
+ throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
856
+ }
857
+ value = numberToken.value;
858
+ } else {
859
+ // Skip whitespace and other tokens
860
+ this.advance();
861
+ }
862
+ }
863
+
864
+ const rbrace = this.expect('RBRACE');
865
+
866
+ if (value === null) {
867
+ throw new Error(`Expected value (string, identifier, or number) in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
868
+ }
869
+
870
+ return value;
871
+ }
872
+
873
+ /**
874
+ * Parses an options block containing key-value pairs
875
+ * @returns {Record<string, string | number>}
876
+ */
877
+ parseOptionsBlock() {
878
+ this.expect('IDENTIFIER', 'options');
879
+ const lbrace = this.expect('LBRACE');
880
+ const options = {};
881
+
882
+ while (!this.match('RBRACE') && !this.match('EOF')) {
883
+ if (this.match('IDENTIFIER')) {
884
+ const keyToken = this.advance();
885
+ const key = keyToken.value;
886
+
887
+ // Expect LBRACE for the value block
888
+ const valueLbrace = this.expect('LBRACE');
889
+ let value = null;
890
+
891
+ // Parse value (string, identifier, or number)
892
+ while (!this.match('RBRACE') && !this.match('EOF')) {
893
+ if (this.match('STRING')) {
894
+ const stringToken = this.advance();
895
+ if (value !== null) {
896
+ throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
897
+ }
898
+ value = stringToken.value;
899
+ } else if (this.match('IDENTIFIER')) {
900
+ const identifierToken = this.advance();
901
+ if (value !== null) {
902
+ throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
903
+ }
904
+ value = identifierToken.value;
905
+ } else if (this.match('NUMBER')) {
906
+ const numberToken = this.advance();
907
+ if (value !== null) {
908
+ throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
909
+ }
910
+ value = numberToken.value;
911
+ } else {
912
+ // Skip whitespace and other tokens
913
+ this.advance();
914
+ }
915
+ }
916
+
917
+ const valueRbrace = this.expect('RBRACE');
918
+
919
+ if (value === null) {
920
+ throw new Error(`Expected value (string, identifier, or number) in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
921
+ }
922
+
923
+ options[key] = value;
924
+ } else {
925
+ // Skip unexpected tokens
926
+ this.advance();
927
+ }
928
+ }
929
+
930
+ const rbrace = this.expect('RBRACE');
931
+ return options;
932
+ }
933
+
934
+ /**
935
+ * Parses a repository block: repository <Identifier> { kind { ... } options { ... } }
936
+ * @returns {RepositoryDecl}
937
+ */
938
+ parseRepository() {
939
+ const startToken = this.expect('KEYWORD', 'repository');
940
+ const nameToken = this.expect('IDENTIFIER');
941
+ const lbrace = this.expect('LBRACE');
942
+
943
+ let kind = null;
944
+ let options = null;
945
+
946
+ while (!this.match('RBRACE') && !this.match('EOF')) {
947
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
948
+ const identifier = this.peek()?.value;
949
+ if (identifier === 'kind') {
950
+ kind = this.parseValueBlock('kind');
951
+ } else if (identifier === 'options') {
952
+ options = this.parseOptionsBlock();
953
+ } else {
954
+ // Skip unknown identifier/keyword
955
+ this.advance();
956
+ }
957
+ } else {
958
+ // Skip unexpected tokens
959
+ this.advance();
960
+ }
961
+ }
962
+
963
+ const rbrace = this.expect('RBRACE');
964
+
965
+ if (!kind) {
966
+ throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
967
+ }
968
+
969
+ return {
970
+ kind: 'repository',
971
+ name: nameToken.value,
972
+ repositoryKind: kind,
973
+ options: options || {},
974
+ source: {
975
+ file: this.file,
976
+ start: startToken.span.start,
977
+ end: rbrace.span.end,
978
+ },
979
+ };
980
+ }
981
+
982
+ /**
983
+ * Parses a paths block (e.g., paths { 'path1', 'path2' })
984
+ * @returns {string[]}
985
+ */
986
+ parsePathsBlock() {
987
+ this.expect('IDENTIFIER', 'paths');
988
+ const lbrace = this.expect('LBRACE');
989
+ const paths = [];
990
+
991
+ // Parse string literals (commas are whitespace, so scanner skips them)
992
+ while (!this.match('RBRACE') && !this.match('EOF')) {
993
+ if (this.match('STRING')) {
994
+ const stringToken = this.advance();
995
+ paths.push(stringToken.value);
996
+ } else {
997
+ // Skip non-string tokens (whitespace/comments already skipped by scanner)
998
+ this.advance();
999
+ }
1000
+ }
1001
+
1002
+ const rbrace = this.expect('RBRACE');
1003
+
1004
+ return paths;
1005
+ }
1006
+
1007
+ /**
1008
+ * Parses a documentation block containing nested document blocks
1009
+ * @returns {DocumentationBlock}
1010
+ */
1011
+ parseDocumentation() {
1012
+ const startToken = this.expect('IDENTIFIER', 'documentation');
1013
+ const lbrace = this.expect('LBRACE');
1014
+ const documents = [];
1015
+
1016
+ // Parse nested document blocks
1017
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1018
+ // Look for 'document' identifier
1019
+ if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'document') {
1020
+ documents.push(this.parseDocument());
1021
+ } else {
1022
+ // Skip unexpected tokens (tolerant parsing)
1023
+ this.advance();
1024
+ }
1025
+ }
1026
+
1027
+ const rbrace = this.expect('RBRACE');
1028
+
1029
+ return {
1030
+ kind: 'documentation',
1031
+ documents,
1032
+ source: {
1033
+ file: this.file,
1034
+ start: startToken.span.start,
1035
+ end: rbrace.span.end,
1036
+ },
1037
+ };
1038
+ }
1039
+
1040
+ /**
1041
+ * Parses a single document block
1042
+ * Supports both forms: `document Title { ... }` and `document { ... }`
1043
+ * @returns {DocumentBlock}
1044
+ */
1045
+ parseDocument() {
1046
+ const startToken = this.expect('IDENTIFIER', 'document');
1047
+ let title = null;
1048
+
1049
+ // Check if there's an optional title identifier before the brace
1050
+ if (this.match('IDENTIFIER')) {
1051
+ // Peek ahead to see if next token is LBRACE (meaning this is a title)
1052
+ const nextPos = this.pos + 1;
1053
+ if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
1054
+ const titleToken = this.expect('IDENTIFIER');
1055
+ title = titleToken.value;
1056
+ }
1057
+ }
1058
+
1059
+ const lbrace = this.expect('LBRACE');
1060
+ let kind = null;
1061
+ let path = null;
1062
+ let describe = null;
1063
+
1064
+ // Parse kind, path (both required), and optional describe
1065
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1066
+ if (this.match('RBRACE')) break;
1067
+
1068
+ // Check for identifier or keyword (describe is a keyword)
1069
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1070
+ const identifier = this.peek()?.value;
1071
+ if (identifier === 'kind') {
1072
+ kind = this.parseStringBlock('kind');
1073
+ } else if (identifier === 'path') {
1074
+ path = this.parseStringBlock('path');
1075
+ } else if (identifier === 'describe') {
1076
+ describe = this.parseDescribe();
1077
+ } else {
1078
+ // Skip unknown identifier/keyword
1079
+ this.advance();
1080
+ }
1081
+ } else {
1082
+ // Skip unexpected tokens
1083
+ this.advance();
1084
+ }
1085
+ }
1086
+
1087
+ const rbrace = this.expect('RBRACE');
1088
+
1089
+ if (!kind) {
1090
+ throw new Error(`Missing 'kind' field in document block at ${this.file}:${startToken.span.start.line}`);
1091
+ }
1092
+ if (!path) {
1093
+ throw new Error(`Missing 'path' field in document block at ${this.file}:${startToken.span.start.line}`);
1094
+ }
1095
+
1096
+ return {
1097
+ kind: 'document',
1098
+ title: title || undefined,
1099
+ documentKind: kind,
1100
+ path,
1101
+ describe,
1102
+ source: {
1103
+ file: this.file,
1104
+ start: startToken.span.start,
1105
+ end: rbrace.span.end,
1106
+ },
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * Parses a named document block: document <Name> { ... }
1112
+ * @returns {NamedDocumentBlock}
1113
+ */
1114
+ parseNamedDocument() {
1115
+ const startToken = this.expect('IDENTIFIER', 'document');
1116
+ const nameToken = this.expect('IDENTIFIER');
1117
+ const lbrace = this.expect('LBRACE');
1118
+ let kind = null;
1119
+ let path = null;
1120
+ let describe = null;
1121
+
1122
+ // Parse kind, path (both required), and optional describe (same as parseDocument, but no title)
1123
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1124
+ if (this.match('RBRACE')) break;
1125
+
1126
+ // Check for identifier or keyword (describe is a keyword)
1127
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1128
+ const identifier = this.peek()?.value;
1129
+ if (identifier === 'kind') {
1130
+ kind = this.parseStringBlock('kind');
1131
+ } else if (identifier === 'path') {
1132
+ path = this.parseStringBlock('path');
1133
+ } else if (identifier === 'describe') {
1134
+ describe = this.parseDescribe();
1135
+ } else {
1136
+ // Skip unknown identifier/keyword
1137
+ this.advance();
1138
+ }
1139
+ } else {
1140
+ // Skip unexpected tokens
1141
+ this.advance();
1142
+ }
1143
+ }
1144
+
1145
+ const rbrace = this.expect('RBRACE');
1146
+
1147
+ if (!kind) {
1148
+ throw new Error(`Missing 'kind' field in named document block at ${this.file}:${startToken.span.start.line}`);
1149
+ }
1150
+ if (!path) {
1151
+ throw new Error(`Missing 'path' field in named document block at ${this.file}:${startToken.span.start.line}`);
1152
+ }
1153
+
1154
+ return {
1155
+ kind: 'named-document',
1156
+ name: nameToken.value,
1157
+ documentKind: kind,
1158
+ path,
1159
+ describe,
1160
+ source: {
1161
+ file: this.file,
1162
+ start: startToken.span.start,
1163
+ end: rbrace.span.end,
1164
+ },
1165
+ };
1166
+ }
1167
+
1168
+ /**
1169
+ * @returns {SceneDecl}
1170
+ */
1171
+ parseScene() {
1172
+ const startToken = this.expect('KEYWORD', 'scene');
1173
+ const nameToken = this.expect('IDENTIFIER');
1174
+ this.expect('KEYWORD', 'for');
1175
+ // Target can be a dot-separated path like "Amaranthine.Items"
1176
+ // Parse as: IDENTIFIER (DOT IDENTIFIER)*
1177
+ const targetParts = [];
1178
+ targetParts.push(this.expect('IDENTIFIER').value);
1179
+
1180
+ // Consume dot-separated identifiers
1181
+ while (this.match('DOT')) {
1182
+ this.expect('DOT'); // consume dot
1183
+ if (this.match('IDENTIFIER')) {
1184
+ targetParts.push(this.expect('IDENTIFIER').value);
1185
+ } else {
1186
+ break;
1187
+ }
1188
+ }
1189
+
1190
+ const target = targetParts.join('.');
1191
+ const lbrace = this.expect('LBRACE');
1192
+ const body = this.parseSceneBody();
1193
+ const rbrace = this.expect('RBRACE');
1194
+
1195
+ return {
1196
+ kind: 'scene',
1197
+ name: nameToken.value,
1198
+ target,
1199
+ body,
1200
+ source: {
1201
+ file: this.file,
1202
+ start: startToken.span.start,
1203
+ end: rbrace.span.end,
1204
+ },
1205
+ };
1206
+ }
1207
+
1208
+ /**
1209
+ * Parses scene body blocks
1210
+ * @returns {Array<UsingBlock | ActorDecl | DescribeBlock | UnknownBlock>}
1211
+ */
1212
+ parseSceneBody() {
1213
+ const body = [];
1214
+
1215
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1216
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
1217
+ const keyword = this.peek()?.value;
1218
+ if (!keyword) break;
1219
+
1220
+ if (keyword === 'using' && this.match('KEYWORD')) {
1221
+ body.push(this.parseUsing());
1222
+ } else if (keyword === 'actor' && this.match('KEYWORD')) {
1223
+ body.push(this.parseActor());
1224
+ } else if (keyword === 'describe' && this.match('KEYWORD')) {
1225
+ body.push(this.parseDescribe());
1226
+ } else {
1227
+ // Unknown keyword/identifier followed by brace - parse as UnknownBlock
1228
+ const nextPos = this.pos + 1;
1229
+ if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
1230
+ body.push(this.parseUnknownBlock());
1231
+ } else {
1232
+ // Not a block, just skip this token (tolerant parsing)
1233
+ this.advance();
1234
+ }
1235
+ }
1236
+ } else {
1237
+ // Unexpected token in body - skip (tolerant)
1238
+ this.advance();
1239
+ }
1240
+ }
1241
+
1242
+ return body;
1243
+ }
1244
+
1245
+ /**
1246
+ * @returns {UsingBlock}
1247
+ */
1248
+ parseUsing() {
1249
+ const startToken = this.expect('KEYWORD', 'using');
1250
+ const lbrace = this.expect('LBRACE');
1251
+ const identifiers = [];
1252
+
1253
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1254
+ if (this.match('IDENTIFIER')) {
1255
+ identifiers.push(this.expect('IDENTIFIER').value);
1256
+ // Skip optional comma
1257
+ if (this.match('COMMA')) {
1258
+ this.expect('COMMA');
1259
+ }
1260
+ } else if (this.match('COMMA')) {
1261
+ // Skip stray commas
1262
+ this.expect('COMMA');
1263
+ } else {
1264
+ // Skip non-identifiers (tolerant parsing)
1265
+ this.advance();
1266
+ }
1267
+ }
1268
+
1269
+ const rbrace = this.expect('RBRACE');
1270
+
1271
+ return {
1272
+ kind: 'using',
1273
+ identifiers,
1274
+ source: {
1275
+ file: this.file,
1276
+ start: startToken.span.start,
1277
+ end: rbrace.span.end,
1278
+ },
1279
+ };
1280
+ }
1281
+
1282
+ /**
1283
+ * @returns {ActorDecl}
1284
+ */
1285
+ parseActor() {
1286
+ const startToken = this.expect('KEYWORD', 'actor');
1287
+ const nameToken = this.expect('IDENTIFIER');
1288
+ const lbrace = this.expect('LBRACE');
1289
+ const body = this.parseActorBody();
1290
+ const rbrace = this.expect('RBRACE');
1291
+
1292
+ return {
1293
+ kind: 'actor',
1294
+ name: nameToken.value,
1295
+ body,
1296
+ source: {
1297
+ file: this.file,
1298
+ start: startToken.span.start,
1299
+ end: rbrace.span.end,
1300
+ },
1301
+ };
1302
+ }
1303
+
1304
+ /**
1305
+ * Parses actor body blocks
1306
+ * @returns {Array<DescribeBlock | TypeBlock | IdentityBlock | SourceBlock | TransformsBlock | UnknownBlock>}
1307
+ */
1308
+ parseActorBody() {
1309
+ const body = [];
1310
+
1311
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1312
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
1313
+ const keyword = this.peek()?.value;
1314
+ if (!keyword) break;
1315
+
1316
+ if (keyword === 'describe' && this.match('KEYWORD')) {
1317
+ body.push(this.parseDescribe());
1318
+ } else if (keyword === 'type' && this.match('KEYWORD')) {
1319
+ body.push(this.parseTypeBlock());
1320
+ } else if (keyword === 'identity' && this.match('KEYWORD')) {
1321
+ body.push(this.parseIdentityBlock());
1322
+ } else if (keyword === 'file' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
1323
+ body.push(this.parseSourceBlock('file'));
1324
+ } else if (keyword === 'sqlite' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
1325
+ body.push(this.parseSourceBlock('sqlite'));
1326
+ } else if (keyword === 'mysql' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
1327
+ body.push(this.parseSourceBlock('mysql'));
1328
+ } else if (keyword === 'transforms' && this.match('KEYWORD')) {
1329
+ body.push(this.parseTransformsBlock());
1330
+ } else {
1331
+ // Unknown keyword/identifier followed by brace - parse as UnknownBlock
1332
+ const nextPos = this.pos + 1;
1333
+ if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
1334
+ body.push(this.parseUnknownBlock());
1335
+ } else {
1336
+ // Not a block, just skip this token (tolerant parsing)
1337
+ this.advance();
1338
+ }
1339
+ }
1340
+ } else {
1341
+ // Unexpected token in body - skip (tolerant)
1342
+ this.advance();
1343
+ }
1344
+ }
1345
+
1346
+ return body;
1347
+ }
1348
+
1349
+ /**
1350
+ * @returns {TypeBlock}
1351
+ */
1352
+ parseTypeBlock() {
1353
+ const startToken = this.expect('KEYWORD', 'type');
1354
+ const lbrace = this.expect('LBRACE');
1355
+
1356
+ // Track brace depth to find matching closing brace
1357
+ let depth = 1;
1358
+ const startOffset = lbrace.span.end.offset;
1359
+ let endOffset = startOffset;
1360
+ let endToken = null;
1361
+
1362
+ while (depth > 0 && this.pos < this.tokens.length) {
1363
+ const token = this.tokens[this.pos];
1364
+ if (token.type === 'EOF') break;
1365
+
1366
+ if (token.type === 'LBRACE') {
1367
+ depth++;
1368
+ this.pos++;
1369
+ } else if (token.type === 'RBRACE') {
1370
+ depth--;
1371
+ if (depth === 0) {
1372
+ endToken = token;
1373
+ endOffset = token.span.start.offset;
1374
+ this.pos++;
1375
+ break;
1376
+ } else {
1377
+ this.pos++;
1378
+ }
1379
+ } else {
1380
+ this.pos++;
1381
+ }
1382
+ }
1383
+
1384
+ if (depth > 0) {
1385
+ throw new Error(`Unclosed type block at ${this.file}:${startToken.span.start.line}`);
1386
+ }
1387
+
1388
+ // Extract raw content from source text
1389
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1390
+
1391
+ return {
1392
+ kind: 'type',
1393
+ raw: rawContent,
1394
+ source: {
1395
+ file: this.file,
1396
+ start: lbrace.span.end,
1397
+ end: endToken ? endToken.span.start : lbrace.span.end,
1398
+ },
1399
+ };
1400
+ }
1401
+
1402
+ /**
1403
+ * @returns {IdentityBlock}
1404
+ */
1405
+ parseIdentityBlock() {
1406
+ const startToken = this.expect('KEYWORD', 'identity');
1407
+ const lbrace = this.expect('LBRACE');
1408
+
1409
+ // Track brace depth to find matching closing brace
1410
+ let depth = 1;
1411
+ const startOffset = lbrace.span.end.offset;
1412
+ let endOffset = startOffset;
1413
+ let endToken = null;
1414
+
1415
+ while (depth > 0 && this.pos < this.tokens.length) {
1416
+ const token = this.tokens[this.pos];
1417
+ if (token.type === 'EOF') break;
1418
+
1419
+ if (token.type === 'LBRACE') {
1420
+ depth++;
1421
+ this.pos++;
1422
+ } else if (token.type === 'RBRACE') {
1423
+ depth--;
1424
+ if (depth === 0) {
1425
+ endToken = token;
1426
+ endOffset = token.span.start.offset;
1427
+ this.pos++;
1428
+ break;
1429
+ } else {
1430
+ this.pos++;
1431
+ }
1432
+ } else {
1433
+ this.pos++;
1434
+ }
1435
+ }
1436
+
1437
+ if (depth > 0) {
1438
+ throw new Error(`Unclosed identity block at ${this.file}:${startToken.span.start.line}`);
1439
+ }
1440
+
1441
+ // Extract raw content from source text
1442
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1443
+
1444
+ return {
1445
+ kind: 'identity',
1446
+ raw: rawContent,
1447
+ source: {
1448
+ file: this.file,
1449
+ start: lbrace.span.end,
1450
+ end: endToken ? endToken.span.start : lbrace.span.end,
1451
+ },
1452
+ };
1453
+ }
1454
+
1455
+ /**
1456
+ * @param {string} sourceType - 'file', 'sqlite', or 'mysql'
1457
+ * @returns {SourceBlock}
1458
+ */
1459
+ parseSourceBlock(sourceType) {
1460
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', sourceType) : this.expect('IDENTIFIER');
1461
+ const lbrace = this.expect('LBRACE');
1462
+
1463
+ // Track brace depth to find matching closing brace
1464
+ let depth = 1;
1465
+ const startOffset = lbrace.span.end.offset;
1466
+ let endOffset = startOffset;
1467
+ let endToken = null;
1468
+
1469
+ while (depth > 0 && this.pos < this.tokens.length) {
1470
+ const token = this.tokens[this.pos];
1471
+ if (token.type === 'EOF') break;
1472
+
1473
+ if (token.type === 'LBRACE') {
1474
+ depth++;
1475
+ this.pos++;
1476
+ } else if (token.type === 'RBRACE') {
1477
+ depth--;
1478
+ if (depth === 0) {
1479
+ endToken = token;
1480
+ endOffset = token.span.start.offset;
1481
+ this.pos++;
1482
+ break;
1483
+ } else {
1484
+ this.pos++;
1485
+ }
1486
+ } else {
1487
+ this.pos++;
1488
+ }
1489
+ }
1490
+
1491
+ if (depth > 0) {
1492
+ throw new Error(`Unclosed ${sourceType} block at ${this.file}:${startToken.span.start.line}`);
1493
+ }
1494
+
1495
+ // Extract raw content from source text
1496
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1497
+
1498
+ return {
1499
+ kind: 'source',
1500
+ sourceType,
1501
+ raw: rawContent,
1502
+ source: {
1503
+ file: this.file,
1504
+ start: lbrace.span.end,
1505
+ end: endToken ? endToken.span.start : lbrace.span.end,
1506
+ },
1507
+ };
1508
+ }
1509
+
1510
+ /**
1511
+ * @returns {TransformsBlock}
1512
+ */
1513
+ parseTransformsBlock() {
1514
+ const startToken = this.expect('KEYWORD', 'transforms');
1515
+ const lbrace = this.expect('LBRACE');
1516
+ const transforms = [];
1517
+
1518
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1519
+ if (this.match('KEYWORD') && this.peek()?.value === 'transform') {
1520
+ transforms.push(this.parseTransformBlock());
1521
+ } else {
1522
+ // Skip non-transform blocks (tolerant parsing)
1523
+ this.advance();
1524
+ }
1525
+ }
1526
+
1527
+ const rbrace = this.expect('RBRACE');
1528
+
1529
+ return {
1530
+ kind: 'transforms',
1531
+ transforms,
1532
+ source: {
1533
+ file: this.file,
1534
+ start: startToken.span.start,
1535
+ end: rbrace.span.end,
1536
+ },
1537
+ };
1538
+ }
1539
+
1540
+ /**
1541
+ * @returns {TransformBlock}
1542
+ */
1543
+ parseTransformBlock() {
1544
+ const startToken = this.expect('KEYWORD', 'transform');
1545
+ const lbrace = this.expect('LBRACE');
1546
+
1547
+ // Track brace depth to find matching closing brace
1548
+ let depth = 1;
1549
+ const startOffset = lbrace.span.end.offset;
1550
+ let endOffset = startOffset;
1551
+ let endToken = null;
1552
+
1553
+ while (depth > 0 && this.pos < this.tokens.length) {
1554
+ const token = this.tokens[this.pos];
1555
+ if (token.type === 'EOF') break;
1556
+
1557
+ if (token.type === 'LBRACE') {
1558
+ depth++;
1559
+ this.pos++;
1560
+ } else if (token.type === 'RBRACE') {
1561
+ depth--;
1562
+ if (depth === 0) {
1563
+ endToken = token;
1564
+ endOffset = token.span.start.offset;
1565
+ this.pos++;
1566
+ break;
1567
+ } else {
1568
+ this.pos++;
1569
+ }
1570
+ } else {
1571
+ this.pos++;
1572
+ }
1573
+ }
1574
+
1575
+ if (depth > 0) {
1576
+ throw new Error(`Unclosed transform block at ${this.file}:${startToken.span.start.line}`);
1577
+ }
1578
+
1579
+ // Extract raw content from source text
1580
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1581
+
1582
+ return {
1583
+ kind: 'transform',
1584
+ raw: rawContent,
1585
+ source: {
1586
+ file: this.file,
1587
+ start: lbrace.span.end,
1588
+ end: endToken ? endToken.span.start : lbrace.span.end,
1589
+ },
1590
+ };
1591
+ }
1592
+
1593
+ /**
1594
+ * Parses an unknown block with balanced brace scanning
1595
+ * @returns {UnknownBlock}
1596
+ */
1597
+ parseUnknownBlock() {
1598
+ // Can be either KEYWORD or IDENTIFIER
1599
+ const token = this.peek();
1600
+ if (!token || (token.type !== 'KEYWORD' && token.type !== 'IDENTIFIER')) {
1601
+ throw new Error(
1602
+ `Expected keyword or identifier for unknown block at ${this.file}:${token?.span.start.line || '?'}`,
1603
+ );
1604
+ }
1605
+ const keywordToken = this.advance();
1606
+ const lbrace = this.expect('LBRACE');
1607
+
1608
+ // Track brace depth to find matching closing brace
1609
+ let depth = 1;
1610
+ const startOffset = lbrace.span.end.offset;
1611
+ let endOffset = startOffset;
1612
+ let endToken = null;
1613
+
1614
+ while (depth > 0 && this.pos < this.tokens.length) {
1615
+ const token = this.tokens[this.pos];
1616
+ if (token.type === 'EOF') break;
1617
+
1618
+ if (token.type === 'LBRACE') {
1619
+ depth++;
1620
+ this.pos++;
1621
+ } else if (token.type === 'RBRACE') {
1622
+ depth--;
1623
+ if (depth === 0) {
1624
+ endToken = token;
1625
+ endOffset = token.span.start.offset;
1626
+ this.pos++;
1627
+ break;
1628
+ } else {
1629
+ this.pos++;
1630
+ }
1631
+ } else {
1632
+ this.pos++;
1633
+ }
1634
+ }
1635
+
1636
+ if (depth > 0) {
1637
+ const keywordValue = keywordToken ? keywordToken.value : 'unknown';
1638
+ const line = keywordToken ? keywordToken.span.start.line : '?';
1639
+ throw new Error(`Unclosed block "${keywordValue}" at ${this.file}:${line}`);
1640
+ }
1641
+
1642
+ // Extract raw content from source text
1643
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1644
+
1645
+ return {
1646
+ keyword: keywordToken.value,
1647
+ raw: rawContent,
1648
+ source: {
1649
+ file: this.file,
1650
+ start: lbrace.span.end,
1651
+ end: endToken ? endToken.span.start : lbrace.span.end,
1652
+ },
1653
+ };
1654
+ }
1655
+ }
1656
+