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

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,6 +22,7 @@ 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
@@ -49,7 +50,7 @@ export function buildGraph(fileASTs) {
49
50
  version: 1,
50
51
  universes: {},
51
52
  nodes: {},
52
- edges: {},
53
+ edges: [],
53
54
  diagnostics: [],
54
55
  documentsByName: {},
55
56
  repositories: {},
@@ -66,6 +67,15 @@ export function buildGraph(fileASTs) {
66
67
  // Track repositories and references per universe for scoped resolution
67
68
  const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
68
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 = {};
69
79
 
70
80
  // Track entity kinds for duplicate detection (references/repositories)
71
81
  const entityKinds = new Map(); // id -> kind
@@ -190,6 +200,8 @@ export function buildGraph(fileASTs) {
190
200
  repositoriesByUniverse.get(universeName),
191
201
  referencesByUniverse.get(universeName),
192
202
  entityKinds,
203
+ relationshipsByScope,
204
+ relationshipDeclsByUniverse,
193
205
  pendingReferenceDecls,
194
206
  pendingReferenceAttachments,
195
207
  pendingContainerResolutions,
@@ -220,6 +232,8 @@ export function buildGraph(fileASTs) {
220
232
  repositoriesByUniverse.get(universeName),
221
233
  referencesByUniverse.get(universeName),
222
234
  entityKinds,
235
+ relationshipsByScope,
236
+ relationshipDeclsByUniverse,
223
237
  pendingReferenceDecls,
224
238
  pendingReferenceAttachments,
225
239
  pendingContainerResolutions,
@@ -256,6 +270,7 @@ export function buildGraph(fileASTs) {
256
270
  continue;
257
271
  }
258
272
  }
273
+ // Process the declaration itself first
259
274
  processBody(
260
275
  graph,
261
276
  universeName,
@@ -268,6 +283,8 @@ export function buildGraph(fileASTs) {
268
283
  repositoriesByUniverse.get(universeName),
269
284
  referencesByUniverse.get(universeName),
270
285
  entityKinds,
286
+ relationshipsByScope,
287
+ relationshipDeclsByUniverse,
271
288
  pendingReferenceDecls,
272
289
  pendingReferenceAttachments,
273
290
  pendingContainerResolutions,
@@ -298,6 +315,31 @@ export function buildGraph(fileASTs) {
298
315
  // Validate and apply ordering blocks after all nodes and relationships are established
299
316
  validateOrderingBlocks(graph);
300
317
 
318
+ // Extract asserted edges from both relationships blocks and relates nodes
319
+ graph.edgesAsserted = extractAssertedEdges(graph, scopeIndex);
320
+
321
+ // Normalize edges (add inverse edges for paired/symmetric relationships)
322
+ const universeNames = Array.from(universeNameToFiles.keys());
323
+ if (universeNames.length > 0) {
324
+ // For now, assume single universe (as per buildGraph contract)
325
+ const universeName = universeNames[0];
326
+ graph.edges = normalizeEdges(
327
+ graph.edgesAsserted,
328
+ relationshipDeclsByUniverse,
329
+ universeName,
330
+ );
331
+ } else {
332
+ graph.edges = [];
333
+ }
334
+
335
+ // Convert relationshipDeclsByUniverse Maps to plain objects for JSON serialization
336
+ // (already done above, but ensure all universes are initialized)
337
+ for (const [universeName, declsMap] of relationshipDeclsByUniverse.entries()) {
338
+ if (!graph.relationshipDecls[universeName]) {
339
+ graph.relationshipDecls[universeName] = {};
340
+ }
341
+ }
342
+
301
343
  return graph;
302
344
  }
303
345
 
@@ -337,6 +379,46 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
337
379
  }
338
380
  }
339
381
 
382
+ /**
383
+ * Finds the series containing a given node by walking up the parent chain
384
+ * @param {UniverseGraph} graph - The graph
385
+ * @param {string} nodeId - Node ID to find series for
386
+ * @returns {string | null} - Series node ID, or null if not found
387
+ */
388
+ function findSeriesForNode(graph, nodeId) {
389
+ const node = graph.nodes[nodeId];
390
+ if (!node) return null;
391
+
392
+ let checkNode = node;
393
+ while (checkNode && checkNode.parent) {
394
+ checkNode = graph.nodes[checkNode.parent];
395
+ if (checkNode && checkNode.kind === 'series') {
396
+ return checkNode.id;
397
+ }
398
+ }
399
+ return null;
400
+ }
401
+
402
+ /**
403
+ * Prefers a candidate from the same series as the starting node when multiple candidates exist
404
+ * @param {UniverseGraph} graph - The graph
405
+ * @param {string} startScopeNodeId - Starting node ID
406
+ * @param {string[]} candidates - Array of candidate node IDs
407
+ * @returns {string | null} - Preferred candidate node ID, or null if none found
408
+ */
409
+ function preferSameSeriesCandidate(graph, startScopeNodeId, candidates) {
410
+ const seriesNodeId = findSeriesForNode(graph, startScopeNodeId);
411
+ if (!seriesNodeId) return null;
412
+
413
+ for (const candidateId of candidates) {
414
+ const candidateSeriesNodeId = findSeriesForNode(graph, candidateId);
415
+ if (candidateSeriesNodeId === seriesNodeId) {
416
+ return candidateId;
417
+ }
418
+ }
419
+ return null;
420
+ }
421
+
340
422
  /**
341
423
  * Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
342
424
  * Returns the first matching node ID found, or null if not found
@@ -350,7 +432,7 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
350
432
  */
351
433
  function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
352
434
  // Handle qualified names (dot notation)
353
- if (name.includes('.')) {
435
+ if (name && name.includes('.')) {
354
436
  const parts = name.split('.');
355
437
  // Start from universe and resolve each part
356
438
  const universeName = startScopeNodeId.split(':')[0];
@@ -400,7 +482,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
400
482
  } else if (candidates.length === 1) {
401
483
  return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
402
484
  } else {
403
- // Ambiguity at this scope level
485
+ // Ambiguity at this scope level - try to disambiguate by preferring same series
486
+ const preferredCandidate = preferSameSeriesCandidate(graph, startScopeNodeId, candidates);
487
+
488
+ if (preferredCandidate) {
489
+ return { nodeId: preferredCandidate, ambiguous: false, ambiguousNodes: [] };
490
+ }
491
+
492
+ // No preferred candidate found - report ambiguity
404
493
  if (source) {
405
494
  const scopeNode = graph.nodes[currentScope];
406
495
  const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
@@ -430,6 +519,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
430
519
  if (candidates.length === 1) {
431
520
  return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
432
521
  } else if (candidates.length > 1) {
522
+ // Multiple matches at universe scope - try to disambiguate by preferring same series
523
+ const preferredCandidate = preferSameSeriesCandidate(graph, startScopeNodeId, candidates);
524
+
525
+ if (preferredCandidate) {
526
+ return { nodeId: preferredCandidate, ambiguous: false, ambiguousNodes: [] };
527
+ }
528
+
529
+ // No preferred candidate found - report ambiguity
433
530
  if (source) {
434
531
  graph.diagnostics.push({
435
532
  severity: 'error',
@@ -506,6 +603,8 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
506
603
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
507
604
  * @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
508
605
  * @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
606
+ * @param {Map<string, Map<string, RelationshipDecl>>} relationshipsByScope - Map for tracking relationship declarations per scope (required)
607
+ * @param {Map<string, Map<string, RelationshipDecl>>} relationshipDeclsByUniverse - Map for storing relationship declarations by universe for UI access
509
608
  * @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
510
609
  * @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
511
610
  * @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
@@ -522,6 +621,8 @@ function processBody(
522
621
  reposMap,
523
622
  refsMap,
524
623
  entityKinds,
624
+ relationshipsByScope,
625
+ relationshipDeclsByUniverse,
525
626
  pendingReferenceDecls,
526
627
  pendingReferenceAttachments,
527
628
  pendingContainerResolutions,
@@ -529,8 +630,88 @@ function processBody(
529
630
  // Collect ordering blocks to process after all children are added
530
631
  const orderingBlocks = [];
531
632
 
633
+ // Ensure relationshipsByScope is initialized as a Map
634
+ // This should always be provided, but check to prevent runtime errors
635
+ if (!relationshipsByScope) {
636
+ throw new Error(`relationshipsByScope is required but was ${relationshipsByScope}`);
637
+ }
638
+ if (typeof relationshipsByScope.has !== 'function' || typeof relationshipsByScope.set !== 'function' || typeof relationshipsByScope.get !== 'function') {
639
+ throw new Error(`relationshipsByScope must be a Map, got ${typeof relationshipsByScope} with has: ${typeof relationshipsByScope.has}, set: ${typeof relationshipsByScope.set}, get: ${typeof relationshipsByScope.get}`);
640
+ }
641
+
642
+ // Initialize relationship scope for current container if not exists
643
+ if (!relationshipsByScope.has(currentNodeId)) {
644
+ relationshipsByScope.set(currentNodeId, new Map());
645
+ }
646
+ // Inherit parent relationships
647
+ if (parentNodeId && relationshipsByScope.has(parentNodeId)) {
648
+ const parentRelationships = relationshipsByScope.get(parentNodeId);
649
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
650
+ if (parentRelationships && currentRelationships) {
651
+ for (const [id, rel] of parentRelationships) {
652
+ if (!currentRelationships.has(id)) {
653
+ currentRelationships.set(id, rel);
654
+ }
655
+ }
656
+ }
657
+ }
658
+
532
659
  for (const decl of body) {
533
- if (decl.kind === 'anthology') {
660
+ if (decl.kind === 'relationship' && relationshipsByScope) {
661
+ // Track relationship declaration in current scope
662
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
663
+ if (currentRelationships) {
664
+ if (decl.type === 'symmetric' && decl.id) {
665
+ currentRelationships.set(decl.id, decl);
666
+ // Also store in universe-level map for UI access
667
+ if (!relationshipDeclsByUniverse.has(universeName)) {
668
+ relationshipDeclsByUniverse.set(universeName, new Map());
669
+ graph.relationshipDecls[universeName] = {};
670
+ }
671
+ const universeDecls = relationshipDeclsByUniverse.get(universeName);
672
+ if (universeDecls) {
673
+ universeDecls.set(decl.id, {
674
+ type: decl.type,
675
+ id: decl.id,
676
+ describe: decl.describe,
677
+ label: decl.label,
678
+ source: decl.source,
679
+ });
680
+ graph.relationshipDecls[universeName][decl.id] = {
681
+ type: decl.type,
682
+ id: decl.id,
683
+ describe: decl.describe,
684
+ label: decl.label,
685
+ source: decl.source,
686
+ };
687
+ }
688
+ } else if (decl.type === 'paired') {
689
+ if (decl.leftId) currentRelationships.set(decl.leftId, decl);
690
+ if (decl.rightId) currentRelationships.set(decl.rightId, decl);
691
+ // Also store in universe-level map for UI access (store once per pair)
692
+ if (!relationshipDeclsByUniverse.has(universeName)) {
693
+ relationshipDeclsByUniverse.set(universeName, new Map());
694
+ graph.relationshipDecls[universeName] = {};
695
+ }
696
+ const universeDecls = relationshipDeclsByUniverse.get(universeName);
697
+ if (universeDecls && decl.leftId && decl.rightId) {
698
+ // Store under both IDs for easy lookup
699
+ const declModel = {
700
+ type: decl.type,
701
+ leftId: decl.leftId,
702
+ rightId: decl.rightId,
703
+ describe: decl.describe,
704
+ from: decl.from,
705
+ source: decl.source,
706
+ };
707
+ universeDecls.set(decl.leftId, declModel);
708
+ universeDecls.set(decl.rightId, declModel);
709
+ graph.relationshipDecls[universeName][decl.leftId] = declModel;
710
+ graph.relationshipDecls[universeName][decl.rightId] = declModel;
711
+ }
712
+ }
713
+ }
714
+ } else if (decl.kind === 'anthology') {
534
715
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
535
716
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
536
717
  let actualParentNodeId = parentNodeId;
@@ -556,6 +737,8 @@ function processBody(
556
737
  reposMap,
557
738
  refsMap,
558
739
  entityKinds,
740
+ relationshipsByScope,
741
+ relationshipDeclsByUniverse,
559
742
  pendingReferenceDecls,
560
743
  pendingReferenceAttachments,
561
744
  pendingContainerResolutions,
@@ -590,6 +773,8 @@ function processBody(
590
773
  reposMap,
591
774
  refsMap,
592
775
  entityKinds,
776
+ relationshipsByScope,
777
+ relationshipDeclsByUniverse,
593
778
  pendingReferenceDecls,
594
779
  pendingReferenceAttachments,
595
780
  pendingContainerResolutions,
@@ -633,41 +818,89 @@ function processBody(
633
818
  reposMap,
634
819
  refsMap,
635
820
  entityKinds,
821
+ relationshipsByScope,
822
+ relationshipDeclsByUniverse,
636
823
  pendingReferenceDecls,
637
824
  pendingReferenceAttachments,
638
825
  pendingContainerResolutions,
639
826
  );
640
827
  } else if (decl.kind === 'chapter') {
641
- const nodeId = makeNodeId(universeName, 'chapter', decl.name);
642
- checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
643
-
644
- // Validate: chapter must have an "in" block
645
- if (!decl.parentName) {
646
- graph.diagnostics.push({
647
- severity: 'error',
648
- message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
649
- source: decl.source,
650
- });
651
- // Continue processing but mark as invalid
652
- }
653
-
654
- const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
655
- const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
828
+ let containerNodeId = undefined;
656
829
 
657
- // Validate: chapter container must be a book (if found)
658
- // Note: We defer "not found" validation until after all nodes are created
659
- // to allow forward references (book defined after chapter)
660
- if (containerNodeId) {
661
- const containerNode = graph.nodes[containerNodeId];
662
- if (containerNode && containerNode.kind !== 'book') {
830
+ if (decl.parentName) {
831
+ // Explicit "in" clause - resolve the parent name
832
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
833
+ containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : undefined;
834
+
835
+ // Validate: chapter container must be a book (if found)
836
+ // Note: We defer "not found" validation until after all nodes are created
837
+ // to allow forward references (book defined after chapter)
838
+ if (containerNodeId) {
839
+ const containerNode = graph.nodes[containerNodeId];
840
+ if (containerNode && containerNode.kind !== 'book') {
841
+ graph.diagnostics.push({
842
+ severity: 'error',
843
+ message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
844
+ source: decl.source,
845
+ });
846
+ }
847
+ }
848
+ } else {
849
+ // Nested chapter - parentName is undefined, check if parentNodeId is a book
850
+ if (parentNodeId) {
851
+ const parentNode = graph.nodes[parentNodeId];
852
+ if (parentNode && parentNode.kind !== 'book') {
853
+ graph.diagnostics.push({
854
+ severity: 'error',
855
+ message: `Chapter "${decl.name}" must belong to a book, but it's nested inside a ${parentNode.kind}`,
856
+ source: decl.source,
857
+ });
858
+ } else if (parentNode && parentNode.kind === 'book') {
859
+ // Valid nested chapter - use parentNodeId as container
860
+ containerNodeId = parentNodeId;
861
+ }
862
+ } else {
863
+ // No parent at all - error
663
864
  graph.diagnostics.push({
664
865
  severity: 'error',
665
- message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
866
+ message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
666
867
  source: decl.source,
667
868
  });
668
869
  }
669
- } else if (decl.parentName && pendingContainerResolutions) {
870
+ }
871
+
872
+ // Build unique node ID including container path
873
+ let containerPath = null;
874
+ if (containerNodeId) {
875
+ // Container is resolved - build path from container node
876
+ containerPath = buildContainerPath(graph, containerNodeId);
877
+ } else if (parentNodeId) {
878
+ // Nested chapter - use parent (book) to build path
879
+ containerPath = buildContainerPath(graph, parentNodeId);
880
+ } else if (decl.parentName) {
881
+ // Forward reference - build path from current scope to include series
882
+ // Walk up from currentNodeId to find series, then use book name
883
+ // This ensures uniqueness even when the container isn't resolved yet
884
+ const pathParts = [];
885
+ const seriesNodeId = findSeriesForNode(graph, currentNodeId);
886
+ if (seriesNodeId) {
887
+ const seriesNode = graph.nodes[seriesNodeId];
888
+ if (seriesNode) {
889
+ pathParts.push('series', seriesNode.name);
890
+ }
891
+ }
892
+ // Always include book name - if series wasn't found, we'll still have book name
893
+ // which should be unique within the current processing context
894
+ pathParts.push('book', decl.parentName);
895
+ containerPath = pathParts.length > 0 ? pathParts.join(':') : null;
896
+ }
897
+
898
+ const nodeId = makeNodeId(universeName, 'chapter', decl.name, containerPath);
899
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
900
+
901
+ if (decl.parentName && !containerNodeId && pendingContainerResolutions) {
670
902
  // Container not found yet - may be a forward reference, track for later resolution
903
+ // Note: nodeId already includes the parent name, so it's unique even if container isn't resolved yet
671
904
  pendingContainerResolutions.push({
672
905
  nodeId,
673
906
  parentName: decl.parentName,
@@ -705,6 +938,8 @@ function processBody(
705
938
  reposMap,
706
939
  refsMap,
707
940
  entityKinds,
941
+ relationshipsByScope,
942
+ relationshipDeclsByUniverse,
708
943
  pendingReferenceDecls,
709
944
  pendingReferenceAttachments,
710
945
  pendingContainerResolutions,
@@ -727,6 +962,27 @@ function processBody(
727
962
  graph.nodes[nodeId] = node;
728
963
  graph.nodes[actualParentNodeId].children.push(nodeId);
729
964
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
965
+
966
+ // Validate relationships blocks in concept body
967
+ const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
968
+ for (const relBlock of relationshipsBlocks) {
969
+ if (relBlock.entries && relationshipsByScope) {
970
+ // New syntax: validate relationship IDs
971
+ const currentRelationships = relationshipsByScope.get(currentNodeId);
972
+ if (currentRelationships) {
973
+ for (const entry of relBlock.entries) {
974
+ if (!currentRelationships.has(entry.relationshipId)) {
975
+ graph.diagnostics.push({
976
+ severity: 'error',
977
+ message: `Undeclared relationship identifier "${entry.relationshipId}" in relationships block`,
978
+ source: relBlock.source,
979
+ });
980
+ }
981
+ }
982
+ }
983
+ }
984
+ }
985
+
730
986
  processBody(
731
987
  graph,
732
988
  universeName,
@@ -739,8 +995,10 @@ function processBody(
739
995
  reposMap,
740
996
  refsMap,
741
997
  entityKinds,
998
+ relationshipsByScope,
742
999
  pendingReferenceDecls,
743
1000
  pendingReferenceAttachments,
1001
+ pendingContainerResolutions,
744
1002
  );
745
1003
  } else if (decl.kind === 'relates') {
746
1004
  // Check for duplicate relates in reverse order
@@ -825,6 +1083,8 @@ function processBody(
825
1083
  source: decl.source,
826
1084
  endpoints: [], // Will be populated during resolution
827
1085
  unresolvedEndpoints: [decl.a, decl.b], // Will be cleared during resolution
1086
+ spelledKind: decl.spelledKind,
1087
+ aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
828
1088
  };
829
1089
 
830
1090
  // Add top-level describe if present
@@ -925,32 +1185,15 @@ function processBody(
925
1185
 
926
1186
  graph.nodes[finalRelatesNodeId] = relatesNode;
927
1187
  graph.nodes[parentNodeId].children.push(finalRelatesNodeId);
928
-
929
- // Also create edge for backward compatibility
930
- const edgeId = makeEdgeId(universeName, decl.a, decl.b, index);
931
- const edge = {
932
- id: edgeId,
933
- kind: 'relates',
934
- a: { text: decl.a },
935
- b: { text: decl.b },
936
- source: decl.source,
937
- };
938
-
939
- if (describeBlocks.length > 0) {
940
- edge.describe = {
941
- raw: describeBlocks[0].raw,
942
- normalized: normalizeProseBlock(describeBlocks[0].raw),
943
- source: describeBlocks[0].source,
944
- };
945
- }
946
-
947
- graph.edges[edgeId] = edge;
948
1188
  } else if (decl.kind === 'describe') {
949
1189
  // Describe blocks are attached to their parent node
950
1190
  // This is handled in createNode
951
1191
  } else if (decl.kind === 'title') {
952
1192
  // Title blocks are attached to their parent node
953
1193
  // This is handled in createNode
1194
+ } else if (decl.kind === 'relationships') {
1195
+ // Relationships blocks are attached to their parent node
1196
+ // This is handled in createNode
954
1197
  } else if (decl.kind === 'references') {
955
1198
  if (pendingReferenceAttachments) {
956
1199
  pendingReferenceAttachments.push({
@@ -1112,18 +1355,27 @@ function processBody(
1112
1355
  graph.documentsByName[universeName][docName] = docModel;
1113
1356
  }
1114
1357
  }
1358
+ } else if (decl.kind === 'relationship') {
1359
+ // Relationship declarations are tracked in relationshipsByScope but don't create nodes
1360
+ // They're just declarations that can be referenced in relationships blocks
1115
1361
  } else {
1116
1362
  // UnknownBlock - attach to current node (the node whose body contains this block)
1117
- const currentNode = graph.nodes[currentNodeId];
1118
- if (!currentNode.unknownBlocks) {
1119
- currentNode.unknownBlocks = [];
1363
+ // Only process if it's actually an UnknownBlock (has keyword property, not kind)
1364
+ // Blocks with 'kind' property are known block types that should be handled above
1365
+ if (decl.keyword && !decl.kind) {
1366
+ const currentNode = graph.nodes[currentNodeId];
1367
+ if (!currentNode.unknownBlocks) {
1368
+ currentNode.unknownBlocks = [];
1369
+ }
1370
+ currentNode.unknownBlocks.push({
1371
+ keyword: decl.keyword,
1372
+ raw: decl.raw,
1373
+ normalized: normalizeProseBlock(decl.raw),
1374
+ source: decl.source,
1375
+ });
1120
1376
  }
1121
- currentNode.unknownBlocks.push({
1122
- keyword: decl.keyword,
1123
- raw: decl.raw,
1124
- normalized: normalizeProseBlock(decl.raw),
1125
- source: decl.source,
1126
- });
1377
+ // If it has a 'kind' property, it's a known block type that should have been handled above
1378
+ // Skip it silently (it may have been handled elsewhere, e.g., in createNode)
1127
1379
  }
1128
1380
  }
1129
1381
 
@@ -1170,6 +1422,8 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1170
1422
  parent: parentNodeId,
1171
1423
  children: [],
1172
1424
  source: decl.source,
1425
+ spelledKind: decl.spelledKind,
1426
+ aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
1173
1427
  };
1174
1428
 
1175
1429
  // Always set container for book/chapter nodes (may be undefined if not resolved)
@@ -1200,6 +1454,34 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1200
1454
  }
1201
1455
  }
1202
1456
 
1457
+ // Extract relationships block if present
1458
+ const relationshipsBlock = decl.body?.find((b) => b.kind === 'relationships');
1459
+ if (relationshipsBlock) {
1460
+ if (relationshipsBlock.entries) {
1461
+ // New syntax: relationship ID + targets
1462
+ node.relationships = {
1463
+ entries: relationshipsBlock.entries.map((entry) => ({
1464
+ relationshipId: entry.relationshipId,
1465
+ targets: entry.targets.map((target) => ({
1466
+ id: target.id,
1467
+ metadata: target.metadata ? {
1468
+ raw: target.metadata.raw,
1469
+ normalized: normalizeProseBlock(target.metadata.raw),
1470
+ source: target.metadata.source,
1471
+ } : undefined,
1472
+ })),
1473
+ })),
1474
+ source: relationshipsBlock.source,
1475
+ };
1476
+ } else if (relationshipsBlock.values) {
1477
+ // String literals syntax (for relates blocks)
1478
+ node.relationships = {
1479
+ values: relationshipsBlock.values,
1480
+ source: relationshipsBlock.source,
1481
+ };
1482
+ }
1483
+ }
1484
+
1203
1485
  // Note: UnknownBlocks are handled in processBody and attached to the parent node
1204
1486
  // They are not extracted here to avoid duplication
1205
1487
 
@@ -1267,60 +1549,7 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
1267
1549
  * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
1268
1550
  */
1269
1551
  function resolveEdges(graph, scopeIndex) {
1270
- // Build a set of relates node names to avoid duplicate warnings
1271
- // (edges are created for backward compatibility alongside relates nodes)
1272
- const relatesNodeNames = new Set();
1273
- for (const nodeId in graph.nodes) {
1274
- const node = graph.nodes[nodeId];
1275
- if (node.kind === 'relates') {
1276
- relatesNodeNames.add(node.name);
1277
- }
1278
- }
1279
-
1280
- // Resolve edges (for backward compatibility)
1281
- for (const edgeId in graph.edges) {
1282
- const edge = graph.edges[edgeId];
1283
- const universeName = edgeId.split(':')[0];
1284
- const universeNodeId = `${universeName}:universe:${universeName}`;
1285
-
1286
- // Check if this edge corresponds to a relates node
1287
- // If so, skip warnings here (they'll be generated during relates node resolution)
1288
- const edgeName = `${edge.a.text} and ${edge.b.text}`;
1289
- const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
1290
- const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
1291
-
1292
- // Resolve endpoint A - start from universe scope
1293
- const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1294
- if (resolvedA.nodeId && !resolvedA.ambiguous) {
1295
- edge.a.target = resolvedA.nodeId;
1296
- } else if (!hasRelatesNode) {
1297
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1298
- if (!resolvedA.ambiguous) {
1299
- graph.diagnostics.push({
1300
- severity: 'warning',
1301
- message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
1302
- source: edge.source,
1303
- });
1304
- }
1305
- }
1306
-
1307
- // Resolve endpoint B - start from universe scope
1308
- const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1309
- if (resolvedB.nodeId && !resolvedB.ambiguous) {
1310
- edge.b.target = resolvedB.nodeId;
1311
- } else if (!hasRelatesNode) {
1312
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1313
- if (!resolvedB.ambiguous) {
1314
- graph.diagnostics.push({
1315
- severity: 'warning',
1316
- message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
1317
- source: edge.source,
1318
- });
1319
- }
1320
- }
1321
- }
1322
-
1323
- // Resolve relates nodes
1552
+ // Resolve relates node endpoints
1324
1553
  for (const nodeId in graph.nodes) {
1325
1554
  const node = graph.nodes[nodeId];
1326
1555
  if (node.kind === 'relates' && node.unresolvedEndpoints) {
@@ -1433,6 +1662,162 @@ function resolveContainers(graph, scopeIndex, pending) {
1433
1662
  }
1434
1663
  }
1435
1664
 
1665
+ /**
1666
+ * Extracts asserted edges from both relationships {} blocks and relates nodes
1667
+ * @param {UniverseGraph} graph - The graph
1668
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
1669
+ * @returns {import('./ir.js').EdgeAssertedModel[]}
1670
+ */
1671
+ function extractAssertedEdges(graph, scopeIndex) {
1672
+ /** @type {import('./ir.js').EdgeAssertedModel[]} */
1673
+ const assertedEdges = [];
1674
+
1675
+ // Extract from relationships {} blocks (new-style adjacency lists)
1676
+ for (const nodeId in graph.nodes) {
1677
+ const node = graph.nodes[nodeId];
1678
+ if (!node.relationships || !node.relationships.entries) continue;
1679
+
1680
+ // Resolve each target and create asserted edge
1681
+ for (const entry of node.relationships.entries) {
1682
+ for (const target of entry.targets) {
1683
+ // Resolve target ID using scope resolution
1684
+ const resolved = resolveNameInScope(
1685
+ graph,
1686
+ scopeIndex,
1687
+ target.id,
1688
+ nodeId,
1689
+ node.relationships.source,
1690
+ );
1691
+ const targetNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
1692
+ if (!targetNodeId) continue;
1693
+
1694
+ assertedEdges.push({
1695
+ from: nodeId,
1696
+ via: entry.relationshipId,
1697
+ to: targetNodeId,
1698
+ meta: target.metadata,
1699
+ source: node.relationships.source,
1700
+ });
1701
+ }
1702
+ }
1703
+ }
1704
+
1705
+ // Extract from relates nodes (bidirectional relationships)
1706
+ // Note: Relates are bidirectional, but we create only ONE edge entry
1707
+ // The UI should traverse edges bidirectionally to show the relationship from both perspectives
1708
+ //
1709
+ // Metadata behavior: edge.meta contains metadata from endpointA's perspective (from block at endpointA).
1710
+ // When viewing endpointB, the UI should look up endpointB-specific metadata from the relates node's
1711
+ // from[endpointB] block. This allows different descriptions for the same relationship from each endpoint's view.
1712
+ for (const nodeId in graph.nodes) {
1713
+ const node = graph.nodes[nodeId];
1714
+ if (node.kind !== 'relates' || !node.endpoints || node.endpoints.length !== 2) continue;
1715
+
1716
+ const [endpointA, endpointB] = node.endpoints;
1717
+
1718
+ // Use the relationship label from the relates node if available
1719
+ const viaLabel = node.relationships?.values?.[0] || 'related to';
1720
+
1721
+ // Create a single bidirectional edge (A -> B)
1722
+ // The UI should show this relationship when viewing either endpoint
1723
+ // Metadata is from endpointA's perspective; UI will look up endpointB-specific metadata when needed
1724
+ assertedEdges.push({
1725
+ from: endpointA,
1726
+ via: viaLabel, // Note: relates don't use relationship declarations, they use string labels
1727
+ to: endpointB,
1728
+ meta: node.from?.[endpointA]?.describe, // Metadata from endpointA's perspective
1729
+ source: node.source,
1730
+ bidirectional: true, // Mark as bidirectional so UI can traverse both directions
1731
+ });
1732
+ }
1733
+
1734
+ return assertedEdges;
1735
+ }
1736
+
1737
+ /**
1738
+ * Normalizes edges by adding inverse edges for paired/symmetric relationships
1739
+ * @param {import('./ir.js').EdgeAssertedModel[]} assertedEdges - Asserted edges
1740
+ * @param {Map<string, Map<string, import('./ast.js').RelationshipDecl>>} relationshipDecls - Relationship declarations by universe
1741
+ * @param {string} universeName - Universe name
1742
+ * @returns {import('./ir.js').NormalizedEdgeModel[]}
1743
+ */
1744
+ function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
1745
+ /** @type {import('./ir.js').NormalizedEdgeModel[]} */
1746
+ const normalizedEdges = [];
1747
+ const relDeclsMap = relationshipDecls.get(universeName);
1748
+ const relDecls = relDeclsMap ? Object.fromEntries(relDeclsMap) : {};
1749
+ const seenEdges = new Set(); // Track edges to avoid duplicates
1750
+
1751
+ for (const asserted of assertedEdges) {
1752
+ const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
1753
+
1754
+ // Skip if we've already added this exact edge (can happen with relates bidirectional edges)
1755
+ if (seenEdges.has(edgeKey)) continue;
1756
+ seenEdges.add(edgeKey);
1757
+
1758
+ // Get relationship declaration if this is a declared relationship
1759
+ // Note: relates edges use string labels, not relationship IDs, so they won't have a relDecl
1760
+ const relDecl = relDecls[asserted.via];
1761
+
1762
+ // Add asserted edge
1763
+ normalizedEdges.push({
1764
+ from: asserted.from,
1765
+ via: asserted.via,
1766
+ to: asserted.to,
1767
+ asserted: true,
1768
+ sourceRefs: [asserted.source],
1769
+ meta: asserted.meta,
1770
+ bidirectional: asserted.bidirectional || false, // Preserve bidirectional flag for relates edges
1771
+ });
1772
+
1773
+ // Add inverse edge if applicable (only for declared relationships, not relates)
1774
+ // Relates are already bidirectional, so both directions are in assertedEdges
1775
+ if (relDecl) {
1776
+ if (relDecl.type === 'paired') {
1777
+ // Determine inverse side
1778
+ const inverseVia =
1779
+ asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
1780
+
1781
+ if (inverseVia) {
1782
+ const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
1783
+ // Only add inverse if not already present as an asserted edge
1784
+ if (!seenEdges.has(inverseKey)) {
1785
+ normalizedEdges.push({
1786
+ from: asserted.to,
1787
+ via: inverseVia,
1788
+ to: asserted.from,
1789
+ asserted: false,
1790
+ sourceRefs: [asserted.source], // Points back to asserted edge
1791
+ meta: asserted.meta, // Copy metadata
1792
+ });
1793
+ seenEdges.add(inverseKey);
1794
+ }
1795
+ }
1796
+ } else if (relDecl.type === 'symmetric') {
1797
+ // Symmetric: add reverse edge with same relationship ID
1798
+ const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
1799
+ // Only add reverse if not already present as an asserted edge
1800
+ if (!seenEdges.has(reverseKey)) {
1801
+ normalizedEdges.push({
1802
+ from: asserted.to,
1803
+ via: asserted.via,
1804
+ to: asserted.from,
1805
+ asserted: false,
1806
+ sourceRefs: [asserted.source],
1807
+ meta: asserted.meta,
1808
+ });
1809
+ seenEdges.add(reverseKey);
1810
+ }
1811
+ }
1812
+ }
1813
+ // Note: If no relDecl found, it's likely a relates edge (string label, not declared relationship)
1814
+ // Relates edges are bidirectional - the UI should traverse them bidirectionally
1815
+ // by checking both from/to fields, so we only create one edge entry
1816
+ }
1817
+
1818
+ return normalizedEdges;
1819
+ }
1820
+
1436
1821
  /**
1437
1822
  * @param {string | undefined} raw
1438
1823
  * @returns {string | undefined}
@@ -1761,10 +2146,47 @@ function deriveReferenceName(decl) {
1761
2146
  * @param {string} name
1762
2147
  * @returns {string}
1763
2148
  */
1764
- function makeNodeId(universeName, kind, name) {
2149
+ function makeNodeId(universeName, kind, name, containerPath = null) {
2150
+ if (containerPath) {
2151
+ return `${universeName}:${kind}:${containerPath}:${name}`;
2152
+ }
1765
2153
  return `${universeName}:${kind}:${name}`;
1766
2154
  }
1767
2155
 
2156
+ /**
2157
+ * Builds a container path string from a node's parent chain
2158
+ * @param {UniverseGraph} graph
2159
+ * @param {string} containerNodeId - The container node ID
2160
+ * @returns {string | null} - Container path like "series:Alchemy:book:Materials" or null if no container
2161
+ */
2162
+ function buildContainerPath(graph, containerNodeId) {
2163
+ if (!containerNodeId) return null;
2164
+
2165
+ const containerNode = graph.nodes[containerNodeId];
2166
+ // If container node doesn't exist yet (e.g., forward reference), return null
2167
+ // The caller should handle this case appropriately
2168
+ if (!containerNode) return null;
2169
+
2170
+ const pathParts = [];
2171
+ let current = containerNode;
2172
+
2173
+ // Walk up the parent chain, collecting series and book names
2174
+ while (current) {
2175
+ if (current.kind === 'series' || current.kind === 'book') {
2176
+ pathParts.unshift(current.kind, current.name);
2177
+ }
2178
+ if (current.parent) {
2179
+ current = graph.nodes[current.parent];
2180
+ // Safety check: if parent node doesn't exist, stop walking
2181
+ if (!current) break;
2182
+ } else {
2183
+ break;
2184
+ }
2185
+ }
2186
+
2187
+ return pathParts.length > 0 ? pathParts.join(':') : null;
2188
+ }
2189
+
1768
2190
  /**
1769
2191
  * Creates a relates node ID (deterministic, endpoints in source order)
1770
2192
  * @param {string} universeName