@sprig-and-prose/sprig-universe 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/sprig-universe",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
package/src/graph.js CHANGED
@@ -50,7 +50,7 @@ export function buildGraph(fileASTs) {
50
50
  version: 1,
51
51
  universes: {},
52
52
  nodes: {},
53
- edges: {},
53
+ edges: [],
54
54
  diagnostics: [],
55
55
  documentsByName: {},
56
56
  repositories: {},
@@ -318,9 +318,6 @@ export function buildGraph(fileASTs) {
318
318
  // Extract asserted edges from both relationships blocks and relates nodes
319
319
  graph.edgesAsserted = extractAssertedEdges(graph, scopeIndex);
320
320
 
321
- // Preserve existing edges object format for relates (for code that still uses it)
322
- graph.edgesByRelates = graph.edges;
323
-
324
321
  // Normalize edges (add inverse edges for paired/symmetric relationships)
325
322
  const universeNames = Array.from(universeNameToFiles.keys());
326
323
  if (universeNames.length > 0) {
@@ -382,6 +379,46 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
382
379
  }
383
380
  }
384
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
+
385
422
  /**
386
423
  * Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
387
424
  * Returns the first matching node ID found, or null if not found
@@ -445,7 +482,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
445
482
  } else if (candidates.length === 1) {
446
483
  return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
447
484
  } else {
448
- // 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
449
493
  if (source) {
450
494
  const scopeNode = graph.nodes[currentScope];
451
495
  const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
@@ -475,6 +519,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
475
519
  if (candidates.length === 1) {
476
520
  return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
477
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
478
530
  if (source) {
479
531
  graph.diagnostics.push({
480
532
  severity: 'error',
@@ -773,36 +825,82 @@ function processBody(
773
825
  pendingContainerResolutions,
774
826
  );
775
827
  } else if (decl.kind === 'chapter') {
776
- const nodeId = makeNodeId(universeName, 'chapter', decl.name);
777
- checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
778
-
779
- // Validate: chapter must have an "in" block
780
- if (!decl.parentName) {
781
- graph.diagnostics.push({
782
- severity: 'error',
783
- message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
784
- source: decl.source,
785
- });
786
- // Continue processing but mark as invalid
787
- }
788
-
789
- const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
790
- const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
828
+ let containerNodeId = undefined;
791
829
 
792
- // Validate: chapter container must be a book (if found)
793
- // Note: We defer "not found" validation until after all nodes are created
794
- // to allow forward references (book defined after chapter)
795
- if (containerNodeId) {
796
- const containerNode = graph.nodes[containerNodeId];
797
- 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
798
864
  graph.diagnostics.push({
799
865
  severity: 'error',
800
- 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> { ... }"`,
801
867
  source: decl.source,
802
868
  });
803
869
  }
804
- } 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) {
805
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
806
904
  pendingContainerResolutions.push({
807
905
  nodeId,
808
906
  parentName: decl.parentName,
@@ -1087,26 +1185,6 @@ function processBody(
1087
1185
 
1088
1186
  graph.nodes[finalRelatesNodeId] = relatesNode;
1089
1187
  graph.nodes[parentNodeId].children.push(finalRelatesNodeId);
1090
-
1091
- // Also create edge for backward compatibility
1092
- const edgeId = makeEdgeId(universeName, decl.a, decl.b, index);
1093
- const edge = {
1094
- id: edgeId,
1095
- kind: 'relates',
1096
- a: { text: decl.a },
1097
- b: { text: decl.b },
1098
- source: decl.source,
1099
- };
1100
-
1101
- if (describeBlocks.length > 0) {
1102
- edge.describe = {
1103
- raw: describeBlocks[0].raw,
1104
- normalized: normalizeProseBlock(describeBlocks[0].raw),
1105
- source: describeBlocks[0].source,
1106
- };
1107
- }
1108
-
1109
- graph.edges[edgeId] = edge;
1110
1188
  } else if (decl.kind === 'describe') {
1111
1189
  // Describe blocks are attached to their parent node
1112
1190
  // This is handled in createNode
@@ -1471,60 +1549,7 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
1471
1549
  * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
1472
1550
  */
1473
1551
  function resolveEdges(graph, scopeIndex) {
1474
- // Build a set of relates node names to avoid duplicate warnings
1475
- // (edges are created for backward compatibility alongside relates nodes)
1476
- const relatesNodeNames = new Set();
1477
- for (const nodeId in graph.nodes) {
1478
- const node = graph.nodes[nodeId];
1479
- if (node.kind === 'relates') {
1480
- relatesNodeNames.add(node.name);
1481
- }
1482
- }
1483
-
1484
- // Resolve edges (relates edges in object format)
1485
- for (const edgeId in graph.edges) {
1486
- const edge = graph.edges[edgeId];
1487
- const universeName = edgeId.split(':')[0];
1488
- const universeNodeId = `${universeName}:universe:${universeName}`;
1489
-
1490
- // Check if this edge corresponds to a relates node
1491
- // If so, skip warnings here (they'll be generated during relates node resolution)
1492
- const edgeName = `${edge.a.text} and ${edge.b.text}`;
1493
- const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
1494
- const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
1495
-
1496
- // Resolve endpoint A - start from universe scope
1497
- const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1498
- if (resolvedA.nodeId && !resolvedA.ambiguous) {
1499
- edge.a.target = resolvedA.nodeId;
1500
- } else if (!hasRelatesNode) {
1501
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1502
- if (!resolvedA.ambiguous) {
1503
- graph.diagnostics.push({
1504
- severity: 'warning',
1505
- message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
1506
- source: edge.source,
1507
- });
1508
- }
1509
- }
1510
-
1511
- // Resolve endpoint B - start from universe scope
1512
- const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1513
- if (resolvedB.nodeId && !resolvedB.ambiguous) {
1514
- edge.b.target = resolvedB.nodeId;
1515
- } else if (!hasRelatesNode) {
1516
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1517
- if (!resolvedB.ambiguous) {
1518
- graph.diagnostics.push({
1519
- severity: 'warning',
1520
- message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
1521
- source: edge.source,
1522
- });
1523
- }
1524
- }
1525
- }
1526
-
1527
- // Resolve relates nodes
1552
+ // Resolve relates node endpoints
1528
1553
  for (const nodeId in graph.nodes) {
1529
1554
  const node = graph.nodes[nodeId];
1530
1555
  if (node.kind === 'relates' && node.unresolvedEndpoints) {
@@ -1678,32 +1703,31 @@ function extractAssertedEdges(graph, scopeIndex) {
1678
1703
  }
1679
1704
 
1680
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.
1681
1712
  for (const nodeId in graph.nodes) {
1682
1713
  const node = graph.nodes[nodeId];
1683
1714
  if (node.kind !== 'relates' || !node.endpoints || node.endpoints.length !== 2) continue;
1684
1715
 
1685
1716
  const [endpointA, endpointB] = node.endpoints;
1686
1717
 
1687
- // Relates are bidirectional - extract both directions as asserted edges
1688
1718
  // Use the relationship label from the relates node if available
1689
1719
  const viaLabel = node.relationships?.values?.[0] || 'related to';
1690
1720
 
1691
- // Create edge A -> B
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
1692
1724
  assertedEdges.push({
1693
1725
  from: endpointA,
1694
1726
  via: viaLabel, // Note: relates don't use relationship declarations, they use string labels
1695
1727
  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,
1728
+ meta: node.from?.[endpointA]?.describe, // Metadata from endpointA's perspective
1706
1729
  source: node.source,
1730
+ bidirectional: true, // Mark as bidirectional so UI can traverse both directions
1707
1731
  });
1708
1732
  }
1709
1733
 
@@ -1743,6 +1767,7 @@ function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
1743
1767
  asserted: true,
1744
1768
  sourceRefs: [asserted.source],
1745
1769
  meta: asserted.meta,
1770
+ bidirectional: asserted.bidirectional || false, // Preserve bidirectional flag for relates edges
1746
1771
  });
1747
1772
 
1748
1773
  // Add inverse edge if applicable (only for declared relationships, not relates)
@@ -1786,7 +1811,8 @@ function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
1786
1811
  }
1787
1812
  }
