@sprig-and-prose/sprig-universe 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,856 @@
1
+ /**
2
+ * @fileoverview Converter from AST to Scene Manifest JSON
3
+ */
4
+
5
+ /**
6
+ * @typedef {import('../ast.js').FileAST} FileAST
7
+ * @typedef {import('../ast.js').SceneDecl} SceneDecl
8
+ * @typedef {import('../ast.js').ActorDecl} ActorDecl
9
+ * @typedef {import('../ast.js').UsingBlock} UsingBlock
10
+ * @typedef {import('../ast.js').DescribeBlock} DescribeBlock
11
+ * @typedef {import('../ast.js').TypeBlock} TypeBlock
12
+ * @typedef {import('../ast.js').IdentityBlock} IdentityBlock
13
+ * @typedef {import('../ast.js').SourceBlock} SourceBlock
14
+ * @typedef {import('../ast.js').TransformsBlock} TransformsBlock
15
+ * @typedef {import('../ast.js').TransformBlock} TransformBlock
16
+ * @typedef {import('../ast.js').UnknownBlock} UnknownBlock
17
+ */
18
+
19
+ /**
20
+ * Normalizes describe block text (similar to universe layer)
21
+ * @param {string} raw - Raw text content
22
+ * @returns {string} Normalized text
23
+ */
24
+ function normalizeDescribe(raw) {
25
+ // Trim leading/trailing whitespace and normalize internal whitespace
26
+ return raw
27
+ .split('\n')
28
+ .map((line) => line.trim())
29
+ .filter((line) => line.length > 0)
30
+ .join(' ')
31
+ .replace(/\s+/g, ' ')
32
+ .trim();
33
+ }
34
+
35
+ /**
36
+ * Converts a source span to location format
37
+ * @param {import('../ast.js').SourceSpan} source
38
+ * @returns {{ file: string, start: { line: number, col: number, offset: number }, end: { line: number, col: number, offset: number } }}
39
+ */
40
+ function sourceToLocation(source) {
41
+ return {
42
+ file: source.file,
43
+ start: source.start,
44
+ end: source.end,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Parses a type expression into a structured AST
50
+ * @param {string} typeExpr - Type expression string
51
+ * @returns {Object} Structured type AST
52
+ */
53
+ function parseTypeExpression(typeExpr) {
54
+ const trimmed = typeExpr.trim();
55
+
56
+ // Handle optional wrapper: "optional T"
57
+ const optionalMatch = trimmed.match(/^optional\s+(.+)$/i);
58
+ if (optionalMatch) {
59
+ return {
60
+ kind: 'optional',
61
+ of: parseTypeExpression(optionalMatch[1]),
62
+ };
63
+ }
64
+
65
+ // Handle array: "[ T ]"
66
+ const arrayMatch = trimmed.match(/^\[\s*(.+?)\s*\]$/);
67
+ if (arrayMatch) {
68
+ return {
69
+ kind: 'array',
70
+ elementType: parseTypeExpression(arrayMatch[1]),
71
+ };
72
+ }
73
+
74
+ // Handle enum/union: "one of { ... }"
75
+ const oneOfMatch = trimmed.match(/^one\s+of\s+\{\s*(.+?)\s*\}$/i);
76
+ if (oneOfMatch) {
77
+ const valuesStr = oneOfMatch[1];
78
+ // Parse values (can be strings, numbers, or identifiers)
79
+ const values = [];
80
+ const valueParts = valuesStr.split(',').map((v) => v.trim());
81
+
82
+ for (const part of valueParts) {
83
+ // Check if it's a quoted string
84
+ const stringMatch = part.match(/^['"](.+?)['"]$/);
85
+ if (stringMatch) {
86
+ values.push({ kind: 'string', value: stringMatch[1] });
87
+ } else if (/^\d+$/.test(part)) {
88
+ // Number
89
+ values.push({ kind: 'number', value: parseInt(part, 10) });
90
+ } else {
91
+ // Identifier or other
92
+ values.push({ kind: 'identifier', value: part });
93
+ }
94
+ }
95
+
96
+ // Infer value type from first value
97
+ const valueType = values.length > 0 ? values[0].kind : 'unknown';
98
+
99
+ return {
100
+ kind: 'oneOf',
101
+ values: values.map((v) => v.value),
102
+ valueType,
103
+ };
104
+ }
105
+
106
+ // Handle primitive types or type references
107
+ const primitiveTypes = new Set(['integer', 'string', 'boolean', 'number', 'float', 'double']);
108
+ if (primitiveTypes.has(trimmed.toLowerCase())) {
109
+ return {
110
+ kind: 'primitive',
111
+ name: trimmed.toLowerCase(),
112
+ };
113
+ }
114
+
115
+ // Type reference (e.g., "Tool", "Item")
116
+ if (/^[A-Z][A-Za-z0-9_]*$/.test(trimmed)) {
117
+ return {
118
+ kind: 'reference',
119
+ name: trimmed,
120
+ };
121
+ }
122
+
123
+ // Fallback: unknown type
124
+ return {
125
+ kind: 'unknown',
126
+ raw: trimmed,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Extracts a light structure from type block raw content
132
+ * Attempts to parse simple field definitions but keeps it minimal
133
+ * @param {string} raw - Raw type block content
134
+ * @param {import('../ast.js').SourceSpan} typeBlockLocation - Location of the type block
135
+ * @returns {{ fields?: Array<{ name: string, typeExpr: string, type: Object, required: boolean, location?: Object }> } | null}
136
+ */
137
+ function extractTypeFields(raw, typeBlockLocation) {
138
+ // Very light extraction - just try to find field: type patterns
139
+ // This is forward-compatible and doesn't do full type parsing
140
+ const fields = [];
141
+ const lines = raw.split('\n');
142
+
143
+ // Track line offset relative to type block start
144
+ // The start line is the opening brace line, and the first field is on the next line
145
+ // So we initialize to start.line and increment before processing each line
146
+ let currentLine = typeBlockLocation.start.line;
147
+ let lineOffset = 0;
148
+
149
+ // Track indentation levels to detect nested structures
150
+ const getIndentLevel = (line) => {
151
+ const match = line.match(/^(\s*)/);
152
+ return match ? match[1].length : 0;
153
+ };
154
+
155
+ // Find the minimum indentation level (base indent)
156
+ // This handles cases where the first line has no indent but subsequent lines do
157
+ let baseIndent = Infinity;
158
+ for (const line of lines) {
159
+ const trimmed = line.trim();
160
+ if (trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('--')) {
161
+ const indent = getIndentLevel(line);
162
+ if (indent < baseIndent) {
163
+ baseIndent = indent;
164
+ }
165
+ }
166
+ }
167
+
168
+ // If we couldn't find a base indent, default to 0
169
+ if (baseIndent === Infinity) {
170
+ baseIndent = 0;
171
+ }
172
+
173
+ let skipUntilIndent = -1; // Track when to skip nested content
174
+
175
+ for (let i = 0; i < lines.length; i++) {
176
+ const line = lines[i];
177
+ const trimmed = line.trim();
178
+
179
+ // Update current line for each line we process
180
+ // This ensures the first field gets the correct line number
181
+ currentLine++;
182
+
183
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('--')) {
184
+ // Update line offset even for empty lines
185
+ lineOffset += line.length + 1;
186
+ continue;
187
+ }
188
+
189
+ const indentLevel = getIndentLevel(line);
190
+
191
+ // Skip nested content (indented more than base)
192
+ if (skipUntilIndent >= 0) {
193
+ if (indentLevel > skipUntilIndent) {
194
+ // Still in nested content, skip
195
+ lineOffset += line.length + 1;
196
+ continue;
197
+ } else {
198
+ // Back to base level, stop skipping
199
+ skipUntilIndent = -1;
200
+ }
201
+ }
202
+
203
+ // Look for pattern: identifier { typeExpr } or identifier : typeExpr
204
+ // Pattern 1: fieldName { typeExpr } - handle nested braces
205
+ const braceStartMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*/);
206
+ if (braceStartMatch) {
207
+ const fieldName = braceStartMatch[1];
208
+ const fieldStartCol = line.indexOf(fieldName) + 1; // 1-indexed
209
+ const startPos = braceStartMatch[0].length;
210
+
211
+ // Find matching closing brace by tracking depth on the same line
212
+ let depth = 1;
213
+ let pos = startPos;
214
+ let endPos = startPos;
215
+
216
+ while (pos < trimmed.length && depth > 0) {
217
+ if (trimmed[pos] === '{') {
218
+ depth++;
219
+ } else if (trimmed[pos] === '}') {
220
+ depth--;
221
+ if (depth === 0) {
222
+ endPos = pos;
223
+ break;
224
+ }
225
+ }
226
+ pos++;
227
+ }
228
+
229
+ // If not closed on same line, check if next line has nested content
230
+ if (depth > 0 && i + 1 < lines.length) {
231
+ const nextLine = lines[i + 1];
232
+ const nextIndent = getIndentLevel(nextLine);
233
+ const nextTrimmed = nextLine.trim();
234
+
235
+ // If next line is more indented than current line and not empty, it's a nested object
236
+ if (nextIndent > indentLevel && nextTrimmed && !nextTrimmed.startsWith('//') && !nextTrimmed.startsWith('--')) {
237
+ // Check if the nested content starts with "optional {"
238
+ // This indicates the field itself is optional, not just a nested object
239
+ const isOptionalField = nextTrimmed.match(/^optional\s*\{/i) !== null;
240
+
241
+ // Extract nested fields from the nested object structure
242
+ // Find the content inside the nested braces (or inside optional { ... })
243
+ let nestedContentStart = i + 1;
244
+ let nestedContentEnd = i + 1;
245
+ let nestedIndent = nextIndent;
246
+
247
+ // If it starts with "optional {", find the content inside optional block
248
+ if (isOptionalField) {
249
+ // Find the closing brace of the optional block
250
+ let optionalDepth = 1;
251
+ nestedContentStart = i + 1;
252
+ let foundClosingBrace = false;
253
+ for (let j = i + 1; j < lines.length; j++) {
254
+ const testLine = lines[j];
255
+ const testTrimmed = testLine.trim();
256
+
257
+ // Count braces to find end of optional block (check this first)
258
+ for (const ch of testTrimmed) {
259
+ if (ch === '{') optionalDepth++;
260
+ if (ch === '}') {
261
+ optionalDepth--;
262
+ if (optionalDepth === 0) {
263
+ // Found the closing brace of the optional block
264
+ nestedContentEnd = j;
265
+ foundClosingBrace = true;
266
+ break;
267
+ }
268
+ }
269
+ }
270
+
271
+ if (foundClosingBrace) break;
272
+
273
+ // Check if we've gone back to base level (only after checking for closing brace)
274
+ const testIndent = getIndentLevel(testLine);
275
+ if (testIndent <= indentLevel && testTrimmed && !testTrimmed.startsWith('//') && !testTrimmed.startsWith('--')) {
276
+ // Back to base level without finding closing brace - this shouldn't happen normally
277
+ break;
278
+ }
279
+ }
280
+
281
+ // The nested fields are inside the optional block, starting from the line after "optional {"
282
+ nestedContentStart = i + 2; // Skip "optional {" line
283
+
284
+ // If nestedContentEnd points to lines that only contain closing braces,
285
+ // exclude them from the content to extract (work backwards)
286
+ while (nestedContentEnd >= nestedContentStart && nestedContentEnd < lines.length) {
287
+ const endLineTrimmed = lines[nestedContentEnd]?.trim() || '';
288
+ // If the end line is just a closing brace (possibly with whitespace), exclude it
289
+ if (endLineTrimmed === '}' || endLineTrimmed.match(/^\s*\}\s*$/)) {
290
+ nestedContentEnd = nestedContentEnd - 1;
291
+ } else {
292
+ break; // Found a line with actual content
293
+ }
294
+ }
295
+
296
+ nestedIndent = getIndentLevel(lines[nestedContentStart] || '');
297
+ } else {
298
+ // Find where the nested object block ends
299
+ let objectDepth = 1;
300
+ for (let j = i + 1; j < lines.length; j++) {
301
+ const testLine = lines[j];
302
+ const testIndent = getIndentLevel(testLine);
303
+ const testTrimmed = testLine.trim();
304
+
305
+ if (testIndent <= indentLevel && testTrimmed && !testTrimmed.startsWith('//') && !testTrimmed.startsWith('--')) {
306
+ nestedContentEnd = j - 1;
307
+ break;
308
+ }
309
+
310
+ // Count braces
311
+ for (const ch of testTrimmed) {
312
+ if (ch === '{') objectDepth++;
313
+ if (ch === '}') {
314
+ objectDepth--;
315
+ if (objectDepth === 0) {
316
+ nestedContentEnd = j;
317
+ break;
318
+ }
319
+ }
320
+ }
321
+
322
+ if (objectDepth === 0) {
323
+ nestedContentEnd = j;
324
+ break;
325
+ }
326
+ }
327
+ }
328
+
329
+ // Extract nested fields from the nested content
330
+ let nestedFields = null;
331
+ if (nestedContentStart <= nestedContentEnd && nestedContentStart < lines.length) {
332
+ // Build a sub-raw content for the nested object
333
+ const nestedLines = [];
334
+ for (let j = nestedContentStart; j <= nestedContentEnd && j < lines.length; j++) {
335
+ nestedLines.push(lines[j]);
336
+ }
337
+ const nestedRaw = nestedLines.join('\n');
338
+
339
+ // Create a location for the nested type block (relative to parent)
340
+ const nestedLocation = {
341
+ file: typeBlockLocation.file,
342
+ start: {
343
+ line: currentLine + (nestedContentStart - i),
344
+ col: 1,
345
+ offset: typeBlockLocation.start.offset + lineOffset + (nestedContentStart > i ? lines.slice(i + 1, nestedContentStart).reduce((sum, l) => sum + l.length + 1, 0) : 0),
346
+ },
347
+ end: {
348
+ line: currentLine + (nestedContentEnd - i),
349
+ col: 1,
350
+ offset: typeBlockLocation.start.offset + lineOffset + nestedLines.reduce((sum, l) => sum + l.length + 1, 0),
351
+ },
352
+ };
353
+
354
+ // Recursively extract fields from nested content
355
+ nestedFields = extractTypeFields(nestedRaw, nestedLocation);
356
+ // Note: The optional { } wrapper makes the parent field optional,
357
+ // but nested fields inside are still required when the parent is present
358
+ }
359
+
360
+ // This is a nested object structure - treat as object type
361
+ const typeExpr = isOptionalField ? 'optional { ... }' : '{ ... }';
362
+ const fieldEndCol = fieldStartCol + trimmed.length;
363
+
364
+ const fieldDef = {
365
+ name: fieldName,
366
+ typeExpr: typeExpr,
367
+ type: { kind: 'object', nested: true },
368
+ required: !isOptionalField, // Optional if it starts with "optional {"
369
+ location: {
370
+ file: typeBlockLocation.file,
371
+ start: {
372
+ line: currentLine,
373
+ col: fieldStartCol,
374
+ offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
375
+ },
376
+ end: {
377
+ line: currentLine,
378
+ col: fieldEndCol,
379
+ offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
380
+ },
381
+ },
382
+ };
383
+
384
+ // Add nested fields if we extracted them
385
+ if (nestedFields && nestedFields.fields) {
386
+ fieldDef.nestedFields = nestedFields.fields;
387
+ }
388
+
389
+ fields.push(fieldDef);
390
+
391
+ // Skip nested content until we're back at this indent level or less
392
+ skipUntilIndent = indentLevel;
393
+ lineOffset += line.length + 1;
394
+ continue;
395
+ }
396
+ }
397
+
398
+ if (depth === 0) {
399
+ // Simple type on same line
400
+ const typeExpr = trimmed.substring(startPos, endPos).trim();
401
+ const fieldEndCol = fieldStartCol + trimmed.length;
402
+
403
+ // Determine if field is required (not optional)
404
+ const isOptional = /^optional\s+/i.test(typeExpr);
405
+
406
+ // Parse type expression into structured AST
407
+ const typeAst = parseTypeExpression(typeExpr);
408
+
409
+ fields.push({
410
+ name: fieldName,
411
+ typeExpr: typeExpr || 'unknown',
412
+ type: typeAst,
413
+ required: !isOptional,
414
+ location: {
415
+ file: typeBlockLocation.file,
416
+ start: {
417
+ line: currentLine,
418
+ col: fieldStartCol,
419
+ offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
420
+ },
421
+ end: {
422
+ line: currentLine,
423
+ col: fieldEndCol,
424
+ offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
425
+ },
426
+ },
427
+ });
428
+ lineOffset += line.length + 1;
429
+ continue;
430
+ }
431
+ }
432
+
433
+ // Pattern 2: fieldName : typeExpr (colon syntax)
434
+ const colonMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)/);
435
+ if (colonMatch) {
436
+ const fieldName = colonMatch[1];
437
+ const typeExpr = colonMatch[2].trim();
438
+ const fieldStartCol = line.indexOf(fieldName) + 1;
439
+ const fieldEndCol = fieldStartCol + trimmed.length;
440
+
441
+ const isOptional = /^optional\s+/i.test(typeExpr);
442
+ const typeAst = parseTypeExpression(typeExpr);
443
+
444
+ fields.push({
445
+ name: fieldName,
446
+ typeExpr: typeExpr || 'unknown',
447
+ type: typeAst,
448
+ required: !isOptional,
449
+ location: {
450
+ file: typeBlockLocation.file,
451
+ start: {
452
+ line: currentLine,
453
+ col: fieldStartCol,
454
+ offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
455
+ },
456
+ end: {
457
+ line: currentLine,
458
+ col: fieldEndCol,
459
+ offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
460
+ },
461
+ },
462
+ });
463
+ }
464
+
465
+ // Update line offset for next iteration
466
+ lineOffset += line.length + 1; // +1 for newline
467
+ }
468
+
469
+ return fields.length > 0 ? { fields } : null;
470
+ }
471
+
472
+ /**
473
+ * Converts a describe block to manifest format
474
+ * @param {DescribeBlock} describeBlock
475
+ * @returns {{ raw: string, normalized: string }}
476
+ */
477
+ function convertDescribe(describeBlock) {
478
+ return {
479
+ raw: describeBlock.raw,
480
+ normalized: normalizeDescribe(describeBlock.raw),
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Converts a source block to manifest format
486
+ * @param {SourceBlock} sourceBlock
487
+ * @returns {Object}
488
+ */
489
+ function convertSourceBlock(sourceBlock) {
490
+ const base = {
491
+ ast: sourceBlock.raw,
492
+ location: sourceToLocation(sourceBlock.source),
493
+ };
494
+
495
+ // Try to extract common properties (repository, paths, name, format, connection, table)
496
+ // This is a light extraction - we preserve the raw AST for full parsing later
497
+ const extracted = {};
498
+
499
+ // Simple extraction: look for key: value patterns
500
+ const lines = sourceBlock.raw.split('\n');
501
+ for (const line of lines) {
502
+ const trimmed = line.trim();
503
+ if (!trimmed) continue;
504
+
505
+ // Match: key { value } or key { 'value' }
506
+ const keyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
507
+ if (keyMatch) {
508
+ const key = keyMatch[1];
509
+ // Extract value (simplified - just get content between braces)
510
+ const braceStart = trimmed.indexOf('{');
511
+ const braceEnd = trimmed.lastIndexOf('}');
512
+ if (braceStart >= 0 && braceEnd > braceStart) {
513
+ const valueContent = trimmed.substring(braceStart + 1, braceEnd).trim();
514
+ // Remove quotes if present
515
+ const value = valueContent.replace(/^['"]|['"]$/g, '');
516
+
517
+ if (key === 'paths') {
518
+ // Handle paths - can be single value or comma-separated
519
+ if (value.includes(',')) {
520
+ // Handle array-like paths
521
+ extracted.paths = value.split(',').map((p) => p.trim().replace(/^['"]|['"]$/g, ''));
522
+ } else {
523
+ // Single path value
524
+ extracted.paths = [value];
525
+ }
526
+ } else if (key === 'shape') {
527
+ // Validate shape is either 'list' or 'record'
528
+ if (value === 'list' || value === 'record') {
529
+ extracted.shape = value;
530
+ } else {
531
+ // Invalid shape value - preserve it but could emit warning
532
+ extracted.shape = value;
533
+ }
534
+ } else if (key === 'repository' || key === 'name' || key === 'format' || key === 'connection' || key === 'table') {
535
+ extracted[key] = value;
536
+ }
537
+ }
538
+ }
539
+ }
540
+
541
+ return {
542
+ ...extracted,
543
+ ...base,
544
+ };
545
+ }
546
+
547
+ /**
548
+ * Converts an actor to manifest format
549
+ * @param {ActorDecl} actor
550
+ * @returns {Object}
551
+ */
552
+ function convertActor(actor) {
553
+ const result = {
554
+ name: actor.name,
555
+ location: sourceToLocation(actor.source),
556
+ sources: {},
557
+ transforms: [],
558
+ unknown: [],
559
+ };
560
+
561
+ for (const block of actor.body) {
562
+ if (block.kind === 'describe') {
563
+ result.describe = convertDescribe(block);
564
+ } else if (block.kind === 'type') {
565
+ const typeFields = extractTypeFields(block.raw, block.source);
566
+ result.type = {
567
+ ast: block.raw,
568
+ ...(typeFields || {}),
569
+ location: sourceToLocation(block.source),
570
+ };
571
+ } else if (block.kind === 'identity') {
572
+ result.identity = {
573
+ ast: block.raw,
574
+ location: sourceToLocation(block.source),
575
+ };
576
+ } else if (block.kind === 'source') {
577
+ const sourceType = block.sourceType;
578
+ if (!result.sources[sourceType]) {
579
+ result.sources[sourceType] = [];
580
+ }
581
+ result.sources[sourceType].push(convertSourceBlock(block));
582
+ } else if (block.kind === 'transforms') {
583
+ for (const transform of block.transforms) {
584
+ result.transforms.push({
585
+ ast: transform.raw,
586
+ location: sourceToLocation(transform.source),
587
+ });
588
+ }
589
+ } else {
590
+ // Unknown block
591
+ result.unknown.push({
592
+ keyword: block.keyword || 'unknown',
593
+ ast: block.raw,
594
+ location: sourceToLocation(block.source),
595
+ });
596
+ }
597
+ }
598
+
599
+ // Convert sources object to have single entries (not arrays) if only one of each type
600
+ const normalizedSources = {};
601
+ for (const [sourceType, entries] of Object.entries(result.sources)) {
602
+ if (entries.length === 1) {
603
+ normalizedSources[sourceType] = entries[0];
604
+ } else {
605
+ normalizedSources[sourceType] = entries;
606
+ }
607
+ }
608
+ result.sources = normalizedSources;
609
+
610
+ return result;
611
+ }
612
+
613
+ /**
614
+ * Converts a scene to manifest format
615
+ * @param {SceneDecl} scene
616
+ * @returns {Object}
617
+ */
618
+ function convertScene(scene) {
619
+ const result = {
620
+ name: scene.name,
621
+ target: scene.target,
622
+ location: sourceToLocation(scene.source),
623
+ using: [],
624
+ actors: [],
625
+ unknown: [],
626
+ };
627
+
628
+ for (const block of scene.body) {
629
+ if (block.kind === 'using') {
630
+ result.using = [...block.identifiers];
631
+ } else if (block.kind === 'actor') {
632
+ result.actors.push(convertActor(block));
633
+ } else if (block.kind === 'describe') {
634
+ result.describe = convertDescribe(block);
635
+ } else {
636
+ // Unknown block
637
+ result.unknown.push({
638
+ keyword: block.keyword || 'unknown',
639
+ ast: block.raw,
640
+ location: sourceToLocation(block.source),
641
+ });
642
+ }
643
+ }
644
+
645
+ return result;
646
+ }
647
+
648
+ /**
649
+ * Converts a FileAST to Scene Manifest JSON
650
+ * @param {FileAST} fileAST
651
+ * @returns {Object}
652
+ */
653
+ export function convertToSceneManifest(fileAST) {
654
+ const scenes = fileAST.scenes.map(convertScene);
655
+
656
+ return {
657
+ kind: 'scene-manifest',
658
+ scenes,
659
+ };
660
+ }
661
+
662
+ /**
663
+ * Builds an actor map across all scenes and resolves using blocks
664
+ * @param {Object} manifest - Scene manifest
665
+ * @returns {Map<string, Object>} Map of actor name to actor definition
666
+ */
667
+ function buildActorMap(manifest) {
668
+ const actorMap = new Map();
669
+ const sceneMap = new Map(); // scene name -> scene
670
+
671
+ // First pass: build scene map and collect all actors
672
+ for (const scene of manifest.scenes) {
673
+ sceneMap.set(scene.name, scene);
674
+ for (const actor of scene.actors || []) {
675
+ actorMap.set(actor.name, actor);
676
+ }
677
+ }
678
+
679
+ // Second pass: resolve using blocks
680
+ // For each scene, make actors from used scenes available
681
+ // Note: This doesn't modify the scene structure, just makes actors findable
682
+ // The actual resolution happens when resolving type references
683
+
684
+ return actorMap;
685
+ }
686
+
687
+ /**
688
+ * Resolves a type reference to its actual type
689
+ * @param {Object} typeAst - Type AST to resolve
690
+ * @param {Map<string, Object>} actorMap - Map of actor names to actor definitions
691
+ * @param {Set<string>} visited - Set of visited type names (for cycle detection)
692
+ * @returns {Object} Resolved type AST
693
+ */
694
+ function resolveTypeReference(typeAst, actorMap, visited = new Set()) {
695
+ if (!typeAst) {
696
+ return { kind: 'unknown' };
697
+ }
698
+
699
+ // Handle optional wrapper
700
+ if (typeAst.kind === 'optional') {
701
+ return {
702
+ kind: 'optional',
703
+ of: resolveTypeReference(typeAst.of, actorMap, visited),
704
+ };
705
+ }
706
+
707
+ // Handle array
708
+ if (typeAst.kind === 'array') {
709
+ return {
710
+ kind: 'array',
711
+ elementType: resolveTypeReference(typeAst.elementType, actorMap, visited),
712
+ };
713
+ }
714
+
715
+ // Primitives and oneOf are already resolved
716
+ if (typeAst.kind === 'primitive' || typeAst.kind === 'oneOf' || typeAst.kind === 'unknown') {
717
+ return typeAst;
718
+ }
719
+
720
+ // Handle reference
721
+ if (typeAst.kind === 'reference') {
722
+ const refName = typeAst.name;
723
+
724
+ // Cycle detection
725
+ if (visited.has(refName)) {
726
+ // Circular reference - return as reference (it's an object type)
727
+ return typeAst;
728
+ }
729
+
730
+ visited.add(refName);
731
+
732
+ // Look up the actor
733
+ const referencedActor = actorMap.get(refName);
734
+ if (!referencedActor) {
735
+ // Actor not found - return unknown
736
+ visited.delete(refName);
737
+ return { kind: 'unknown', raw: refName };
738
+ }
739
+
740
+ // Check if actor has a type definition
741
+ if (!referencedActor.type) {
742
+ visited.delete(refName);
743
+ return { kind: 'unknown', raw: refName };
744
+ }
745
+
746
+ // If actor has fields, it's an object type - return as reference
747
+ const hasFields = referencedActor.type.fields &&
748
+ Array.isArray(referencedActor.type.fields) &&
749
+ referencedActor.type.fields.length > 0;
750
+ if (hasFields) {
751
+ visited.delete(refName);
752
+ return typeAst;
753
+ }
754
+
755
+ // If actor has no fields but has a type AST, it's a primitive type alias
756
+ // Parse the type expression to get the primitive
757
+ let typeExpr = referencedActor.type.ast || '';
758
+ if (!typeExpr.trim()) {
759
+ visited.delete(refName);
760
+ return { kind: 'unknown', raw: refName };
761
+ }
762
+
763
+ // Extract the type expression from the AST
764
+ // The AST might be just "integer" or "{ integer }" or have newlines
765
+ typeExpr = typeExpr.trim();
766
+ // Remove outer braces if present
767
+ if (typeExpr.startsWith('{') && typeExpr.endsWith('}')) {
768
+ typeExpr = typeExpr.slice(1, -1).trim();
769
+ }
770
+ // Take the first line if there are multiple lines (for simple types)
771
+ const firstLine = typeExpr.split('\n')[0].trim();
772
+ typeExpr = firstLine;
773
+
774
+ // Parse the type expression - it should be a simple type like "integer"
775
+ const parsedType = parseTypeExpression(typeExpr);
776
+
777
+ // If it resolved to a primitive, return that
778
+ if (parsedType.kind === 'primitive') {
779
+ visited.delete(refName);
780
+ return parsedType;
781
+ }
782
+
783
+ // If it's another reference, resolve recursively
784
+ if (parsedType.kind === 'reference') {
785
+ const resolved = resolveTypeReference(parsedType, actorMap, visited);
786
+ visited.delete(refName);
787
+ return resolved;
788
+ }
789
+
790
+ // If it's optional, unwrap and resolve
791
+ if (parsedType.kind === 'optional') {
792
+ const resolved = resolveTypeReference(parsedType.of, actorMap, visited);
793
+ visited.delete(refName);
794
+ return {
795
+ kind: 'optional',
796
+ of: resolved,
797
+ };
798
+ }
799
+
800
+ // Otherwise return the parsed type
801
+ visited.delete(refName);
802
+ return parsedType;
803
+ }
804
+
805
+ // Unknown kind - return as-is
806
+ return typeAst;
807
+ }
808
+
809
+ /**
810
+ * Resolves all type references in a scene manifest
811
+ * @param {Object} manifest - Scene manifest
812
+ * @returns {Object} Manifest with resolved types
813
+ */
814
+ function resolveTypesInManifest(manifest) {
815
+ // Build actor map
816
+ const actorMap = buildActorMap(manifest);
817
+
818
+ // Resolve types in all actors
819
+ for (const scene of manifest.scenes) {
820
+ for (const actor of scene.actors || []) {
821
+ if (actor.type && actor.type.fields && Array.isArray(actor.type.fields)) {
822
+ for (const field of actor.type.fields) {
823
+ if (field.type && field.type.kind === 'reference') {
824
+ // Only resolve references, leave other types as-is
825
+ const resolved = resolveTypeReference(field.type, actorMap);
826
+ // Replace with resolved type (even if it's still a reference for object types)
827
+ field.type = resolved;
828
+ }
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+ return manifest;
835
+ }
836
+
837
+ /**
838
+ * Converts multiple FileASTs to a single Scene Manifest JSON
839
+ * @param {FileAST[]} fileASTs
840
+ * @returns {Object}
841
+ */
842
+ export function convertFilesToSceneManifest(fileASTs) {
843
+ const allScenes = [];
844
+ for (const fileAST of fileASTs) {
845
+ allScenes.push(...fileAST.scenes.map(convertScene));
846
+ }
847
+
848
+ const manifest = {
849
+ kind: 'scene-manifest',
850
+ scenes: allScenes,
851
+ };
852
+
853
+ // Resolve type references
854
+ return resolveTypesInManifest(manifest);
855
+ }
856
+