@sprig-and-prose/sprig-universe 0.3.3 → 0.4.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.
package/src/graph.js CHANGED
@@ -22,11 +22,13 @@ import { normalizeProseBlock } from './util/text.js';
22
22
  * @typedef {import('./ast.js').ChapterDecl} ChapterDecl
23
23
  * @typedef {import('./ast.js').ConceptDecl} ConceptDecl
24
24
  * @typedef {import('./ast.js').RelatesDecl} RelatesDecl
25
+ * @typedef {import('./ast.js').RelationshipDecl} RelationshipDecl
25
26
  * @typedef {import('./ast.js').DescribeBlock} DescribeBlock
26
27
  * @typedef {import('./ast.js').TitleBlock} TitleBlock
27
28
  * @typedef {import('./ast.js').FromBlock} FromBlock
28
29
  * @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
29
30
  * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
31
+ * @typedef {import('./ast.js').OrderingBlock} OrderingBlock
30
32
  * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
31
33
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
32
34
  * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
@@ -65,6 +67,15 @@ export function buildGraph(fileASTs) {
65
67
  // Track repositories and references per universe for scoped resolution
66
68
  const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
67
69
  const referencesByUniverse = new Map(); // universeName -> Map<id, ReferenceDecl>
70
+
71
+ // Track relationship declarations per scope (containerNodeId -> Map<relationshipId, RelationshipDecl>)
72
+ const relationshipsByScope = new Map(); // containerNodeId -> Map<relationshipId, RelationshipDecl>
73
+
74
+ // Store relationship declarations by universe for UI access
75
+ const relationshipDeclsByUniverse = new Map(); // universeName -> Map<relationshipId, RelationshipDecl>
76
+
77
+ // Initialize relationshipDecls in graph
78
+ graph.relationshipDecls = {};
68
79
 
69
80
  // Track entity kinds for duplicate detection (references/repositories)
70
81
  const entityKinds = new Map(); // id -> kind
@@ -189,6 +200,8 @@ export function buildGraph(fileASTs) {
189
200
  repositoriesByUniverse.get(universeName),
190
201
  referencesByUniverse.get(universeName),
191
202
  entityKinds,
203
+ relationshipsByScope,
204
+ relationshipDeclsByUniverse,
192
205
  pendingReferenceDecls,
193
206
  pendingReferenceAttachments,
194
207
  pendingContainerResolutions,
@@ -219,6 +232,8 @@ export function buildGraph(fileASTs) {
219
232
  repositoriesByUniverse.get(universeName),
220
233
  referencesByUniverse.get(universeName),
221
234
  entityKinds,
235
+ relationshipsByScope,
236
+ relationshipDeclsByUniverse,
222
237
  pendingReferenceDecls,
223
238
  pendingReferenceAttachments,
224
239
  pendingContainerResolutions,
@@ -255,6 +270,7 @@ export function buildGraph(fileASTs) {
255
270
  continue;
256
271
  }
257
272
  }
273
+ // Process the declaration itself first
258
274
  processBody(
259
275
  graph,
260
276
  universeName,
@@ -267,6 +283,8 @@ export function buildGraph(fileASTs) {
267
283
  repositoriesByUniverse.get(universeName),
268
284
  referencesByUniverse.get(universeName),
269
285
  entityKinds,
286
+ relationshipsByScope,
287
+ relationshipDeclsByUniverse,
270
288
  pendingReferenceDecls,
271
289
  pendingReferenceAttachments,
272
290
  pendingContainerResolutions,
@@ -294,6 +312,37 @@ export function buildGraph(fileASTs) {
294
312
  // Resolve container references (books/chapters) that may have forward references
295
313
  resolveContainers(graph, scopeIndex, pendingContainerResolutions);
296
314
 
315
+ // Validate and apply ordering blocks after all nodes and relationships are established
316
+ validateOrderingBlocks(graph);
317
+
318
+ // Extract asserted edges from both relationships blocks and relates nodes
319
+ graph.edgesAsserted = extractAssertedEdges(graph, scopeIndex);
320
+
321
+ // Preserve existing edges object format for relates (for code that still uses it)
322
+ graph.edgesByRelates = graph.edges;
323
+
324
+ // Normalize edges (add inverse edges for paired/symmetric relationships)
325
+ const universeNames = Array.from(universeNameToFiles.keys());
326
+ if (universeNames.length > 0) {
327
+ // For now, assume single universe (as per buildGraph contract)
328
+ const universeName = universeNames[0];
329
+ graph.edges = normalizeEdges(
330
+ graph.edgesAsserted,
331
+ relationshipDeclsByUniverse,
332
+ universeName,
333
+ );
334
+ } else {
335
+ graph.edges = [];
336
+ }
337
+
338
+ // Convert relationshipDeclsByUniverse Maps to plain objects for JSON serialization
339
+ // (already done above, but ensure all universes are initialized)
340
+ for (const [universeName, declsMap] of relationshipDeclsByUniverse.entries()) {
341
+ if (!graph.relationshipDecls[universeName]) {
342
+ graph.relationshipDecls[universeName] = {};
343
+ }
344
+ }
345
+
297
346
  return graph;
298
347
  }
299
348
 
@@ -346,7 +395,7 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
346
395
  */
347
396
  function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
348
397
  // Handle qualified names (dot notation)
349
- if (name.includes('.')) {
398
+ if (name && name.includes('.')) {
350
399
  const parts = name.split('.');
351
400
  // Start from universe and resolve each part
352
401
  const universeName = startScopeNodeId.split(':')[0];
@@ -502,6 +551,8 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
502
551
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
503
552
  * @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
504
553
  * @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
554
+ * @param {Map<string, Map<string, RelationshipDecl>>} relationshipsByScope - Map for tracking relationship declarations per scope (required)
555
+ * @param {Map<string, Map<string, RelationshipDecl>>} relationshipDeclsByUniverse - Map for storing relationship declarations by universe for UI access
505
556
  * @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
506
557
  * @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
507
558
  * @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
@@ -518,12 +569,97 @@ function processBody(
518
569
  reposMap,
519
570
  refsMap,
520
571
  entityKinds,
572
+ relationshipsByScope,
573
+ relationshipDeclsByUniverse,
521
574
  pendingReferenceDecls,
522
575
  pendingReferenceAttachments,
523
576
  pendingContainerResolutions,
524
577
  ) {
578
+ // Collect ordering blocks to process after all children are added
579
+ const orderingBlocks = [];
580
+
581
+ // Ensure relationshipsByScope is initialized as a Map
582
+ // This should always be provided, but check to prevent runtime errors
583
+ if (!relationshipsByScope) {
584
+ throw new Error(`relationshipsByScope is required but was ${relationshipsByScope}`);
585
+ }
586
+ if (typeof relationshipsByScope.has !== 'function' || typeof relationshipsByScope.set !== 'function' || typeof relationshipsByScope.get !== 'function') {
587
+ throw new Error(`relationshipsByScope must be a Map, got ${typeof relationshipsByScope} with has: ${typeof relationshipsByScope.has}, set: ${typeof relationshipsByScope.set}, get: ${typeof relationshipsByScope.get}`);
588
+ }
589
+
590
+ // Initialize relationship scope for current container if not exists
591
+ if (!relationshipsByScope.has(currentNodeId)) {
592
+ relationshipsByScope.set(currentNodeId, new Map());
593
+ }
594
+ // Inherit parent relationships
595
+ if (parentNodeId && relationshipsByScope.has(parentNodeId)) {
596
+ const parentRelationships = relationshipsByScope.get(parentNodeId);
597
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
598
+ if (parentRelationships && currentRelationships) {
599
+ for (const [id, rel] of parentRelationships) {
600
+ if (!currentRelationships.has(id)) {
601
+ currentRelationships.set(id, rel);
602
+ }
603
+ }
604
+ }
605
+ }
606
+
525
607
  for (const decl of body) {
526
- if (decl.kind === 'anthology') {
608
+ if (decl.kind === 'relationship' && relationshipsByScope) {
609
+ // Track relationship declaration in current scope
610
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
611
+ if (currentRelationships) {
612
+ if (decl.type === 'symmetric' && decl.id) {
613
+ currentRelationships.set(decl.id, decl);
614
+ // Also store in universe-level map for UI access
615
+ if (!relationshipDeclsByUniverse.has(universeName)) {
616
+ relationshipDeclsByUniverse.set(universeName, new Map());
617
+ graph.relationshipDecls[universeName] = {};
618
+ }
619
+ const universeDecls = relationshipDeclsByUniverse.get(universeName);
620
+ if (universeDecls) {
621
+ universeDecls.set(decl.id, {
622
+ type: decl.type,
623
+ id: decl.id,
624
+ describe: decl.describe,
625
+ label: decl.label,
626
+ source: decl.source,
627
+ });
628
+ graph.relationshipDecls[universeName][decl.id] = {
629
+ type: decl.type,
630
+ id: decl.id,
631
+ describe: decl.describe,
632
+ label: decl.label,
633
+ source: decl.source,
634
+ };
635
+ }
636
+ } else if (decl.type === 'paired') {
637
+ if (decl.leftId) currentRelationships.set(decl.leftId, decl);
638
+ if (decl.rightId) currentRelationships.set(decl.rightId, decl);
639
+ // Also store in universe-level map for UI access (store once per pair)
640
+ if (!relationshipDeclsByUniverse.has(universeName)) {
641
+ relationshipDeclsByUniverse.set(universeName, new Map());
642
+ graph.relationshipDecls[universeName] = {};
643
+ }
644
+ const universeDecls = relationshipDeclsByUniverse.get(universeName);
645
+ if (universeDecls && decl.leftId && decl.rightId) {
646
+ // Store under both IDs for easy lookup
647
+ const declModel = {
648
+ type: decl.type,
649
+ leftId: decl.leftId,
650
+ rightId: decl.rightId,
651
+ describe: decl.describe,
652
+ from: decl.from,
653
+ source: decl.source,
654
+ };
655
+ universeDecls.set(decl.leftId, declModel);
656
+ universeDecls.set(decl.rightId, declModel);
657
+ graph.relationshipDecls[universeName][decl.leftId] = declModel;
658
+ graph.relationshipDecls[universeName][decl.rightId] = declModel;
659
+ }
660
+ }
661
+ }
662
+ } else if (decl.kind === 'anthology') {
527
663
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
528
664
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
529
665
  let actualParentNodeId = parentNodeId;
@@ -549,6 +685,8 @@ function processBody(
549
685
  reposMap,
550
686
  refsMap,
551
687
  entityKinds,
688
+ relationshipsByScope,
689
+ relationshipDeclsByUniverse,
552
690
  pendingReferenceDecls,
553
691
  pendingReferenceAttachments,
554
692
  pendingContainerResolutions,
@@ -583,6 +721,8 @@ function processBody(
583
721
  reposMap,
584
722
  refsMap,
585
723
  entityKinds,
724
+ relationshipsByScope,
725
+ relationshipDeclsByUniverse,
586
726
  pendingReferenceDecls,
587
727
  pendingReferenceAttachments,
588
728
  pendingContainerResolutions,
@@ -626,6 +766,8 @@ function processBody(
626
766
  reposMap,
627
767
  refsMap,
628
768
  entityKinds,
769
+ relationshipsByScope,
770
+ relationshipDeclsByUniverse,
629
771
  pendingReferenceDecls,
630
772
  pendingReferenceAttachments,
631
773
  pendingContainerResolutions,
@@ -698,6 +840,8 @@ function processBody(
698
840
  reposMap,
699
841
  refsMap,
700
842
  entityKinds,
843
+ relationshipsByScope,
844
+ relationshipDeclsByUniverse,
701
845
  pendingReferenceDecls,
702
846
  pendingReferenceAttachments,
703
847
  pendingContainerResolutions,
@@ -720,6 +864,27 @@ function processBody(
720
864
  graph.nodes[nodeId] = node;
721
865
  graph.nodes[actualParentNodeId].children.push(nodeId);
722
866
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
867
+
868
+ // Validate relationships blocks in concept body
869
+ const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
870
+ for (const relBlock of relationshipsBlocks) {
871
+ if (relBlock.entries && relationshipsByScope) {
872
+ // New syntax: validate relationship IDs
873
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
874
+ if (currentRelationships) {
875
+ for (const entry of relBlock.entries) {
876
+ if (!currentRelationships.has(entry.relationshipId)) {
877
+ graph.diagnostics.push({
878
+ severity: 'error',
879
+ message: `Undeclared relationship identifier "${entry.relationshipId}" in relationships block`,
880
+ source: relBlock.source,
881
+ });
882
+ }
883
+ }
884
+ }
885
+ }
886
+ }
887
+
723
888
  processBody(
724
889
  graph,
725
890
  universeName,
@@ -732,8 +897,10 @@ function processBody(
732
897
  reposMap,
733
898
  refsMap,
734
899
  entityKinds,
900
+ relationshipsByScope,
735
901
  pendingReferenceDecls,
736
902
  pendingReferenceAttachments,
903
+ pendingContainerResolutions,
737
904
  );
738
905
  } else if (decl.kind === 'relates') {
739
906
  // Check for duplicate relates in reverse order
@@ -818,6 +985,8 @@ function processBody(
818
985
  source: decl.source,
819
986
  endpoints: [], // Will be populated during resolution
820
987
  unresolvedEndpoints: [decl.a, decl.b], // Will be cleared during resolution
988
+ spelledKind: decl.spelledKind,
989
+ aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
821
990
  };
822
991
 
823
992
  // Add top-level describe if present
@@ -944,6 +1113,9 @@ function processBody(
944
1113
  } else if (decl.kind === 'title') {
945
1114
  // Title blocks are attached to their parent node
946
1115
  // This is handled in createNode
1116
+ } else if (decl.kind === 'relationships') {
1117
+ // Relationships blocks are attached to their parent node
1118
+ // This is handled in createNode
947
1119
  } else if (decl.kind === 'references') {
948
1120
  if (pendingReferenceAttachments) {
949
1121
  pendingReferenceAttachments.push({
@@ -952,6 +1124,9 @@ function processBody(
952
1124
  universeName,
953
1125
  });
954
1126
  }
1127
+ } else if (decl.kind === 'ordering') {
1128
+ // Collect ordering blocks to process after all children are added
1129
+ orderingBlocks.push(decl);
955
1130
  } else if (decl.kind === 'documentation') {
956
1131
  // DocumentationBlock - attach to current node
957
1132
  const currentNode = graph.nodes[currentNodeId];
@@ -1102,19 +1277,52 @@ function processBody(
1102
1277
  graph.documentsByName[universeName][docName] = docModel;
1103
1278
  }
1104
1279
  }
1280
+ } else if (decl.kind === 'relationship') {
1281
+ // Relationship declarations are tracked in relationshipsByScope but don't create nodes
1282
+ // They're just declarations that can be referenced in relationships blocks
1105
1283
  } else {
1106
1284
  // UnknownBlock - attach to current node (the node whose body contains this block)
1107
- const currentNode = graph.nodes[currentNodeId];
1108
- if (!currentNode.unknownBlocks) {
1109
- currentNode.unknownBlocks = [];
1285
+ // Only process if it's actually an UnknownBlock (has keyword property, not kind)
1286
+ // Blocks with 'kind' property are known block types that should be handled above
1287
+ if (decl.keyword && !decl.kind) {
1288
+ const currentNode = graph.nodes[currentNodeId];
1289
+ if (!currentNode.unknownBlocks) {
1290
+ currentNode.unknownBlocks = [];
1291
+ }
1292
+ currentNode.unknownBlocks.push({
1293
+ keyword: decl.keyword,
1294
+ raw: decl.raw,
1295
+ normalized: normalizeProseBlock(decl.raw),
1296
+ source: decl.source,
1297
+ });
1110
1298
  }
1111
- currentNode.unknownBlocks.push({
1112
- keyword: decl.keyword,
1113
- raw: decl.raw,
1114
- normalized: normalizeProseBlock(decl.raw),
1115
- source: decl.source,
1299
+ // If it has a 'kind' property, it's a known block type that should have been handled above
1300
+ // Skip it silently (it may have been handled elsewhere, e.g., in createNode)
1301
+ }
1302
+ }
1303
+
1304
+ // Store ordering blocks for later validation (after all nodes are created)
1305
+ // We'll validate them in a separate pass after the entire graph is built
1306
+ // This is necessary because children may be defined outside their parent's body
1307
+ // (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
1308
+ if (orderingBlocks.length > 0) {
1309
+ const currentNode = graph.nodes[currentNodeId];
1310
+ if (!currentNode) {
1311
+ return;
1312
+ }
1313
+
1314
+ // Validate: only one ordering block per container
1315
+ if (orderingBlocks.length > 1) {
1316
+ graph.diagnostics.push({
1317
+ severity: 'warning',
1318
+ message: `Multiple ordering blocks in ${currentNode.kind} "${currentNode.name}". Using the first one.`,
1319
+ source: orderingBlocks[1].source,
1116
1320
  });
1117
1321
  }
1322
+
1323
+ // Store ordering block temporarily on the node for later validation
1324
+ // We'll process this in validateOrderingBlocks() after all nodes are created
1325
+ currentNode._pendingOrdering = orderingBlocks[0];
1118
1326
  }
1119
1327
  }
1120
1328
 
@@ -1136,6 +1344,8 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1136
1344
  parent: parentNodeId,
1137
1345
  children: [],
1138
1346
  source: decl.source,
1347
+ spelledKind: decl.spelledKind,
1348
+ aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
1139
1349
  };
1140
1350
 
1141
1351
  // Always set container for book/chapter nodes (may be undefined if not resolved)
@@ -1166,6 +1376,34 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1166
1376
  }
1167
1377
  }
1168
1378
 
1379
+ // Extract relationships block if present
1380
+ const relationshipsBlock = decl.body?.find((b) => b.kind === 'relationships');
1381
+ if (relationshipsBlock) {
1382
+ if (relationshipsBlock.entries) {
1383
+ // New syntax: relationship ID + targets
1384
+ node.relationships = {
1385
+ entries: relationshipsBlock.entries.map((entry) => ({
1386
+ relationshipId: entry.relationshipId,
1387
+ targets: entry.targets.map((target) => ({
1388
+ id: target.id,
1389
+ metadata: target.metadata ? {
1390
+ raw: target.metadata.raw,
1391
+ normalized: normalizeProseBlock(target.metadata.raw),
1392
+ source: target.metadata.source,
1393
+ } : undefined,
1394
+ })),
1395
+ })),
1396
+ source: relationshipsBlock.source,
1397
+ };
1398
+ } else if (relationshipsBlock.values) {
1399
+ // String literals syntax (for relates blocks)
1400
+ node.relationships = {
1401
+ values: relationshipsBlock.values,
1402
+ source: relationshipsBlock.source,
1403
+ };
1404
+ }
1405
+ }
1406
+
1169
1407
  // Note: UnknownBlocks are handled in processBody and attached to the parent node
1170
1408
  // They are not extracted here to avoid duplication
1171
1409
 
@@ -1243,7 +1481,7 @@ function resolveEdges(graph, scopeIndex) {
1243
1481
  }
1244
1482
  }
1245
1483
 
1246
- // Resolve edges (for backward compatibility)
1484
+ // Resolve edges (relates edges in object format)
1247
1485
  for (const edgeId in graph.edges) {
1248
1486
  const edge = graph.edges[edgeId];
1249
1487
  const universeName = edgeId.split(':')[0];
@@ -1399,6 +1637,161 @@ function resolveContainers(graph, scopeIndex, pending) {
1399
1637
  }
1400
1638
  }
1401
1639
 
1640
+ /**
1641
+ * Extracts asserted edges from both relationships {} blocks and relates nodes
1642
+ * @param {UniverseGraph} graph - The graph
1643
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
1644
+ * @returns {import('./ir.js').EdgeAssertedModel[]}
1645
+ */
1646
+ function extractAssertedEdges(graph, scopeIndex) {
1647
+ /** @type {import('./ir.js').EdgeAssertedModel[]} */
1648
+ const assertedEdges = [];
1649
+
1650
+ // Extract from relationships {} blocks (new-style adjacency lists)
1651
+ for (const nodeId in graph.nodes) {
1652
+ const node = graph.nodes[nodeId];
1653
+ if (!node.relationships || !node.relationships.entries) continue;
1654
+
1655
+ // Resolve each target and create asserted edge
1656
+ for (const entry of node.relationships.entries) {
1657
+ for (const target of entry.targets) {
1658
+ // Resolve target ID using scope resolution
1659
+ const resolved = resolveNameInScope(
1660
+ graph,
1661
+ scopeIndex,
1662
+ target.id,
1663
+ nodeId,
1664
+ node.relationships.source,
1665
+ );
1666
+ const targetNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
1667
+ if (!targetNodeId) continue;
1668
+
1669
+ assertedEdges.push({
1670
+ from: nodeId,
1671
+ via: entry.relationshipId,
1672
+ to: targetNodeId,
1673
+ meta: target.metadata,
1674
+ source: node.relationships.source,
1675
+ });
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ // Extract from relates nodes (bidirectional relationships)
1681
+ for (const nodeId in graph.nodes) {
1682
+ const node = graph.nodes[nodeId];
1683
+ if (node.kind !== 'relates' || !node.endpoints || node.endpoints.length !== 2) continue;
1684
+
1685
+ const [endpointA, endpointB] = node.endpoints;
1686
+
1687
+ // Relates are bidirectional - extract both directions as asserted edges
1688
+ // Use the relationship label from the relates node if available
1689
+ const viaLabel = node.relationships?.values?.[0] || 'related to';
1690
+
1691
+ // Create edge A -> B
1692
+ assertedEdges.push({
1693
+ from: endpointA,
1694
+ via: viaLabel, // Note: relates don't use relationship declarations, they use string labels
1695
+ to: endpointB,
1696
+ meta: node.from?.[endpointA]?.describe,
1697
+ source: node.source,
1698
+ });
1699
+
1700
+ // Create edge B -> A (relates are bidirectional)
1701
+ assertedEdges.push({
1702
+ from: endpointB,
1703
+ via: viaLabel,
1704
+ to: endpointA,
1705
+ meta: node.from?.[endpointB]?.describe,
1706
+ source: node.source,
1707
+ });
1708
+ }
1709
+
1710
+ return assertedEdges;
1711
+ }
1712
+
1713
+ /**
1714
+ * Normalizes edges by adding inverse edges for paired/symmetric relationships
1715
+ * @param {import('./ir.js').EdgeAssertedModel[]} assertedEdges - Asserted edges
1716
+ * @param {Map<string, Map<string, import('./ast.js').RelationshipDecl>>} relationshipDecls - Relationship declarations by universe
1717
+ * @param {string} universeName - Universe name
1718
+ * @returns {import('./ir.js').NormalizedEdgeModel[]}
1719
+ */
1720
+ function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
1721
+ /** @type {import('./ir.js').NormalizedEdgeModel[]} */
1722
+ const normalizedEdges = [];
1723
+ const relDeclsMap = relationshipDecls.get(universeName);
1724
+ const relDecls = relDeclsMap ? Object.fromEntries(relDeclsMap) : {};
1725
+ const seenEdges = new Set(); // Track edges to avoid duplicates
1726
+
1727
+ for (const asserted of assertedEdges) {
1728
+ const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
1729
+
1730
+ // Skip if we've already added this exact edge (can happen with relates bidirectional edges)
1731
+ if (seenEdges.has(edgeKey)) continue;
1732
+ seenEdges.add(edgeKey);
1733
+
1734
+ // Get relationship declaration if this is a declared relationship
1735
+ // Note: relates edges use string labels, not relationship IDs, so they won't have a relDecl
1736
+ const relDecl = relDecls[asserted.via];
1737
+
1738
+ // Add asserted edge
1739
+ normalizedEdges.push({
1740
+ from: asserted.from,
1741
+ via: asserted.via,
1742
+ to: asserted.to,
1743
+ asserted: true,
1744
+ sourceRefs: [asserted.source],
1745
+ meta: asserted.meta,
1746
+ });
1747
+
1748
+ // Add inverse edge if applicable (only for declared relationships, not relates)
1749
+ // Relates are already bidirectional, so both directions are in assertedEdges
1750
+ if (relDecl) {
1751
+ if (relDecl.type === 'paired') {
1752
+ // Determine inverse side
1753
+ const inverseVia =
1754
+ asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
1755
+
1756
+ if (inverseVia) {
1757
+ const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
1758
+ // Only add inverse if not already present as an asserted edge
1759
+ if (!seenEdges.has(inverseKey)) {
1760
+ normalizedEdges.push({
1761
+ from: asserted.to,
1762
+ via: inverseVia,
1763
+ to: asserted.from,
1764
+ asserted: false,
1765
+ sourceRefs: [asserted.source], // Points back to asserted edge
1766
+ meta: asserted.meta, // Copy metadata
1767
+ });
1768
+ seenEdges.add(inverseKey);
1769
+ }
1770
+ }
1771
+ } else if (relDecl.type === 'symmetric') {
1772
+ // Symmetric: add reverse edge with same relationship ID
1773
+ const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
1774
+ // Only add reverse if not already present as an asserted edge
1775
+ if (!seenEdges.has(reverseKey)) {
1776
+ normalizedEdges.push({
1777
+ from: asserted.to,
1778
+ via: asserted.via,
1779
+ to: asserted.from,
1780
+ asserted: false,
1781
+ sourceRefs: [asserted.source],
1782
+ meta: asserted.meta,
1783
+ });
1784
+ seenEdges.add(reverseKey);
1785
+ }
1786
+ }
1787
+ }
1788
+ // Note: If no relDecl found, it's likely a relates edge (string label, not declared relationship)
1789
+ // Relates edges are already bidirectional in assertedEdges, so no inverse needed
1790
+ }
1791
+
1792
+ return normalizedEdges;
1793
+ }
1794
+
1402
1795
  /**
1403
1796
  * @param {string | undefined} raw
1404
1797
  * @returns {string | undefined}
@@ -1746,6 +2139,56 @@ function makeRelatesNodeId(universeName, a, b, index = 0) {
1746
2139
  return `${universeName}:relates:${a}:${b}:${index}`;
1747
2140
  }
1748
2141
 
2142
+ /**
2143
+ * Validates and applies ordering blocks after all nodes and relationships are established
2144
+ * This must run after all nodes are created because children may be defined outside their parent's body
2145
+ * (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
2146
+ * @param {UniverseGraph} graph
2147
+ */
2148
+ function validateOrderingBlocks(graph) {
2149
+ for (const nodeId in graph.nodes) {
2150
+ const node = graph.nodes[nodeId];
2151
+ if (!node._pendingOrdering) {
2152
+ continue;
2153
+ }
2154
+
2155
+ const orderingBlock = node._pendingOrdering;
2156
+ const orderingIdentifiers = orderingBlock.identifiers;
2157
+
2158
+ // Build a map of child names to node IDs for validation
2159
+ const childNameToNodeId = new Map();
2160
+ for (const childId of node.children) {
2161
+ const childNode = graph.nodes[childId];
2162
+ if (childNode) {
2163
+ childNameToNodeId.set(childNode.name, childId);
2164
+ }
2165
+ }
2166
+
2167
+ // Validate ordering identifiers against actual children
2168
+ const validOrdering = [];
2169
+ for (const identifier of orderingIdentifiers) {
2170
+ if (childNameToNodeId.has(identifier)) {
2171
+ validOrdering.push(identifier);
2172
+ } else {
2173
+ // Unknown identifier - emit gentle warning
2174
+ graph.diagnostics.push({
2175
+ severity: 'warning',
2176
+ message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
2177
+ source: orderingBlock.source,
2178
+ });
2179
+ }
2180
+ }
2181
+
2182
+ // Store ordering if there are any valid identifiers
2183
+ if (validOrdering.length > 0) {
2184
+ node.ordering = validOrdering;
2185
+ }
2186
+
2187
+ // Clean up temporary field
2188
+ delete node._pendingOrdering;
2189
+ }
2190
+ }
2191
+
1749
2192
  /**
1750
2193
  * Creates an edge ID
1751
2194
  * @param {string} universeName