@sprig-and-prose/sprig-universe 0.3.3 → 0.4.0
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 +62 -8
- package/src/graph.js +454 -11
- package/src/ir.js +39 -2
- package/src/parser.js +714 -51
- package/src/scanner.js +1 -0
- package/test/aliases.test.js +91 -0
- package/test/fixtures/aliases-basic.prose +7 -0
- package/test/fixtures/aliases-conflict.prose +6 -0
- package/test/fixtures/aliases-no-leak.prose +5 -0
- package/test/fixtures/aliases-shadowing.prose +11 -0
- package/test/fixtures/aliases-single-line.prose +7 -0
- package/test/fixtures/relationship-errors.prose +20 -0
- package/test/fixtures/relationship-paired.prose +28 -0
- package/test/fixtures/relationship-scoped.prose +23 -0
- package/test/fixtures/relationship-symmetric.prose +18 -0
- package/test/fixtures/relationship-usage.prose +31 -0
package/src/graph.js
CHANGED
|
@@ -22,11 +22,13 @@ 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
|
|
28
29
|
* @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
|
|
29
30
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
31
|
+
* @typedef {import('./ast.js').OrderingBlock} OrderingBlock
|
|
30
32
|
* @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
|
|
31
33
|
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
32
34
|
* @typedef {import('./ast.js').DocumentBlock} DocumentBlock
|
|
@@ -65,6 +67,15 @@ export function buildGraph(fileASTs) {
|
|
|
65
67
|
// Track repositories and references per universe for scoped resolution
|
|
66
68
|
const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
|
|
67
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 = {};
|
|
68
79
|
|
|
69
80
|
// Track entity kinds for duplicate detection (references/repositories)
|
|
70
81
|
const entityKinds = new Map(); // id -> kind
|
|
@@ -189,6 +200,8 @@ export function buildGraph(fileASTs) {
|
|
|
189
200
|
repositoriesByUniverse.get(universeName),
|
|
190
201
|
referencesByUniverse.get(universeName),
|
|
191
202
|
entityKinds,
|
|
203
|
+
relationshipsByScope,
|
|
204
|
+
relationshipDeclsByUniverse,
|
|
192
205
|
pendingReferenceDecls,
|
|
193
206
|
pendingReferenceAttachments,
|
|
194
207
|
pendingContainerResolutions,
|
|
@@ -219,6 +232,8 @@ export function buildGraph(fileASTs) {
|
|
|
219
232
|
repositoriesByUniverse.get(universeName),
|
|
220
233
|
referencesByUniverse.get(universeName),
|
|
221
234
|
entityKinds,
|
|
235
|
+
relationshipsByScope,
|
|
236
|
+
relationshipDeclsByUniverse,
|
|
222
237
|
pendingReferenceDecls,
|
|
223
238
|
pendingReferenceAttachments,
|
|
224
239
|
pendingContainerResolutions,
|
|
@@ -255,6 +270,7 @@ export function buildGraph(fileASTs) {
|
|
|
255
270
|
continue;
|
|
256
271
|
}
|
|
257
272
|
}
|
|
273
|
+
// Process the declaration itself first
|
|
258
274
|
processBody(
|
|
259
275
|
graph,
|
|
260
276
|
universeName,
|
|
@@ -267,6 +283,8 @@ export function buildGraph(fileASTs) {
|
|
|
267
283
|
repositoriesByUniverse.get(universeName),
|
|
268
284
|
referencesByUniverse.get(universeName),
|
|
269
285
|
entityKinds,
|
|
286
|
+
relationshipsByScope,
|
|
287
|
+
relationshipDeclsByUniverse,
|
|
270
288
|
pendingReferenceDecls,
|
|
271
289
|
pendingReferenceAttachments,
|
|
272
290
|
pendingContainerResolutions,
|
|
@@ -294,6 +312,37 @@ export function buildGraph(fileASTs) {
|
|
|
294
312
|
// Resolve container references (books/chapters) that may have forward references
|
|
295
313
|
resolveContainers(graph, scopeIndex, pendingContainerResolutions);
|
|
296
314
|
|
|
315
|
+
// Validate and apply ordering blocks after all nodes and relationships are established
|
|
316
|
+
validateOrderingBlocks(graph);
|
|
317
|
+
|
|
318
|
+
// Extract asserted edges from both relationships blocks and relates nodes
|
|
319
|
+
graph.edgesAsserted = extractAssertedEdges(graph, scopeIndex);
|
|
320
|
+
|
|
321
|
+
// Preserve existing edges object format for relates (for code that still uses it)
|
|
322
|
+
graph.edgesByRelates = graph.edges;
|
|
323
|
+
|
|
324
|
+
// Normalize edges (add inverse edges for paired/symmetric relationships)
|
|
325
|
+
const universeNames = Array.from(universeNameToFiles.keys());
|
|
326
|
+
if (universeNames.length > 0) {
|
|
327
|
+
// For now, assume single universe (as per buildGraph contract)
|
|
328
|
+
const universeName = universeNames[0];
|
|
329
|
+
graph.edges = normalizeEdges(
|
|
330
|
+
graph.edgesAsserted,
|
|
331
|
+
relationshipDeclsByUniverse,
|
|
332
|
+
universeName,
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
graph.edges = [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Convert relationshipDeclsByUniverse Maps to plain objects for JSON serialization
|
|
339
|
+
// (already done above, but ensure all universes are initialized)
|
|
340
|
+
for (const [universeName, declsMap] of relationshipDeclsByUniverse.entries()) {
|
|
341
|
+
if (!graph.relationshipDecls[universeName]) {
|
|
342
|
+
graph.relationshipDecls[universeName] = {};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
297
346
|
return graph;
|
|
298
347
|
}
|
|
299
348
|
|
|
@@ -346,7 +395,7 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
|
|
|
346
395
|
*/
|
|
347
396
|
function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
348
397
|
// Handle qualified names (dot notation)
|
|
349
|
-
if (name.includes('.')) {
|
|
398
|
+
if (name && name.includes('.')) {
|
|
350
399
|
const parts = name.split('.');
|
|
351
400
|
// Start from universe and resolve each part
|
|
352
401
|
const universeName = startScopeNodeId.split(':')[0];
|
|
@@ -502,6 +551,8 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
|
|
|
502
551
|
* @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
|
|
503
552
|
* @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
|
|
504
553
|
* @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
|
|
554
|
+
* @param {Map<string, Map<string, RelationshipDecl>>} relationshipsByScope - Map for tracking relationship declarations per scope (required)
|
|
555
|
+
* @param {Map<string, Map<string, RelationshipDecl>>} relationshipDeclsByUniverse - Map for storing relationship declarations by universe for UI access
|
|
505
556
|
* @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
|
|
506
557
|
* @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
|
|
507
558
|
* @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
|
|
@@ -518,12 +569,97 @@ function processBody(
|
|
|
518
569
|
reposMap,
|
|
519
570
|
refsMap,
|
|
520
571
|
entityKinds,
|
|
572
|
+
relationshipsByScope,
|
|
573
|
+
relationshipDeclsByUniverse,
|
|
521
574
|
pendingReferenceDecls,
|
|
522
575
|
pendingReferenceAttachments,
|
|
523
576
|
pendingContainerResolutions,
|
|
524
577
|
) {
|
|
578
|
+
// Collect ordering blocks to process after all children are added
|
|
579
|
+
const orderingBlocks = [];
|
|
580
|
+
|
|
581
|
+
// Ensure relationshipsByScope is initialized as a Map
|
|
582
|
+
// This should always be provided, but check to prevent runtime errors
|
|
583
|
+
if (!relationshipsByScope) {
|
|
584
|
+
throw new Error(`relationshipsByScope is required but was ${relationshipsByScope}`);
|
|
585
|
+
}
|
|
586
|
+
if (typeof relationshipsByScope.has !== 'function' || typeof relationshipsByScope.set !== 'function' || typeof relationshipsByScope.get !== 'function') {
|
|
587
|
+
throw new Error(`relationshipsByScope must be a Map, got ${typeof relationshipsByScope} with has: ${typeof relationshipsByScope.has}, set: ${typeof relationshipsByScope.set}, get: ${typeof relationshipsByScope.get}`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Initialize relationship scope for current container if not exists
|
|
591
|
+
if (!relationshipsByScope.has(currentNodeId)) {
|
|
592
|
+
relationshipsByScope.set(currentNodeId, new Map());
|
|
593
|
+
}
|
|
594
|
+
// Inherit parent relationships
|
|
595
|
+
if (parentNodeId && relationshipsByScope.has(parentNodeId)) {
|
|
596
|
+
const parentRelationships = relationshipsByScope.get(parentNodeId);
|
|
597
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
598
|
+
if (parentRelationships && currentRelationships) {
|
|
599
|
+
for (const [id, rel] of parentRelationships) {
|
|
600
|
+
if (!currentRelationships.has(id)) {
|
|
601
|
+
currentRelationships.set(id, rel);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
525
607
|
for (const decl of body) {
|
|
526
|
-
if (decl.kind === '
|
|
608
|
+
if (decl.kind === 'relationship' && relationshipsByScope) {
|
|
609
|
+
// Track relationship declaration in current scope
|
|
610
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
611
|
+
if (currentRelationships) {
|
|
612
|
+
if (decl.type === 'symmetric' && decl.id) {
|
|
613
|
+
currentRelationships.set(decl.id, decl);
|
|
614
|
+
// Also store in universe-level map for UI access
|
|
615
|
+
if (!relationshipDeclsByUniverse.has(universeName)) {
|
|
616
|
+
relationshipDeclsByUniverse.set(universeName, new Map());
|
|
617
|
+
graph.relationshipDecls[universeName] = {};
|
|
618
|
+
}
|
|
619
|
+
const universeDecls = relationshipDeclsByUniverse.get(universeName);
|
|
620
|
+
if (universeDecls) {
|
|
621
|
+
universeDecls.set(decl.id, {
|
|
622
|
+
type: decl.type,
|
|
623
|
+
id: decl.id,
|
|
624
|
+
describe: decl.describe,
|
|
625
|
+
label: decl.label,
|
|
626
|
+
source: decl.source,
|
|
627
|
+
});
|
|
628
|
+
graph.relationshipDecls[universeName][decl.id] = {
|
|
629
|
+
type: decl.type,
|
|
630
|
+
id: decl.id,
|
|
631
|
+
describe: decl.describe,
|
|
632
|
+
label: decl.label,
|
|
633
|
+
source: decl.source,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
} else if (decl.type === 'paired') {
|
|
637
|
+
if (decl.leftId) currentRelationships.set(decl.leftId, decl);
|
|
638
|
+
if (decl.rightId) currentRelationships.set(decl.rightId, decl);
|
|
639
|
+
// Also store in universe-level map for UI access (store once per pair)
|
|
640
|
+
if (!relationshipDeclsByUniverse.has(universeName)) {
|
|
641
|
+
relationshipDeclsByUniverse.set(universeName, new Map());
|
|
642
|
+
graph.relationshipDecls[universeName] = {};
|
|
643
|
+
}
|
|
644
|
+
const universeDecls = relationshipDeclsByUniverse.get(universeName);
|
|
645
|
+
if (universeDecls && decl.leftId && decl.rightId) {
|
|
646
|
+
// Store under both IDs for easy lookup
|
|
647
|
+
const declModel = {
|
|
648
|
+
type: decl.type,
|
|
649
|
+
leftId: decl.leftId,
|
|
650
|
+
rightId: decl.rightId,
|
|
651
|
+
describe: decl.describe,
|
|
652
|
+
from: decl.from,
|
|
653
|
+
source: decl.source,
|
|
654
|
+
};
|
|
655
|
+
universeDecls.set(decl.leftId, declModel);
|
|
656
|
+
universeDecls.set(decl.rightId, declModel);
|
|
657
|
+
graph.relationshipDecls[universeName][decl.leftId] = declModel;
|
|
658
|
+
graph.relationshipDecls[universeName][decl.rightId] = declModel;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
} else if (decl.kind === 'anthology') {
|
|
527
663
|
const nodeId = makeNodeId(universeName, 'anthology', decl.name);
|
|
528
664
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
|
|
529
665
|
let actualParentNodeId = parentNodeId;
|
|
@@ -549,6 +685,8 @@ function processBody(
|
|
|
549
685
|
reposMap,
|
|
550
686
|
refsMap,
|
|
551
687
|
entityKinds,
|
|
688
|
+
relationshipsByScope,
|
|
689
|
+
relationshipDeclsByUniverse,
|
|
552
690
|
pendingReferenceDecls,
|
|
553
691
|
pendingReferenceAttachments,
|
|
554
692
|
pendingContainerResolutions,
|
|
@@ -583,6 +721,8 @@ function processBody(
|
|
|
583
721
|
reposMap,
|
|
584
722
|
refsMap,
|
|
585
723
|
entityKinds,
|
|
724
|
+
relationshipsByScope,
|
|
725
|
+
relationshipDeclsByUniverse,
|
|
586
726
|
pendingReferenceDecls,
|
|
587
727
|
pendingReferenceAttachments,
|
|
588
728
|
pendingContainerResolutions,
|
|
@@ -626,6 +766,8 @@ function processBody(
|
|
|
626
766
|
reposMap,
|
|
627
767
|
refsMap,
|
|
628
768
|
entityKinds,
|
|
769
|
+
relationshipsByScope,
|
|
770
|
+
relationshipDeclsByUniverse,
|
|
629
771
|
pendingReferenceDecls,
|
|
630
772
|
pendingReferenceAttachments,
|
|
631
773
|
pendingContainerResolutions,
|
|
@@ -698,6 +840,8 @@ function processBody(
|
|
|
698
840
|
reposMap,
|
|
699
841
|
refsMap,
|
|
700
842
|
entityKinds,
|
|
843
|
+
relationshipsByScope,
|
|
844
|
+
relationshipDeclsByUniverse,
|
|
701
845
|
pendingReferenceDecls,
|
|
702
846
|
pendingReferenceAttachments,
|
|
703
847
|
pendingContainerResolutions,
|
|
@@ -720,6 +864,27 @@ function processBody(
|
|
|
720
864
|
graph.nodes[nodeId] = node;
|
|
721
865
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
722
866
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
867
|
+
|
|
868
|
+
// Validate relationships blocks in concept body
|
|
869
|
+
const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
|
|
870
|
+
for (const relBlock of relationshipsBlocks) {
|
|
871
|
+
if (relBlock.entries && relationshipsByScope) {
|
|
872
|
+
// New syntax: validate relationship IDs
|
|
873
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
874
|
+
if (currentRelationships) {
|
|
875
|
+
for (const entry of relBlock.entries) {
|
|
876
|
+
if (!currentRelationships.has(entry.relationshipId)) {
|
|
877
|
+
graph.diagnostics.push({
|
|
878
|
+
severity: 'error',
|
|
879
|
+
message: `Undeclared relationship identifier "${entry.relationshipId}" in relationships block`,
|
|
880
|
+
source: relBlock.source,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
723
888
|
processBody(
|
|
724
889
|
graph,
|
|
725
890
|
universeName,
|
|
@@ -732,8 +897,10 @@ function processBody(
|
|
|
732
897
|
reposMap,
|
|
733
898
|
refsMap,
|
|
734
899
|
entityKinds,
|
|
900
|
+
relationshipsByScope,
|
|
735
901
|
pendingReferenceDecls,
|
|
736
902
|
pendingReferenceAttachments,
|
|
903
|
+
pendingContainerResolutions,
|
|
737
904
|
);
|
|
738
905
|
} else if (decl.kind === 'relates') {
|
|
739
906
|
// Check for duplicate relates in reverse order
|
|
@@ -818,6 +985,8 @@ function processBody(
|
|
|
818
985
|
source: decl.source,
|
|
819
986
|
endpoints: [], // Will be populated during resolution
|
|
820
987
|
unresolvedEndpoints: [decl.a, decl.b], // Will be cleared during resolution
|
|
988
|
+
spelledKind: decl.spelledKind,
|
|
989
|
+
aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
|
|
821
990
|
};
|
|
822
991
|
|
|
823
992
|
// Add top-level describe if present
|
|
@@ -944,6 +1113,9 @@ function processBody(
|
|
|
944
1113
|
} else if (decl.kind === 'title') {
|
|
945
1114
|
// Title blocks are attached to their parent node
|
|
946
1115
|
// This is handled in createNode
|
|
1116
|
+
} else if (decl.kind === 'relationships') {
|
|
1117
|
+
// Relationships blocks are attached to their parent node
|
|
1118
|
+
// This is handled in createNode
|
|
947
1119
|
} else if (decl.kind === 'references') {
|
|
948
1120
|
if (pendingReferenceAttachments) {
|
|
949
1121
|
pendingReferenceAttachments.push({
|
|
@@ -952,6 +1124,9 @@ function processBody(
|
|
|
952
1124
|
universeName,
|
|
953
1125
|
});
|
|
954
1126
|
}
|
|
1127
|
+
} else if (decl.kind === 'ordering') {
|
|
1128
|
+
// Collect ordering blocks to process after all children are added
|
|
1129
|
+
orderingBlocks.push(decl);
|
|
955
1130
|
} else if (decl.kind === 'documentation') {
|
|
956
1131
|
// DocumentationBlock - attach to current node
|
|
957
1132
|
const currentNode = graph.nodes[currentNodeId];
|
|
@@ -1102,19 +1277,52 @@ function processBody(
|
|
|
1102
1277
|
graph.documentsByName[universeName][docName] = docModel;
|
|
1103
1278
|
}
|
|
1104
1279
|
}
|
|
1280
|
+
} else if (decl.kind === 'relationship') {
|
|
1281
|
+
// Relationship declarations are tracked in relationshipsByScope but don't create nodes
|
|
1282
|
+
// They're just declarations that can be referenced in relationships blocks
|
|
1105
1283
|
} else {
|
|
1106
1284
|
// UnknownBlock - attach to current node (the node whose body contains this block)
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1285
|
+
// Only process if it's actually an UnknownBlock (has keyword property, not kind)
|
|
1286
|
+
// Blocks with 'kind' property are known block types that should be handled above
|
|
1287
|
+
if (decl.keyword && !decl.kind) {
|
|
1288
|
+
const currentNode = graph.nodes[currentNodeId];
|
|
1289
|
+
if (!currentNode.unknownBlocks) {
|
|
1290
|
+
currentNode.unknownBlocks = [];
|
|
1291
|
+
}
|
|
1292
|
+
currentNode.unknownBlocks.push({
|
|
1293
|
+
keyword: decl.keyword,
|
|
1294
|
+
raw: decl.raw,
|
|
1295
|
+
normalized: normalizeProseBlock(decl.raw),
|
|
1296
|
+
source: decl.source,
|
|
1297
|
+
});
|
|
1110
1298
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1299
|
+
// If it has a 'kind' property, it's a known block type that should have been handled above
|
|
1300
|
+
// Skip it silently (it may have been handled elsewhere, e.g., in createNode)
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Store ordering blocks for later validation (after all nodes are created)
|
|
1305
|
+
// We'll validate them in a separate pass after the entire graph is built
|
|
1306
|
+
// This is necessary because children may be defined outside their parent's body
|
|
1307
|
+
// (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
|
|
1308
|
+
if (orderingBlocks.length > 0) {
|
|
1309
|
+
const currentNode = graph.nodes[currentNodeId];
|
|
1310
|
+
if (!currentNode) {
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Validate: only one ordering block per container
|
|
1315
|
+
if (orderingBlocks.length > 1) {
|
|
1316
|
+
graph.diagnostics.push({
|
|
1317
|
+
severity: 'warning',
|
|
1318
|
+
message: `Multiple ordering blocks in ${currentNode.kind} "${currentNode.name}". Using the first one.`,
|
|
1319
|
+
source: orderingBlocks[1].source,
|
|
1116
1320
|
});
|
|
1117
1321
|
}
|
|
1322
|
+
|
|
1323
|
+
// Store ordering block temporarily on the node for later validation
|
|
1324
|
+
// We'll process this in validateOrderingBlocks() after all nodes are created
|
|
1325
|
+
currentNode._pendingOrdering = orderingBlocks[0];
|
|
1118
1326
|
}
|
|
1119
1327
|
}
|
|
1120
1328
|
|
|
@@ -1136,6 +1344,8 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
1136
1344
|
parent: parentNodeId,
|
|
1137
1345
|
children: [],
|
|
1138
1346
|
source: decl.source,
|
|
1347
|
+
spelledKind: decl.spelledKind,
|
|
1348
|
+
aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
|
|
1139
1349
|
};
|
|
1140
1350
|
|
|
1141
1351
|
// Always set container for book/chapter nodes (may be undefined if not resolved)
|
|
@@ -1166,6 +1376,34 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
1166
1376
|
}
|
|
1167
1377
|
}
|
|
1168
1378
|
|
|
1379
|
+
// Extract relationships block if present
|
|
1380
|
+
const relationshipsBlock = decl.body?.find((b) => b.kind === 'relationships');
|
|
1381
|
+
if (relationshipsBlock) {
|
|
1382
|
+
if (relationshipsBlock.entries) {
|
|
1383
|
+
// New syntax: relationship ID + targets
|
|
1384
|
+
node.relationships = {
|
|
1385
|
+
entries: relationshipsBlock.entries.map((entry) => ({
|
|
1386
|
+
relationshipId: entry.relationshipId,
|
|
1387
|
+
targets: entry.targets.map((target) => ({
|
|
1388
|
+
id: target.id,
|
|
1389
|
+
metadata: target.metadata ? {
|
|
1390
|
+
raw: target.metadata.raw,
|
|
1391
|
+
normalized: normalizeProseBlock(target.metadata.raw),
|
|
1392
|
+
source: target.metadata.source,
|
|
1393
|
+
} : undefined,
|
|
1394
|
+
})),
|
|
1395
|
+
})),
|
|
1396
|
+
source: relationshipsBlock.source,
|
|
1397
|
+
};
|
|
1398
|
+
} else if (relationshipsBlock.values) {
|
|
1399
|
+
// String literals syntax (for relates blocks)
|
|
1400
|
+
node.relationships = {
|
|
1401
|
+
values: relationshipsBlock.values,
|
|
1402
|
+
source: relationshipsBlock.source,
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1169
1407
|
// Note: UnknownBlocks are handled in processBody and attached to the parent node
|
|
1170
1408
|
// They are not extracted here to avoid duplication
|
|
1171
1409
|
|
|
@@ -1243,7 +1481,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1243
1481
|
}
|
|
1244
1482
|
}
|
|
1245
1483
|
|
|
1246
|
-
// Resolve edges (
|
|
1484
|
+
// Resolve edges (relates edges in object format)
|
|
1247
1485
|
for (const edgeId in graph.edges) {
|
|
1248
1486
|
const edge = graph.edges[edgeId];
|
|
1249
1487
|
const universeName = edgeId.split(':')[0];
|
|
@@ -1399,6 +1637,161 @@ function resolveContainers(graph, scopeIndex, pending) {
|
|
|
1399
1637
|
}
|
|
1400
1638
|
}
|
|
1401
1639
|
|
|
1640
|
+
/**
|
|
1641
|
+
* Extracts asserted edges from both relationships {} blocks and relates nodes
|
|
1642
|
+
* @param {UniverseGraph} graph - The graph
|
|
1643
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
1644
|
+
* @returns {import('./ir.js').EdgeAssertedModel[]}
|
|
1645
|
+
*/
|
|
1646
|
+
function extractAssertedEdges(graph, scopeIndex) {
|
|
1647
|
+
/** @type {import('./ir.js').EdgeAssertedModel[]} */
|
|
1648
|
+
const assertedEdges = [];
|
|
1649
|
+
|
|
1650
|
+
// Extract from relationships {} blocks (new-style adjacency lists)
|
|
1651
|
+
for (const nodeId in graph.nodes) {
|
|
1652
|
+
const node = graph.nodes[nodeId];
|
|
1653
|
+
if (!node.relationships || !node.relationships.entries) continue;
|
|
1654
|
+
|
|
1655
|
+
// Resolve each target and create asserted edge
|
|
1656
|
+
for (const entry of node.relationships.entries) {
|
|
1657
|
+
for (const target of entry.targets) {
|
|
1658
|
+
// Resolve target ID using scope resolution
|
|
1659
|
+
const resolved = resolveNameInScope(
|
|
1660
|
+
graph,
|
|
1661
|
+
scopeIndex,
|
|
1662
|
+
target.id,
|
|
1663
|
+
nodeId,
|
|
1664
|
+
node.relationships.source,
|
|
1665
|
+
);
|
|
1666
|
+
const targetNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
1667
|
+
if (!targetNodeId) continue;
|
|
1668
|
+
|
|
1669
|
+
assertedEdges.push({
|
|
1670
|
+
from: nodeId,
|
|
1671
|
+
via: entry.relationshipId,
|
|
1672
|
+
to: targetNodeId,
|
|
1673
|
+
meta: target.metadata,
|
|
1674
|
+
source: node.relationships.source,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Extract from relates nodes (bidirectional relationships)
|
|
1681
|
+
for (const nodeId in graph.nodes) {
|
|
1682
|
+
const node = graph.nodes[nodeId];
|
|
1683
|
+
if (node.kind !== 'relates' || !node.endpoints || node.endpoints.length !== 2) continue;
|
|
1684
|
+
|
|
1685
|
+
const [endpointA, endpointB] = node.endpoints;
|
|
1686
|
+
|
|
1687
|
+
// Relates are bidirectional - extract both directions as asserted edges
|
|
1688
|
+
// Use the relationship label from the relates node if available
|
|
1689
|
+
const viaLabel = node.relationships?.values?.[0] || 'related to';
|
|
1690
|
+
|
|
1691
|
+
// Create edge A -> B
|
|
1692
|
+
assertedEdges.push({
|
|
1693
|
+
from: endpointA,
|
|
1694
|
+
via: viaLabel, // Note: relates don't use relationship declarations, they use string labels
|
|
1695
|
+
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,
|
|
1706
|
+
source: node.source,
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return assertedEdges;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Normalizes edges by adding inverse edges for paired/symmetric relationships
|
|
1715
|
+
* @param {import('./ir.js').EdgeAssertedModel[]} assertedEdges - Asserted edges
|
|
1716
|
+
* @param {Map<string, Map<string, import('./ast.js').RelationshipDecl>>} relationshipDecls - Relationship declarations by universe
|
|
1717
|
+
* @param {string} universeName - Universe name
|
|
1718
|
+
* @returns {import('./ir.js').NormalizedEdgeModel[]}
|
|
1719
|
+
*/
|
|
1720
|
+
function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
|
|
1721
|
+
/** @type {import('./ir.js').NormalizedEdgeModel[]} */
|
|
1722
|
+
const normalizedEdges = [];
|
|
1723
|
+
const relDeclsMap = relationshipDecls.get(universeName);
|
|
1724
|
+
const relDecls = relDeclsMap ? Object.fromEntries(relDeclsMap) : {};
|
|
1725
|
+
const seenEdges = new Set(); // Track edges to avoid duplicates
|
|
1726
|
+
|
|
1727
|
+
for (const asserted of assertedEdges) {
|
|
1728
|
+
const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
|
|
1729
|
+
|
|
1730
|
+
// Skip if we've already added this exact edge (can happen with relates bidirectional edges)
|
|
1731
|
+
if (seenEdges.has(edgeKey)) continue;
|
|
1732
|
+
seenEdges.add(edgeKey);
|
|
1733
|
+
|
|
1734
|
+
// Get relationship declaration if this is a declared relationship
|
|
1735
|
+
// Note: relates edges use string labels, not relationship IDs, so they won't have a relDecl
|
|
1736
|
+
const relDecl = relDecls[asserted.via];
|
|
1737
|
+
|
|
1738
|
+
// Add asserted edge
|
|
1739
|
+
normalizedEdges.push({
|
|
1740
|
+
from: asserted.from,
|
|
1741
|
+
via: asserted.via,
|
|
1742
|
+
to: asserted.to,
|
|
1743
|
+
asserted: true,
|
|
1744
|
+
sourceRefs: [asserted.source],
|
|
1745
|
+
meta: asserted.meta,
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
// Add inverse edge if applicable (only for declared relationships, not relates)
|
|
1749
|
+
// Relates are already bidirectional, so both directions are in assertedEdges
|
|
1750
|
+
if (relDecl) {
|
|
1751
|
+
if (relDecl.type === 'paired') {
|
|
1752
|
+
// Determine inverse side
|
|
1753
|
+
const inverseVia =
|
|
1754
|
+
asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
|
|
1755
|
+
|
|
1756
|
+
if (inverseVia) {
|
|
1757
|
+
const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
|
|
1758
|
+
// Only add inverse if not already present as an asserted edge
|
|
1759
|
+
if (!seenEdges.has(inverseKey)) {
|
|
1760
|
+
normalizedEdges.push({
|
|
1761
|
+
from: asserted.to,
|
|
1762
|
+
via: inverseVia,
|
|
1763
|
+
to: asserted.from,
|
|
1764
|
+
asserted: false,
|
|
1765
|
+
sourceRefs: [asserted.source], // Points back to asserted edge
|
|
1766
|
+
meta: asserted.meta, // Copy metadata
|
|
1767
|
+
});
|
|
1768
|
+
seenEdges.add(inverseKey);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
} else if (relDecl.type === 'symmetric') {
|
|
1772
|
+
// Symmetric: add reverse edge with same relationship ID
|
|
1773
|
+
const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
|
|
1774
|
+
// Only add reverse if not already present as an asserted edge
|
|
1775
|
+
if (!seenEdges.has(reverseKey)) {
|
|
1776
|
+
normalizedEdges.push({
|
|
1777
|
+
from: asserted.to,
|
|
1778
|
+
via: asserted.via,
|
|
1779
|
+
to: asserted.from,
|
|
1780
|
+
asserted: false,
|
|
1781
|
+
sourceRefs: [asserted.source],
|
|
1782
|
+
meta: asserted.meta,
|
|
1783
|
+
});
|
|
1784
|
+
seenEdges.add(reverseKey);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
// 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
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
return normalizedEdges;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1402
1795
|
/**
|
|
1403
1796
|
* @param {string | undefined} raw
|
|
1404
1797
|
* @returns {string | undefined}
|
|
@@ -1746,6 +2139,56 @@ function makeRelatesNodeId(universeName, a, b, index = 0) {
|
|
|
1746
2139
|
return `${universeName}:relates:${a}:${b}:${index}`;
|
|
1747
2140
|
}
|
|
1748
2141
|
|
|
2142
|
+
/**
|
|
2143
|
+
* Validates and applies ordering blocks after all nodes and relationships are established
|
|
2144
|
+
* This must run after all nodes are created because children may be defined outside their parent's body
|
|
2145
|
+
* (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
|
|
2146
|
+
* @param {UniverseGraph} graph
|
|
2147
|
+
*/
|
|
2148
|
+
function validateOrderingBlocks(graph) {
|
|
2149
|
+
for (const nodeId in graph.nodes) {
|
|
2150
|
+
const node = graph.nodes[nodeId];
|
|
2151
|
+
if (!node._pendingOrdering) {
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
const orderingBlock = node._pendingOrdering;
|
|
2156
|
+
const orderingIdentifiers = orderingBlock.identifiers;
|
|
2157
|
+
|
|
2158
|
+
// Build a map of child names to node IDs for validation
|
|
2159
|
+
const childNameToNodeId = new Map();
|
|
2160
|
+
for (const childId of node.children) {
|
|
2161
|
+
const childNode = graph.nodes[childId];
|
|
2162
|
+
if (childNode) {
|
|
2163
|
+
childNameToNodeId.set(childNode.name, childId);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Validate ordering identifiers against actual children
|
|
2168
|
+
const validOrdering = [];
|
|
2169
|
+
for (const identifier of orderingIdentifiers) {
|
|
2170
|
+
if (childNameToNodeId.has(identifier)) {
|
|
2171
|
+
validOrdering.push(identifier);
|
|
2172
|
+
} else {
|
|
2173
|
+
// Unknown identifier - emit gentle warning
|
|
2174
|
+
graph.diagnostics.push({
|
|
2175
|
+
severity: 'warning',
|
|
2176
|
+
message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
|
|
2177
|
+
source: orderingBlock.source,
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Store ordering if there are any valid identifiers
|
|
2183
|
+
if (validOrdering.length > 0) {
|
|
2184
|
+
node.ordering = validOrdering;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// Clean up temporary field
|
|
2188
|
+
delete node._pendingOrdering;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
1749
2192
|
/**
|
|
1750
2193
|
* Creates an edge ID
|
|
1751
2194
|
* @param {string} universeName
|