@sprig-and-prose/sprig-universe 0.3.1 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/sprig-universe",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
package/src/ast.js CHANGED
@@ -176,7 +176,7 @@
176
176
  * @property {string} kind - Always 'relates'
177
177
  * @property {string} a - First endpoint text
178
178
  * @property {string} b - Second endpoint text
179
- * @property {Array<DescribeBlock | TitleBlock | FromBlock | UnknownBlock>} body - Body declarations
179
+ * @property {Array<DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | UnknownBlock>} body - Body declarations
180
180
  * @property {SourceSpan} source - Source span
181
181
  */
182
182
 
package/src/graph.js CHANGED
@@ -74,6 +74,8 @@ export function buildGraph(fileASTs) {
74
74
  const pendingReferenceDecls = [];
75
75
  /** @type {Array<{ nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string }>} */
76
76
  const pendingReferenceAttachments = [];
77
+ /** @type {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} */
78
+ const pendingContainerResolutions = [];
77
79
 
78
80
  // First pass: collect all universe names with their file locations for validation
79
81
  const universeNameToFiles = new Map(); // universeName -> Set<file>
@@ -189,6 +191,7 @@ export function buildGraph(fileASTs) {
189
191
  entityKinds,
190
192
  pendingReferenceDecls,
191
193
  pendingReferenceAttachments,
194
+ pendingContainerResolutions,
192
195
  );
193
196
  } else {
194
197
  // First time seeing this universe - create node and process body
@@ -218,6 +221,7 @@ export function buildGraph(fileASTs) {
218
221
  entityKinds,
219
222
  pendingReferenceDecls,
220
223
  pendingReferenceAttachments,
224
+ pendingContainerResolutions,
221
225
  );
222
226
  }
223
227
  }
@@ -265,6 +269,7 @@ export function buildGraph(fileASTs) {
265
269
  entityKinds,
266
270
  pendingReferenceDecls,
267
271
  pendingReferenceAttachments,
272
+ pendingContainerResolutions,
268
273
  );
269
274
  }
270
275
  }
@@ -286,6 +291,9 @@ export function buildGraph(fileASTs) {
286
291
  // Resolve edge endpoints
287
292
  resolveEdges(graph, scopeIndex);
288
293
 
294
+ // Resolve container references (books/chapters) that may have forward references
295
+ resolveContainers(graph, scopeIndex, pendingContainerResolutions);
296
+
289
297
  return graph;
290
298
  }
291
299
 
@@ -496,6 +504,7 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
496
504
  * @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
497
505
  * @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
498
506
  * @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
507
+ * @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
499
508
  */
