@sprig-and-prose/sprig-universe 0.3.1 → 0.3.3
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/ast.js +1 -1
- package/src/graph.js +122 -9
- package/src/ir.js +1 -0
- package/src/parser.js +1 -1
package/package.json
CHANGED
package/src/ast.js
CHANGED
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
* @property {string} kind - Always 'relates'
|
|
177
177
|
* @property {string} a - First endpoint text
|
|
178
178
|
* @property {string} b - Second endpoint text
|
|
179
|
-
* @property {Array<DescribeBlock | TitleBlock | FromBlock | UnknownBlock>} body - Body declarations
|
|
179
|
+
* @property {Array<DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | UnknownBlock>} body - Body declarations
|
|
180
180
|
* @property {SourceSpan} source - Source span
|
|
181
181
|
*/
|
|
182
182
|
|
package/src/graph.js
CHANGED
|
@@ -74,6 +74,8 @@ export function buildGraph(fileASTs) {
|
|
|
74
74
|
const pendingReferenceDecls = [];
|
|
75
75
|
/** @type {Array<{ nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string }>} */
|
|
76
76
|
const pendingReferenceAttachments = [];
|
|
77
|
+
/** @type {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} */
|
|
78
|
+
const pendingContainerResolutions = [];
|
|
77
79
|
|
|
78
80
|
// First pass: collect all universe names with their file locations for validation
|
|
79
81
|
const universeNameToFiles = new Map(); // universeName -> Set<file>
|
|
@@ -189,6 +191,7 @@ export function buildGraph(fileASTs) {
|
|
|
189
191
|
entityKinds,
|
|
190
192
|
pendingReferenceDecls,
|
|
191
193
|
pendingReferenceAttachments,
|
|
194
|
+
pendingContainerResolutions,
|
|
192
195
|
);
|
|
193
196
|
} else {
|
|
194
197
|
// First time seeing this universe - create node and process body
|
|
@@ -218,6 +221,7 @@ export function buildGraph(fileASTs) {
|
|
|
218
221
|
entityKinds,
|
|
219
222
|
pendingReferenceDecls,
|
|
220
223
|
pendingReferenceAttachments,
|
|
224
|
+
pendingContainerResolutions,
|
|
221
225
|
);
|
|
222
226
|
}
|
|
223
227
|
}
|
|
@@ -265,6 +269,7 @@ export function buildGraph(fileASTs) {
|
|
|
265
269
|
entityKinds,
|
|
266
270
|
pendingReferenceDecls,
|
|
267
271
|
pendingReferenceAttachments,
|
|
272
|
+
pendingContainerResolutions,
|
|
268
273
|
);
|
|
269
274
|
}
|
|
270
275
|
}
|
|
@@ -286,6 +291,9 @@ export function buildGraph(fileASTs) {
|
|
|
286
291
|
// Resolve edge endpoints
|
|
287
292
|
resolveEdges(graph, scopeIndex);
|
|
288
293
|
|
|
294
|
+
// Resolve container references (books/chapters) that may have forward references
|
|
295
|
+
resolveContainers(graph, scopeIndex, pendingContainerResolutions);
|
|
296
|
+
|
|
289
297
|
return graph;
|
|
290
298
|
}
|
|
291
299
|
|
|
@@ -496,6 +504,7 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
|
|
|
496
504
|
* @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
|
|
497
505
|
* @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
|
|
498
506
|
* @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
|
|
507
|
+
* @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
|
|
499
508
|
*/
|
|
500
509
|
function processBody(
|
|
501
510
|
graph,
|
|
@@ -511,6 +520,7 @@ function processBody(
|
|
|
511
520
|
entityKinds,
|
|
512
521
|
pendingReferenceDecls,
|
|
513
522
|
pendingReferenceAttachments,
|
|
523
|
+
pendingContainerResolutions,
|
|
514
524
|
) {
|
|
515
525
|
for (const decl of body) {
|
|
516
526
|
if (decl.kind === 'anthology') {
|
|
@@ -541,6 +551,7 @@ function processBody(
|
|
|
541
551
|
entityKinds,
|
|
542
552
|
pendingReferenceDecls,
|
|
543
553
|
pendingReferenceAttachments,
|
|
554
|
+
pendingContainerResolutions,
|
|
544
555
|
);
|
|
545
556
|
} else if (decl.kind === 'series') {
|
|
546
557
|
const nodeId = makeNodeId(universeName, 'series', decl.name);
|
|
@@ -574,6 +585,7 @@ function processBody(
|
|
|
574
585
|
entityKinds,
|
|
575
586
|
pendingReferenceDecls,
|
|
576
587
|
pendingReferenceAttachments,
|
|
588
|
+
pendingContainerResolutions,
|
|
577
589
|
);
|
|
578
590
|
} else if (decl.kind === 'book') {
|
|
579
591
|
const nodeId = makeNodeId(universeName, 'book', decl.name);
|
|
@@ -616,6 +628,7 @@ function processBody(
|
|
|
616
628
|
entityKinds,
|
|
617
629
|
pendingReferenceDecls,
|
|
618
630
|
pendingReferenceAttachments,
|
|
631
|
+
pendingContainerResolutions,
|
|
619
632
|
);
|
|
620
633
|
} else if (decl.kind === 'chapter') {
|
|
621
634
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
@@ -634,7 +647,9 @@ function processBody(
|
|
|
634
647
|
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
635
648
|
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
636
649
|
|
|
637
|
-
// Validate: chapter container must be a book
|
|
650
|
+
// Validate: chapter container must be a book (if found)
|
|
651
|
+
// Note: We defer "not found" validation until after all nodes are created
|
|
652
|
+
// to allow forward references (book defined after chapter)
|
|
638
653
|
if (containerNodeId) {
|
|
639
654
|
const containerNode = graph.nodes[containerNodeId];
|
|
640
655
|
if (containerNode && containerNode.kind !== 'book') {
|
|
@@ -644,12 +659,14 @@ function processBody(
|
|
|
644
659
|
source: decl.source,
|
|
645
660
|
});
|
|
646
661
|
}
|
|
647
|
-
} else if (decl.parentName) {
|
|
648
|
-
// Container not found
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
662
|
+
} else if (decl.parentName && pendingContainerResolutions) {
|
|
663
|
+
// Container not found yet - may be a forward reference, track for later resolution
|
|
664
|
+
pendingContainerResolutions.push({
|
|
665
|
+
nodeId,
|
|
666
|
+
parentName: decl.parentName,
|
|
667
|
+
scopeNodeId: currentNodeId,
|
|
652
668
|
source: decl.source,
|
|
669
|
+
nodeKind: 'chapter',
|
|
653
670
|
});
|
|
654
671
|
}
|
|
655
672
|
|
|
@@ -683,6 +700,7 @@ function processBody(
|
|
|
683
700
|
entityKinds,
|
|
684
701
|
pendingReferenceDecls,
|
|
685
702
|
pendingReferenceAttachments,
|
|
703
|
+
pendingContainerResolutions,
|
|
686
704
|
);
|
|
687
705
|
} else if (decl.kind === 'concept') {
|
|
688
706
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
@@ -731,10 +749,13 @@ function processBody(
|
|
|
731
749
|
finalRelatesNodeId = makeRelatesNodeId(universeName, decl.a, decl.b, index);
|
|
732
750
|
}
|
|
733
751
|
|
|
734
|
-
// Process relates body: extract describe, from blocks, and unknown blocks
|
|
752
|
+
// Process relates body: extract describe, relationships, from blocks, and unknown blocks
|
|
735
753
|
const describeBlocks = decl.body.filter((b) => b.kind === 'describe');
|
|
754
|
+
const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
|
|
736
755
|
const fromBlocks = decl.body.filter((b) => b.kind === 'from');
|
|
737
|
-
const unknownBlocks = decl.body.filter(
|
|
756
|
+
const unknownBlocks = decl.body.filter(
|
|
757
|
+
(b) => b.kind !== 'describe' && b.kind !== 'from' && b.kind !== 'relationships',
|
|
758
|
+
);
|
|
738
759
|
|
|
739
760
|
// Validate: only one top-level describe
|
|
740
761
|
if (describeBlocks.length > 1) {
|
|
@@ -745,6 +766,24 @@ function processBody(
|
|
|
745
766
|
});
|
|
746
767
|
}
|
|
747
768
|
|
|
769
|
+
// Validate: only one top-level relationships block
|
|
770
|
+
if (relationshipsBlocks.length > 1) {
|
|
771
|
+
graph.diagnostics.push({
|
|
772
|
+
severity: 'error',
|
|
773
|
+
message: `Multiple relationships blocks in relates "${decl.a} and ${decl.b}"`,
|
|
774
|
+
source: relationshipsBlocks[1].source,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Validate: relationships must have at least one value
|
|
779
|
+
if (relationshipsBlocks.length > 0 && relationshipsBlocks[0].values.length === 0) {
|
|
780
|
+
graph.diagnostics.push({
|
|
781
|
+
severity: 'error',
|
|
782
|
+
message: `Empty relationships block in relates "${decl.a} and ${decl.b}"`,
|
|
783
|
+
source: relationshipsBlocks[0].source,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
748
787
|
// Validate: from blocks must reference endpoints
|
|
749
788
|
const endpointSet = new Set([decl.a, decl.b]);
|
|
750
789
|
const fromByEndpoint = new Map();
|
|
@@ -790,6 +829,14 @@ function processBody(
|
|
|
790
829
|
};
|
|
791
830
|
}
|
|
792
831
|
|
|
832
|
+
// Add top-level relationships if present
|
|
833
|
+
if (relationshipsBlocks.length > 0) {
|
|
834
|
+
relatesNode.relationships = {
|
|
835
|
+
values: relationshipsBlocks[0].values,
|
|
836
|
+
source: relationshipsBlocks[0].source,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
793
840
|
// Process from blocks (will be resolved later)
|
|
794
841
|
relatesNode.from = {};
|
|
795
842
|
for (const fromBlock of fromBlocks) {
|
|
@@ -1293,6 +1340,65 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1293
1340
|
}
|
|
1294
1341
|
}
|
|
1295
1342
|
|
|
1343
|
+
/**
|
|
1344
|
+
* Resolves container references for chapters and books that may have forward references
|
|
1345
|
+
* @param {UniverseGraph} graph
|
|
1346
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1347
|
+
* @param {Array<{ nodeId: string, parentName: string, scopeNodeId: string, source: SourceSpan, nodeKind: 'chapter' | 'book' }>} pending
|
|
1348
|
+
*/
|
|
1349
|
+
function resolveContainers(graph, scopeIndex, pending) {
|
|
1350
|
+
for (const item of pending) {
|
|
1351
|
+
const { nodeId, parentName, scopeNodeId, source, nodeKind } = item;
|
|
1352
|
+
const node = graph.nodes[nodeId];
|
|
1353
|
+
if (!node) continue;
|
|
1354
|
+
|
|
1355
|
+
// Try to resolve the container now that all nodes are created
|
|
1356
|
+
const resolved = resolveNameInScope(graph, scopeIndex, parentName, scopeNodeId, source);
|
|
1357
|
+
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
1358
|
+
|
|
1359
|
+
if (containerNodeId) {
|
|
1360
|
+
const containerNode = graph.nodes[containerNodeId];
|
|
1361
|
+
|
|
1362
|
+
// Validate container kind
|
|
1363
|
+
if (nodeKind === 'chapter' && containerNode.kind !== 'book') {
|
|
1364
|
+
graph.diagnostics.push({
|
|
1365
|
+
severity: 'error',
|
|
1366
|
+
message: `Chapter "${node.name}" must belong to a book, but "${parentName}" is a ${containerNode.kind}`,
|
|
1367
|
+
source,
|
|
1368
|
+
});
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Update the node's container
|
|
1373
|
+
node.container = containerNodeId;
|
|
1374
|
+
|
|
1375
|
+
// Move the node from its temporary parent to the correct container
|
|
1376
|
+
const oldParent = node.parent;
|
|
1377
|
+
if (oldParent && graph.nodes[oldParent]) {
|
|
1378
|
+
const oldParentChildren = graph.nodes[oldParent].children;
|
|
1379
|
+
const idx = oldParentChildren.indexOf(nodeId);
|
|
1380
|
+
if (idx !== -1) {
|
|
1381
|
+
oldParentChildren.splice(idx, 1);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Add to correct container
|
|
1386
|
+
node.parent = containerNodeId;
|
|
1387
|
+
if (!containerNode.children.includes(nodeId)) {
|
|
1388
|
+
containerNode.children.push(nodeId);
|
|
1389
|
+
}
|
|
1390
|
+
} else {
|
|
1391
|
+
// Still not found - report error
|
|
1392
|
+
const kindLabel = nodeKind === 'chapter' ? 'book' : 'container';
|
|
1393
|
+
graph.diagnostics.push({
|
|
1394
|
+
severity: 'error',
|
|
1395
|
+
message: `Chapter "${node.name}" references unknown ${kindLabel} "${parentName}"`,
|
|
1396
|
+
source,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1296
1402
|
/**
|
|
1297
1403
|
* @param {string | undefined} raw
|
|
1298
1404
|
* @returns {string | undefined}
|
|
@@ -1372,9 +1478,16 @@ function resolveReferenceDecls(graph, pending, scopeIndex) {
|
|
|
1372
1478
|
unknownRepoCounts.set(decl.repositoryName, entry);
|
|
1373
1479
|
}
|
|
1374
1480
|
} else {
|
|
1481
|
+
// Build error message based on whether reference has a name
|
|
1482
|
+
let message;
|
|
1483
|
+
if (decl.name && displayName !== 'unnamed reference') {
|
|
1484
|
+
message = `Reference "${displayName}" must have url { ... } or declared as reference ${displayName} in <Repository>`;
|
|
1485
|
+
} else {
|
|
1486
|
+
message = `Reference must have url { ... } or reference in <Repository>`;
|
|
1487
|
+
}
|
|
1375
1488
|
graph.diagnostics.push({
|
|
1376
1489
|
severity: 'error',
|
|
1377
|
-
message
|
|
1490
|
+
message,
|
|
1378
1491
|
source: diagnosticSource(decl.source),
|
|
1379
1492
|
});
|
|
1380
1493
|
}
|
package/src/ir.js
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
* @property {NodeId[]} [endpoints] - Endpoint node IDs (for relates nodes)
|
|
70
70
|
* @property {string[]} [unresolvedEndpoints] - Unresolved endpoint names (for relates nodes)
|
|
71
71
|
* @property {Record<string, FromView>} [from] - From blocks keyed by endpoint node ID (for relates nodes)
|
|
72
|
+
* @property {{ values: string[], source: SourceSpan }} [relationships] - Top-level relationships block (for relates nodes)
|
|
72
73
|
*/
|
|
73
74
|
|
|
74
75
|
/**
|
package/src/parser.js
CHANGED
|
@@ -449,7 +449,7 @@ class Parser {
|
|
|
449
449
|
this.expect('KEYWORD', 'and');
|
|
450
450
|
const bToken = this.expect('IDENTIFIER');
|
|
451
451
|
const lbrace = this.expect('LBRACE');
|
|
452
|
-
const body = this.parseBlockBody(['describe', 'title', 'from']);
|
|
452
|
+
const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
|
|
453
453
|
const rbrace = this.expect('RBRACE');
|
|
454
454
|
|
|
455
455
|
return {
|