1788
1813
  // 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
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
1790
1816
  }
1791
1817
 
1792
1818
  return normalizedEdges;
@@ -2120,10 +2146,47 @@ function deriveReferenceName(decl) {
2120
2146
  * @param {string} name
2121
2147
  * @returns {string}
2122
2148
  */
2123
- function makeNodeId(universeName, kind, name) {
2149
+ function makeNodeId(universeName, kind, name, containerPath = null) {
2150
+ if (containerPath) {
2151
+ return `${universeName}:${kind}:${containerPath}:${name}`;
2152
+ }
2124
2153
  return `${universeName}:${kind}:${name}`;
2125
2154
  }
2126
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
+
2127
2190
  /**
2128
2191
  * Creates a relates node ID (deterministic, endpoints in source order)
2129
2192
  * @param {string} universeName
package/src/ir.js CHANGED
@@ -92,6 +92,7 @@
92
92
  * @property {string} to - Target node ID
93
93
  * @property {TextBlock} [meta] - Per-edge metadata (from target describe block)
94
94
  * @property {SourceSpan} source - Source location
95
+ * @property {boolean} [bidirectional] - Whether this edge is bidirectional (for relates blocks)
95
96
  */
96
97
 
97
98
  /**
@@ -102,6 +103,7 @@
102
103
  * @property {boolean} asserted - Whether this edge was explicitly authored
103
104
  * @property {SourceSpan[]} sourceRefs - Array of source locations (for inferred edges, points to asserted edge)
104
105
  * @property {TextBlock} [meta] - Per-edge metadata
106
+ * @property {boolean} [bidirectional] - Whether this edge is bidirectional (for relates blocks)
105
107
  */
106
108
 
107
109
  /**
package/src/parser.js CHANGED
@@ -658,17 +658,23 @@ class Parser {
658
658
  const startToken = this.expectKindToken(spelledKind);
659
659
  const nameToken = this.expect('IDENTIFIER');
660
660
 
661
- // Chapters must belong to a book - check for "in" keyword
662
- if (!this.match('KEYWORD') || this.peek()?.value !== 'in') {
661
+ // Chapters must belong to a book - check for "in" keyword or nested structure
662
+ let parentName = undefined;
663
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
664
+ // Explicit "in" clause
665
+ this.expect('KEYWORD', 'in');
666
+ const parentToken = this.expect('IDENTIFIER');
667
+ parentName = parentToken.value;
668
+ } else if (!this.match('LBRACE')) {
669
+ // Neither "in" nor nested - error
663
670
  const nextToken = this.peek();
664
671
  const line = nextToken ? nextToken.span.start.line : startToken.span.start.line;
665
672
  throw new Error(
666
673
  `Chapter "${nameToken.value}" must belong to a book. Use "chapter ${nameToken.value} in <BookName> { ... }" at ${this.file}:${line}`,
667
674
  );
668
675
  }
676
+ // If next token is LBRACE, it's nested - parentName stays undefined
669
677
 
670
- this.expect('KEYWORD', 'in');
671
- const parentToken = this.expect('IDENTIFIER');
672
678
  const lbrace = this.expect('LBRACE');
673
679
  this.pushAliasScope();
674
680
  const body = this.parseBlockBody(
@@ -682,7 +688,7 @@ class Parser {
682
688
  kind: 'chapter',
683
689
  spelledKind,
684
690
  name: nameToken.value,
685
- parentName: parentToken.value,
691
+ parentName,
686
692
  aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
687
693
  body,
688
694
  source: {