@sprig-and-prose/sprig-universe 0.4.0 → 0.4.2
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 +1 -1
- package/src/graph.js +179 -116
- package/src/index.js +30 -0
- package/src/ir.js +2 -0
- package/src/parser.js +11 -5
- package/src/universe/graph.js +1619 -0
- package/src/universe/parser.js +1751 -0
- package/src/universe/scanner.js +240 -0
- package/src/universe/scene-manifest.js +856 -0
- package/src/universe/test-graph.js +157 -0
- package/src/universe/test-parser.js +61 -0
- package/src/universe/test-scanner.js +37 -0
- package/src/universe/universe.prose +169 -0
- package/src/universe/validator.js +862 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
|
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/index.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { scan } from './scanner.js';
|
|
6
6
|
import { parse } from './parser.js';
|
|
7
7
|
import { buildGraph } from './graph.js';
|
|
8
|
+
import { scan as scanBeta } from './universe/scanner.js';
|
|
9
|
+
import { parseUniverse } from './universe/parser.js';
|
|
10
|
+
import { buildGraph as buildGraphBeta } from './universe/graph.js';
|
|
8
11
|
import { convertToSceneManifest, convertFilesToSceneManifest } from './scene-manifest.js';
|
|
9
12
|
import { validateScenes } from './validator.js';
|
|
10
13
|
|
|
@@ -34,6 +37,33 @@ export function parseFiles(files) {
|
|
|
34
37
|
return buildGraph(fileASTs);
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Parses multiple files using the beta universe pipeline.
|
|
42
|
+
* @param {Array<{ text: string, file: string }>} files - Array of file contents
|
|
43
|
+
* @returns {UniverseGraph}
|
|
44
|
+
*/
|
|
45
|
+
export function parseFilesBeta(files) {
|
|
46
|
+
const fileASTs = [];
|
|
47
|
+
const parserDiagnostics = [];
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const tokens = scanBeta(file.text, file.file);
|
|
50
|
+
const { ast, diags } = parseUniverse({
|
|
51
|
+
tokens,
|
|
52
|
+
sourceText: file.text,
|
|
53
|
+
filePath: file.file,
|
|
54
|
+
});
|
|
55
|
+
fileASTs.push({ ...ast, sourceText: file.text });
|
|
56
|
+
if (diags?.length) {
|
|
57
|
+
parserDiagnostics.push(...diags);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const graph = buildGraphBeta(fileASTs);
|
|
61
|
+
if (parserDiagnostics.length > 0) {
|
|
62
|
+
graph.diagnostics = [...parserDiagnostics, ...(graph.diagnostics || [])];
|
|
63
|
+
}
|
|
64
|
+
return graph;
|
|
65
|
+
}
|
|
66
|
+
|
|
37
67
|
// Export scene-related functions
|
|
38
68
|
export { convertToSceneManifest, convertFilesToSceneManifest, validateScenes };
|
|
39
69
|
|
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
|
-
|
|
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
|
|
691
|
+
parentName,
|
|
686
692
|
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
687
693
|
body,
|
|
688
694
|
source: {
|