500
509
  function processBody(
501
510
  graph,
@@ -511,6 +520,7 @@ function processBody(
511
520
  entityKinds,
512
521
  pendingReferenceDecls,
513
522
  pendingReferenceAttachments,
523
+ pendingContainerResolutions,
514
524
  ) {
515
525
  for (const decl of body) {
516
526
  if (decl.kind === 'anthology') {
@@ -541,6 +551,7 @@ function processBody(
541
551
  entityKinds,
542
552
  pendingReferenceDecls,
543
553
  pendingReferenceAttachments,
554
+ pendingContainerResolutions,
544
555
  );
545
556
  } else if (decl.kind === 'series') {
546
557
  const nodeId = makeNodeId(universeName, 'series', decl.name);
@@ -574,6 +585,7 @@ function processBody(
574
585
  entityKinds,
575
586
  pendingReferenceDecls,
576
587
  pendingReferenceAttachments,
588
+ pendingContainerResolutions,
577
589
  );
578
590
  } else if (decl.kind === 'book') {
579
591
  const nodeId = makeNodeId(universeName, 'book', decl.name);
@@ -616,6 +628,7 @@ function processBody(
616
628
  entityKinds,
617
629
  pendingReferenceDecls,
618
630
  pendingReferenceAttachments,
631
+ pendingContainerResolutions,
619
632
  );
620
633
  } else if (decl.kind === 'chapter') {
621
634
  const nodeId = makeNodeId(universeName, 'chapter', decl.name);
@@ -634,7 +647,9 @@ function processBody(
634
647
  const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
635
648
  const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
636
649
 
637
- // Validate: chapter container must be a book
650
+ // Validate: chapter container must be a book (if found)
651
+ // Note: We defer "not found" validation until after all nodes are created
652
+ // to allow forward references (book defined after chapter)
638
653
  if (containerNodeId) {
639
654
  const containerNode = graph.nodes[containerNodeId];
640
655
  if (containerNode && containerNode.kind !== 'book') {
@@ -644,12 +659,14 @@ function processBody(
644
659
  source: decl.source,
645
660
  });
646
661
  }
647
- } else if (decl.parentName) {
648
- // Container not found
649
- graph.diagnostics.push({
650
- severity: 'error',
651
- message: `Chapter "${decl.name}" references unknown book "${decl.parentName}"`,
662
+ } else if (decl.parentName && pendingContainerResolutions) {
663
+ // Container not found yet - may be a forward reference, track for later resolution
664
+ pendingContainerResolutions.push({
665
+ nodeId,
666
+ parentName: decl.parentName,
667
+ scopeNodeId: currentNodeId,
652
668
  source: decl.source,
669
+ nodeKind: 'chapter',
653
670
  });
654
671
  }
655
672
 
@@ -683,6 +700,7 @@ function processBody(
683
700
  entityKinds,
684
701
  pendingReferenceDecls,
685
702
  pendingReferenceAttachments,
703
+ pendingContainerResolutions,
686
704
  );
687
705
  } else if (decl.kind === 'concept') {
688
706
  const nodeId = makeNodeId(universeName, 'concept', decl.name);
@@ -731,10 +749,13 @@ function processBody(
731
749
  finalRelatesNodeId = makeRelatesNodeId(universeName, decl.a, decl.b, index);
732
750
  }
733
751
 
734
- // Process relates body: extract describe, from blocks, and unknown blocks
752
+ // Process relates body: extract describe, relationships, from blocks, and unknown blocks
735
753
  const describeBlocks = decl.body.filter((b) => b.kind === 'describe');
754
+ const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
736
755
  const fromBlocks = decl.body.filter((b) => b.kind === 'from');
737
- const unknownBlocks = decl.body.filter((b) => b.kind !== 'describe' && b.kind !== 'from');
756
+ const unknownBlocks = decl.body.filter(
757
+ (b) => b.kind !== 'describe' && b.kind !== 'from' && b.kind !== 'relationships',
758
+ );
738
759
 
739
760
  // Validate: only one top-level describe
740
761
  if (describeBlocks.length > 1) {
@@ -745,6 +766,24 @@ function processBody(
745
766
  });
746
767
  }
747
768
 
769
+ // Validate: only one top-level relationships block
770
+ if (relationshipsBlocks.length > 1) {
771
+ graph.diagnostics.push({
772
+ severity: 'error',
773
+ message: `Multiple relationships blocks in relates "${decl.a} and ${decl.b}"`,
774
+ source: relationshipsBlocks[1].source,
775
+ });
776
+ }
777
+
778
+ // Validate: relationships must have at least one value
779
+ if (relationshipsBlocks.length > 0 && relationshipsBlocks[0].values.length === 0) {
780
+ graph.diagnostics.push({
781
+ severity: 'error',
782
+ message: `Empty relationships block in relates "${decl.a} and ${decl.b}"`,
783
+ source: relationshipsBlocks[0].source,
784
+ });
785
+ }
786
+
748
787
  // Validate: from blocks must reference endpoints
749
788
  const endpointSet = new Set([decl.a, decl.b]);
750
789
  const fromByEndpoint = new Map();
@@ -790,6 +829,14 @@ function processBody(
790
829
  };
791
830
  }
792
831
 
832
+ // Add top-level relationships if present
833
+ if (relationshipsBlocks.length > 0) {
834
+ relatesNode.relationships = {
835
+ values: relationshipsBlocks[0].values,
836
+ source: relationshipsBlocks[0].source,
837
+ };
838
+ }
839
+
793
840
  // Process from blocks (will be resolved later)
794
841
  relatesNode.from = {};
795
842
  for (const fromBlock of fromBlocks) {
@@ -1293,6 +1340,65 @@ function resolveEdges(graph, scopeIndex) {
1293
1340
  }
1294
1341
  }
1295
1342
 
1343
+ /**
1344
+ * Resolves container references for chapters and books that may have forward references
1345
+ * @param {UniverseGraph} graph
1346
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1347
+ * @param {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} pending
1348
+ */
1349
+ function resolveContainers(graph, scopeIndex, pending) {
1350
+ for (const item of pending) {
1351
+ const { nodeId, parentName, scopeNodeId, source, nodeKind } = item;
1352
+ const node = graph.nodes[nodeId];
1353
+ if (!node) continue;
1354
+
1355
+ // Try to resolve the container now that all nodes are created
1356
+ const resolved = resolveNameInScope(graph, scopeIndex, parentName, scopeNodeId, source);
1357
+ const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
1358
+
1359
+ if (containerNodeId) {
1360
+ const containerNode = graph.nodes[containerNodeId];
1361
+
1362
+ // Validate container kind
1363
+ if (nodeKind === 'chapter' && containerNode.kind !== 'book') {
1364
+ graph.diagnostics.push({
1365
+ severity: 'error',
1366
+ message: `Chapter "${node.name}" must belong to a book, but "${parentName}" is a ${containerNode.kind}`,
1367
+ source,
1368
+ });
1369
+ continue;
1370
+ }
1371
+
1372
+ // Update the node's container
1373
+ node.container = containerNodeId;
1374
+
1375
+ // Move the node from its temporary parent to the correct container
1376
+ const oldParent = node.parent;
1377
+ if (oldParent && graph.nodes[oldParent]) {
1378
+ const oldParentChildren = graph.nodes[oldParent].children;
1379
+ const idx = oldParentChildren.indexOf(nodeId);
1380
+ if (idx !== -1) {
1381
+ oldParentChildren.splice(idx, 1);
1382
+ }
1383
+ }
1384
+
1385
+ // Add to correct container
1386
+ node.parent = containerNodeId;
1387
+ if (!containerNode.children.includes(nodeId)) {
1388
+ containerNode.children.push(nodeId);
1389
+ }
1390
+ } else {
1391
+ // Still not found - report error
1392
+ const kindLabel = nodeKind === 'chapter' ? 'book' : 'container';
1393
+ graph.diagnostics.push({
1394
+ severity: 'error',
1395
+ message: `Chapter "${node.name}" references unknown ${kindLabel} "${parentName}"`,
1396
+ source,
1397
+ });
1398
+ }
1399
+ }
1400
+ }
1401
+
1296
1402
  /**
1297
1403
  * @param {string | undefined} raw
1298
1404
  * @returns {string | undefined}
@@ -1372,9 +1478,16 @@ function resolveReferenceDecls(graph, pending, scopeIndex) {
1372
1478
  unknownRepoCounts.set(decl.repositoryName, entry);
1373
1479
  }
1374
1480
  } else {
1481
+ // Build error message based on whether reference has a name
1482
+ let message;
1483
+ if (decl.name && displayName !== 'unnamed reference') {
1484
+ message = `Reference "${displayName}" must have url { ... } or declared as reference ${displayName} in <Repository>`;
1485
+ } else {
1486
+ message = `Reference must have url { ... } or reference in <Repository>`;
1487
+ }
1375
1488
  graph.diagnostics.push({
1376
1489
  severity: 'error',
1377
- message: `Reference "${displayName}" must have url { ... } or in <Repository>`,
1490
+ message,
1378
1491
  source: diagnosticSource(decl.source),
1379
1492
  });
1380
1493
  }
package/src/ir.js CHANGED
@@ -69,6 +69,7 @@
69
69
  * @property {NodeId[]} [endpoints] - Endpoint node IDs (for relates nodes)
70
70
  * @property {string[]} [unresolvedEndpoints] - Unresolved endpoint names (for relates nodes)
71
71
  * @property {Record<string, FromView>} [from] - From blocks keyed by endpoint node ID (for relates nodes)
72
+ * @property {{ values: string[], source: SourceSpan }} [relationships] - Top-level relationships block (for relates nodes)
72
73
  */
73
74
 
74
75
  /**
package/src/parser.js CHANGED
@@ -449,7 +449,7 @@ class Parser {
449
449
  this.expect('KEYWORD', 'and');
450
450
  const bToken = this.expect('IDENTIFIER');
451
451
  const lbrace = this.expect('LBRACE');
452
- const body = this.parseBlockBody(['describe', 'title', 'from']);
452
+ const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
453
453
  const rbrace = this.expect('RBRACE');
454
454
 
455
455
  return {