@sprig-and-prose/sprig-universe 0.3.2 → 0.3.4
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 -2
- package/src/ast.js +14 -7
- package/src/graph.js +198 -8
- package/src/ir.js +2 -0
- package/src/parser.js +65 -5
package/package.json
CHANGED
package/src/ast.js
CHANGED
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
* @property {SourceSpan} source - Source span
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} OrderingBlock
|
|
48
|
+
* @property {string} kind - Always 'ordering'
|
|
49
|
+
* @property {string[]} identifiers - Array of identifier names
|
|
50
|
+
* @property {SourceSpan} source - Source span
|
|
51
|
+
*/
|
|
52
|
+
|
|
46
53
|
/**
|
|
47
54
|
* @typedef {Object} DocumentBlock
|
|
48
55
|
* @property {string} kind - Always 'document'
|
|
@@ -107,7 +114,7 @@
|
|
|
107
114
|
* @typedef {Object} UniverseDecl
|
|
108
115
|
* @property {string} kind - Always 'universe'
|
|
109
116
|
* @property {string} name - Universe name
|
|
110
|
-
* @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
|
|
117
|
+
* @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
|
|
111
118
|
* @property {SourceSpan} source - Source span
|
|
112
119
|
*/
|
|
113
120
|
|
|
@@ -116,7 +123,7 @@
|
|
|
116
123
|
* @property {string} kind - Always 'anthology'
|
|
117
124
|
* @property {string} name - Anthology name
|
|
118
125
|
* @property {string} [parentName] - Optional parent universe name (from "in UniverseName")
|
|
119
|
-
* @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
126
|
+
* @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
120
127
|
* @property {SourceSpan} source - Source span
|
|
121
128
|
*/
|
|
122
129
|
|
|
@@ -125,7 +132,7 @@
|
|
|
125
132
|
* @property {string} kind - Always 'series'
|
|
126
133
|
* @property {string} name - Series name
|
|
127
134
|
* @property {string} [parentName] - Optional parent anthology name (from "in AnthologyName")
|
|
128
|
-
* @property {Array<BookDecl | ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
135
|
+
* @property {Array<BookDecl | ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
129
136
|
* @property {SourceSpan} source - Source span
|
|
130
137
|
*/
|
|
131
138
|
|
|
@@ -134,7 +141,7 @@
|
|
|
134
141
|
* @property {string} kind - Always 'book'
|
|
135
142
|
* @property {string} name - Book name
|
|
136
143
|
* @property {string} [parentName] - Optional parent name (from "in ParentName")
|
|
137
|
-
* @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
144
|
+
* @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
138
145
|
* @property {SourceSpan} source - Source span
|
|
139
146
|
*/
|
|
140
147
|
|
|
@@ -143,7 +150,7 @@
|
|
|
143
150
|
* @property {string} kind - Always 'chapter'
|
|
144
151
|
* @property {string} name - Chapter name
|
|
145
152
|
* @property {string} parentName - Parent name (from "in ParentName")
|
|
146
|
-
* @property {Array<DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
153
|
+
* @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
147
154
|
* @property {SourceSpan} source - Source span
|
|
148
155
|
*/
|
|
149
156
|
|
|
@@ -152,7 +159,7 @@
|
|
|
152
159
|
* @property {string} kind - Always 'concept'
|
|
153
160
|
* @property {string} name - Concept name
|
|
154
161
|
* @property {string} [parentName] - Optional parent name (from "in ParentName")
|
|
155
|
-
* @property {Array<DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
162
|
+
* @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
|
|
156
163
|
* @property {SourceSpan} source - Source span
|
|
157
164
|
*/
|
|
158
165
|
|
|
@@ -176,7 +183,7 @@
|
|
|
176
183
|
* @property {string} kind - Always 'relates'
|
|
177
184
|
* @property {string} a - First endpoint text
|
|
178
185
|
* @property {string} b - Second endpoint text
|
|
179
|
-
* @property {Array<DescribeBlock | TitleBlock | FromBlock | UnknownBlock>} body - Body declarations
|
|
186
|
+
* @property {Array<DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | UnknownBlock>} body - Body declarations
|
|
180
187
|
* @property {SourceSpan} source - Source span
|
|
181
188
|
*/
|
|
182
189
|
|
package/src/graph.js
CHANGED
|
@@ -27,6 +27,7 @@ import { normalizeProseBlock } from './util/text.js';
|
|
|
27
27
|
* @typedef {import('./ast.js').FromBlock} FromBlock
|
|
28
28
|
* @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
|
|
29
29
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
30
|
+
* @typedef {import('./ast.js').OrderingBlock} OrderingBlock
|
|
30
31
|
* @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
|
|
31
32
|
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
32
33
|
* @typedef {import('./ast.js').DocumentBlock} DocumentBlock
|
|
@@ -74,6 +75,8 @@ export function buildGraph(fileASTs) {
|
|
|
74
75
|
const pendingReferenceDecls = [];
|
|
75
76
|
/** @type {Array<{ nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string }>} */
|
|
76
77
|
const pendingReferenceAttachments = [];
|
|
78
|
+
/** @type {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} */
|
|
79
|
+
const pendingContainerResolutions = [];
|
|
77
80
|
|
|
78
81
|
// First pass: collect all universe names with their file locations for validation
|
|
79
82
|
const universeNameToFiles = new Map(); // universeName -> Set<file>
|
|
@@ -189,6 +192,7 @@ export function buildGraph(fileASTs) {
|
|
|
189
192
|
entityKinds,
|
|
190
193
|
pendingReferenceDecls,
|
|
191
194
|
pendingReferenceAttachments,
|
|
195
|
+
pendingContainerResolutions,
|
|
192
196
|
);
|
|
193
197
|
} else {
|
|
194
198
|
// First time seeing this universe - create node and process body
|
|
@@ -218,6 +222,7 @@ export function buildGraph(fileASTs) {
|
|
|
218
222
|
entityKinds,
|
|
219
223
|
pendingReferenceDecls,
|
|
220
224
|
pendingReferenceAttachments,
|
|
225
|
+
pendingContainerResolutions,
|
|
221
226
|
);
|
|
222
227
|
}
|
|
223
228
|
}
|
|
@@ -265,6 +270,7 @@ export function buildGraph(fileASTs) {
|
|
|
265
270
|
entityKinds,
|
|
266
271
|
pendingReferenceDecls,
|
|
267
272
|
pendingReferenceAttachments,
|
|
273
|
+
pendingContainerResolutions,
|
|
268
274
|
);
|
|
269
275
|
}
|
|
270
276
|
}
|
|
@@ -286,6 +292,12 @@ export function buildGraph(fileASTs) {
|
|
|
286
292
|
// Resolve edge endpoints
|
|
287
293
|
resolveEdges(graph, scopeIndex);
|
|
288
294
|
|
|
295
|
+
// Resolve container references (books/chapters) that may have forward references
|
|
296
|
+
resolveContainers(graph, scopeIndex, pendingContainerResolutions);
|
|
297
|
+
|
|
298
|
+
// Validate and apply ordering blocks after all nodes and relationships are established
|
|
299
|
+
validateOrderingBlocks(graph);
|
|
300
|
+
|
|
289
301
|
return graph;
|
|
290
302
|
}
|
|
291
303
|
|
|
@@ -496,6 +508,7 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
|
|
|
496
508
|
* @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
|
|
497
509
|
* @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
|
|
498
510
|
* @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
|
|
511
|
+
* @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
|
|
499
512
|
*/
|
|
500
513
|
function processBody(
|
|
501
514
|
graph,
|
|
@@ -511,7 +524,11 @@ function processBody(
|
|
|
511
524
|
entityKinds,
|
|
512
525
|
pendingReferenceDecls,
|
|
513
526
|
pendingReferenceAttachments,
|
|
527
|
+
pendingContainerResolutions,
|
|
514
528
|
) {
|
|
529
|
+
// Collect ordering blocks to process after all children are added
|
|
530
|
+
const orderingBlocks = [];
|
|
531
|
+
|
|
515
532
|
for (const decl of body) {
|
|
516
533
|
if (decl.kind === 'anthology') {
|
|
517
534
|
const nodeId = makeNodeId(universeName, 'anthology', decl.name);
|
|
@@ -541,6 +558,7 @@ function processBody(
|
|
|
541
558
|
entityKinds,
|
|
542
559
|
pendingReferenceDecls,
|
|
543
560
|
pendingReferenceAttachments,
|
|
561
|
+
pendingContainerResolutions,
|
|
544
562
|
);
|
|
545
563
|
} else if (decl.kind === 'series') {
|
|
546
564
|
const nodeId = makeNodeId(universeName, 'series', decl.name);
|
|
@@ -574,6 +592,7 @@ function processBody(
|
|
|
574
592
|
entityKinds,
|
|
575
593
|
pendingReferenceDecls,
|
|
576
594
|
pendingReferenceAttachments,
|
|
595
|
+
pendingContainerResolutions,
|
|
577
596
|
);
|
|
578
597
|
} else if (decl.kind === 'book') {
|
|
579
598
|
const nodeId = makeNodeId(universeName, 'book', decl.name);
|
|
@@ -616,6 +635,7 @@ function processBody(
|
|
|
616
635
|
entityKinds,
|
|
617
636
|
pendingReferenceDecls,
|
|
618
637
|
pendingReferenceAttachments,
|
|
638
|
+
pendingContainerResolutions,
|
|
619
639
|
);
|
|
620
640
|
} else if (decl.kind === 'chapter') {
|
|
621
641
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
@@ -634,7 +654,9 @@ function processBody(
|
|
|
634
654
|
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
635
655
|
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
636
656
|
|
|
637
|
-
// Validate: chapter container must be a book
|
|
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)
|
|
638
660
|
if (containerNodeId) {
|
|
639
661
|
const containerNode = graph.nodes[containerNodeId];
|
|
640
662
|
if (containerNode && containerNode.kind !== 'book') {
|
|
@@ -644,12 +666,14 @@ function processBody(
|
|
|
644
666
|
source: decl.source,
|
|
645
667
|
});
|
|
646
668
|
}
|
|
647
|
-
} else if (decl.parentName) {
|
|
648
|
-
// Container not found
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
669
|
+
} else if (decl.parentName && pendingContainerResolutions) {
|
|
670
|
+
// Container not found yet - may be a forward reference, track for later resolution
|
|
671
|
+
pendingContainerResolutions.push({
|
|
672
|
+
nodeId,
|
|
673
|
+
parentName: decl.parentName,
|
|
674
|
+
scopeNodeId: currentNodeId,
|
|
652
675
|
source: decl.source,
|
|
676
|
+
nodeKind: 'chapter',
|
|
653
677
|
});
|
|
654
678
|
}
|
|
655
679
|
|
|
@@ -683,6 +707,7 @@ function processBody(
|
|
|
683
707
|
entityKinds,
|
|
684
708
|
pendingReferenceDecls,
|
|
685
709
|
pendingReferenceAttachments,
|
|
710
|
+
pendingContainerResolutions,
|
|
686
711
|
);
|
|
687
712
|
} else if (decl.kind === 'concept') {
|
|
688
713
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
@@ -731,10 +756,13 @@ function processBody(
|
|
|
731
756
|
finalRelatesNodeId = makeRelatesNodeId(universeName, decl.a, decl.b, index);
|
|
732
757
|
}
|
|
733
758
|
|
|
734
|
-
// Process relates body: extract describe, from blocks, and unknown blocks
|
|
759
|
+
// Process relates body: extract describe, relationships, from blocks, and unknown blocks
|
|
735
760
|
const describeBlocks = decl.body.filter((b) => b.kind === 'describe');
|
|
761
|
+
const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
|
|
736
762
|
const fromBlocks = decl.body.filter((b) => b.kind === 'from');
|
|
737
|
-
const unknownBlocks = decl.body.filter(
|
|
763
|
+
const unknownBlocks = decl.body.filter(
|
|
764
|
+
(b) => b.kind !== 'describe' && b.kind !== 'from' && b.kind !== 'relationships',
|
|
765
|
+
);
|
|
738
766
|
|
|
739
767
|
// Validate: only one top-level describe
|
|
740
768
|
if (describeBlocks.length > 1) {
|
|
@@ -745,6 +773,24 @@ function processBody(
|
|
|
745
773
|
});
|
|
746
774
|
}
|
|
747
775
|
|
|
776
|
+
// Validate: only one top-level relationships block
|
|
777
|
+
if (relationshipsBlocks.length > 1) {
|
|
778
|
+
graph.diagnostics.push({
|
|
779
|
+
severity: 'error',
|
|
780
|
+
message: `Multiple relationships blocks in relates "${decl.a} and ${decl.b}"`,
|
|
781
|
+
source: relationshipsBlocks[1].source,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Validate: relationships must have at least one value
|
|
786
|
+
if (relationshipsBlocks.length > 0 && relationshipsBlocks[0].values.length === 0) {
|
|
787
|
+
graph.diagnostics.push({
|
|
788
|
+
severity: 'error',
|
|
789
|
+
message: `Empty relationships block in relates "${decl.a} and ${decl.b}"`,
|
|
790
|
+
source: relationshipsBlocks[0].source,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
748
794
|
// Validate: from blocks must reference endpoints
|
|
749
795
|
const endpointSet = new Set([decl.a, decl.b]);
|
|
750
796
|
const fromByEndpoint = new Map();
|
|
@@ -790,6 +836,14 @@ function processBody(
|
|
|
790
836
|
};
|
|
791
837
|
}
|
|
792
838
|
|
|
839
|
+
// Add top-level relationships if present
|
|
840
|
+
if (relationshipsBlocks.length > 0) {
|
|
841
|
+
relatesNode.relationships = {
|
|
842
|
+
values: relationshipsBlocks[0].values,
|
|
843
|
+
source: relationshipsBlocks[0].source,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
793
847
|
// Process from blocks (will be resolved later)
|
|
794
848
|
relatesNode.from = {};
|
|
795
849
|
for (const fromBlock of fromBlocks) {
|
|
@@ -905,6 +959,9 @@ function processBody(
|
|
|
905
959
|
universeName,
|
|
906
960
|
});
|
|
907
961
|
}
|
|
962
|
+
} else if (decl.kind === 'ordering') {
|
|
963
|
+
// Collect ordering blocks to process after all children are added
|
|
964
|
+
orderingBlocks.push(decl);
|
|
908
965
|
} else if (decl.kind === 'documentation') {
|
|
909
966
|
// DocumentationBlock - attach to current node
|
|
910
967
|
const currentNode = graph.nodes[currentNodeId];
|
|
@@ -1069,6 +1126,30 @@ function processBody(
|
|
|
1069
1126
|
});
|
|
1070
1127
|
}
|
|
1071
1128
|
}
|
|
1129
|
+
|
|
1130
|
+
// Store ordering blocks for later validation (after all nodes are created)
|
|
1131
|
+
// We'll validate them in a separate pass after the entire graph is built
|
|
1132
|
+
// This is necessary because children may be defined outside their parent's body
|
|
1133
|
+
// (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
|
|
1134
|
+
if (orderingBlocks.length > 0) {
|
|
1135
|
+
const currentNode = graph.nodes[currentNodeId];
|
|
1136
|
+
if (!currentNode) {
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Validate: only one ordering block per container
|
|
1141
|
+
if (orderingBlocks.length > 1) {
|
|
1142
|
+
graph.diagnostics.push({
|
|
1143
|
+
severity: 'warning',
|
|
1144
|
+
message: `Multiple ordering blocks in ${currentNode.kind} "${currentNode.name}". Using the first one.`,
|
|
1145
|
+
source: orderingBlocks[1].source,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Store ordering block temporarily on the node for later validation
|
|
1150
|
+
// We'll process this in validateOrderingBlocks() after all nodes are created
|
|
1151
|
+
currentNode._pendingOrdering = orderingBlocks[0];
|
|
1152
|
+
}
|
|
1072
1153
|
}
|
|
1073
1154
|
|
|
1074
1155
|
/**
|
|
@@ -1293,6 +1374,65 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1293
1374
|
}
|
|
1294
1375
|
}
|
|
1295
1376
|
|
|
1377
|
+
/**
|
|
1378
|
+
* Resolves container references for chapters and books that may have forward references
|
|
1379
|
+
* @param {UniverseGraph} graph
|
|
1380
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1381
|
+
* @param {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} pending
|
|
1382
|
+
*/
|
|
1383
|
+
function resolveContainers(graph, scopeIndex, pending) {
|
|
1384
|
+
for (const item of pending) {
|
|
1385
|
+
const { nodeId, parentName, scopeNodeId, source, nodeKind } = item;
|
|
1386
|
+
const node = graph.nodes[nodeId];
|
|
1387
|
+
if (!node) continue;
|
|
1388
|
+
|
|
1389
|
+
// Try to resolve the container now that all nodes are created
|
|
1390
|
+
const resolved = resolveNameInScope(graph, scopeIndex, parentName, scopeNodeId, source);
|
|
1391
|
+
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
1392
|
+
|
|
1393
|
+
if (containerNodeId) {
|
|
1394
|
+
const containerNode = graph.nodes[containerNodeId];
|
|
1395
|
+
|
|
1396
|
+
// Validate container kind
|
|
1397
|
+
if (nodeKind === 'chapter' && containerNode.kind !== 'book') {
|
|
1398
|
+
graph.diagnostics.push({
|
|
1399
|
+
severity: 'error',
|
|
1400
|
+
message: `Chapter "${node.name}" must belong to a book, but "${parentName}" is a ${containerNode.kind}`,
|
|
1401
|
+
source,
|
|
1402
|
+
});
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Update the node's container
|
|
1407
|
+
node.container = containerNodeId;
|
|
1408
|
+
|
|
1409
|
+
// Move the node from its temporary parent to the correct container
|
|
1410
|
+
const oldParent = node.parent;
|
|
1411
|
+
if (oldParent && graph.nodes[oldParent]) {
|
|
1412
|
+
const oldParentChildren = graph.nodes[oldParent].children;
|
|
1413
|
+
const idx = oldParentChildren.indexOf(nodeId);
|
|
1414
|
+
if (idx !== -1) {
|
|
1415
|
+
oldParentChildren.splice(idx, 1);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Add to correct container
|
|
1420
|
+
node.parent = containerNodeId;
|
|
1421
|
+
if (!containerNode.children.includes(nodeId)) {
|
|
1422
|
+
containerNode.children.push(nodeId);
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
// Still not found - report error
|
|
1426
|
+
const kindLabel = nodeKind === 'chapter' ? 'book' : 'container';
|
|
1427
|
+
graph.diagnostics.push({
|
|
1428
|
+
severity: 'error',
|
|
1429
|
+
message: `Chapter "${node.name}" references unknown ${kindLabel} "${parentName}"`,
|
|
1430
|
+
source,
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1296
1436
|
/**
|
|
1297
1437
|
* @param {string | undefined} raw
|
|
1298
1438
|
* @returns {string | undefined}
|
|
@@ -1640,6 +1780,56 @@ function makeRelatesNodeId(universeName, a, b, index = 0) {
|
|
|
1640
1780
|
return `${universeName}:relates:${a}:${b}:${index}`;
|
|
1641
1781
|
}
|
|
1642
1782
|
|
|
1783
|
+
/**
|
|
1784
|
+
* Validates and applies ordering blocks after all nodes and relationships are established
|
|
1785
|
+
* This must run after all nodes are created because children may be defined outside their parent's body
|
|
1786
|
+
* (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
|
|
1787
|
+
* @param {UniverseGraph} graph
|
|
1788
|
+
*/
|
|
1789
|
+
function validateOrderingBlocks(graph) {
|
|
1790
|
+
for (const nodeId in graph.nodes) {
|
|
1791
|
+
const node = graph.nodes[nodeId];
|
|
1792
|
+
if (!node._pendingOrdering) {
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const orderingBlock = node._pendingOrdering;
|
|
1797
|
+
const orderingIdentifiers = orderingBlock.identifiers;
|
|
1798
|
+
|
|
1799
|
+
// Build a map of child names to node IDs for validation
|
|
1800
|
+
const childNameToNodeId = new Map();
|
|
1801
|
+
for (const childId of node.children) {
|
|
1802
|
+
const childNode = graph.nodes[childId];
|
|
1803
|
+
if (childNode) {
|
|
1804
|
+
childNameToNodeId.set(childNode.name, childId);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Validate ordering identifiers against actual children
|
|
1809
|
+
const validOrdering = [];
|
|
1810
|
+
for (const identifier of orderingIdentifiers) {
|
|
1811
|
+
if (childNameToNodeId.has(identifier)) {
|
|
1812
|
+
validOrdering.push(identifier);
|
|
1813
|
+
} else {
|
|
1814
|
+
// Unknown identifier - emit gentle warning
|
|
1815
|
+
graph.diagnostics.push({
|
|
1816
|
+
severity: 'warning',
|
|
1817
|
+
message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
|
|
1818
|
+
source: orderingBlock.source,
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Store ordering if there are any valid identifiers
|
|
1824
|
+
if (validOrdering.length > 0) {
|
|
1825
|
+
node.ordering = validOrdering;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Clean up temporary field
|
|
1829
|
+
delete node._pendingOrdering;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1643
1833
|
/**
|
|
1644
1834
|
* Creates an edge ID
|
|
1645
1835
|
* @param {string} universeName
|
package/src/ir.js
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
* @property {NodeId} [parent] - Tree parent node ID
|
|
62
62
|
* @property {NodeId[]} children - Child node IDs
|
|
63
63
|
* @property {NodeId} [container] - Container node ID (for book/chapter "in" relationship; always set for book/chapter nodes when resolved)
|
|
64
|
+
* @property {string[]} [ordering] - Optional ordering of direct children by identifier name
|
|
64
65
|
* @property {TextBlock} [describe] - Describe block if present
|
|
65
66
|
* @property {UnknownBlock[]} [unknownBlocks] - Unknown blocks if any
|
|
66
67
|
* @property {string[]} [references] - Reference IDs attached to this node
|
|
@@ -69,6 +70,7 @@
|
|
|
69
70
|
* @property {NodeId[]} [endpoints] - Endpoint node IDs (for relates nodes)
|
|
70
71
|
* @property {string[]} [unresolvedEndpoints] - Unresolved endpoint names (for relates nodes)
|
|
71
72
|
* @property {Record<string, FromView>} [from] - From blocks keyed by endpoint node ID (for relates nodes)
|
|
73
|
+
* @property {{ values: string[], source: SourceSpan }} [relationships] - Top-level relationships block (for relates nodes)
|
|
72
74
|
*/
|
|
73
75
|
|
|
74
76
|
/**
|
package/src/parser.js
CHANGED
|
@@ -16,6 +16,7 @@ import { mergeSpans } from './util/span.js';
|
|
|
16
16
|
* @typedef {import('./ast.js').DescribeBlock} DescribeBlock
|
|
17
17
|
* @typedef {import('./ast.js').UnknownBlock} UnknownBlock
|
|
18
18
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
19
|
+
* @typedef {import('./ast.js').OrderingBlock} OrderingBlock
|
|
19
20
|
* @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
|
|
20
21
|
* @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
|
|
21
22
|
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
@@ -177,7 +178,7 @@ class Parser {
|
|
|
177
178
|
const startToken = this.expect('KEYWORD', 'universe');
|
|
178
179
|
const nameToken = this.expect('IDENTIFIER');
|
|
179
180
|
const lbrace = this.expect('LBRACE');
|
|
180
|
-
const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'documentation']);
|
|
181
|
+
const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'ordering', 'documentation']);
|
|
181
182
|
const rbrace = this.expect('RBRACE');
|
|
182
183
|
|
|
183
184
|
return {
|
|
@@ -246,6 +247,8 @@ class Parser {
|
|
|
246
247
|
body.push(this.parseRelationships());
|
|
247
248
|
} else if (keyword === 'references' && allowedKeywords.includes('references')) {
|
|
248
249
|
body.push(this.parseReferences());
|
|
250
|
+
} else if (keyword === 'ordering' && allowedKeywords.includes('ordering')) {
|
|
251
|
+
body.push(this.parseOrdering());
|
|
249
252
|
} else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
|
|
250
253
|
body.push(this.parseDocumentation());
|
|
251
254
|
} else {
|
|
@@ -290,6 +293,7 @@ class Parser {
|
|
|
290
293
|
'concept',
|
|
291
294
|
'relates',
|
|
292
295
|
'references',
|
|
296
|
+
'ordering',
|
|
293
297
|
'documentation',
|
|
294
298
|
'repository',
|
|
295
299
|
'reference',
|
|
@@ -325,7 +329,7 @@ class Parser {
|
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
const lbrace = this.expect('LBRACE');
|
|
328
|
-
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
332
|
+
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
|
|
329
333
|
const rbrace = this.expect('RBRACE');
|
|
330
334
|
|
|
331
335
|
return {
|
|
@@ -357,7 +361,7 @@ class Parser {
|
|
|
357
361
|
}
|
|
358
362
|
|
|
359
363
|
const lbrace = this.expect('LBRACE');
|
|
360
|
-
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
364
|
+
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
|
|
361
365
|
const rbrace = this.expect('RBRACE');
|
|
362
366
|
|
|
363
367
|
return {
|
|
@@ -424,7 +428,7 @@ class Parser {
|
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
const lbrace = this.expect('LBRACE');
|
|
427
|
-
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
431
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
|
|
428
432
|
const rbrace = this.expect('RBRACE');
|
|
429
433
|
|
|
430
434
|
return {
|
|
@@ -449,7 +453,7 @@ class Parser {
|
|
|
449
453
|
this.expect('KEYWORD', 'and');
|
|
450
454
|
const bToken = this.expect('IDENTIFIER');
|
|
451
455
|
const lbrace = this.expect('LBRACE');
|
|
452
|
-
const body = this.parseBlockBody(['describe', 'title', 'from']);
|
|
456
|
+
const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
|
|
453
457
|
const rbrace = this.expect('RBRACE');
|
|
454
458
|
|
|
455
459
|
return {
|
|
@@ -641,6 +645,62 @@ class Parser {
|
|
|
641
645
|
};
|
|
642
646
|
}
|
|
643
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Parses an ordering block containing a list of identifiers
|
|
650
|
+
* @returns {OrderingBlock}
|
|
651
|
+
*/
|
|
652
|
+
parseOrdering() {
|
|
653
|
+
const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'ordering') : this.expect('IDENTIFIER', 'ordering');
|
|
654
|
+
const lbrace = this.expect('LBRACE');
|
|
655
|
+
const identifiers = [];
|
|
656
|
+
let depth = 1;
|
|
657
|
+
|
|
658
|
+
while (this.pos < this.tokens.length && !this.match('EOF')) {
|
|
659
|
+
if (this.match('LBRACE')) {
|
|
660
|
+
throw new Error(`Unexpected '{' in ordering block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (this.match('RBRACE')) {
|
|
664
|
+
depth -= 1;
|
|
665
|
+
this.advance();
|
|
666
|
+
if (depth === 0) {
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (depth !== 1) {
|
|
673
|
+
this.advance();
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (this.match('COMMA')) {
|
|
678
|
+
this.advance();
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
683
|
+
const identifierToken = this.advance();
|
|
684
|
+
if (identifierToken) {
|
|
685
|
+
identifiers.push(identifierToken.value);
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.advance();
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return {
|
|
694
|
+
kind: 'ordering',
|
|
695
|
+
identifiers,
|
|
696
|
+
source: {
|
|
697
|
+
file: this.file,
|
|
698
|
+
start: startToken.span.start,
|
|
699
|
+
end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
644
704
|
/**
|
|
645
705
|
* Parses a references block containing a list of identifier paths
|
|
646
706
|
* @returns {ReferencesBlock}
|