@sprig-and-prose/sprig-universe 0.3.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/sprig-universe",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
package/src/ast.js CHANGED
@@ -113,25 +113,31 @@
113
113
  /**
114
114
  * @typedef {Object} UniverseDecl
115
115
  * @property {string} kind - Always 'universe'
116
+ * @property {string} spelledKind - Kind token as written (may be alias)
116
117
  * @property {string} name - Universe name
117
- * @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
118
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
119
+ * @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | RelationshipDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
118
120
  * @property {SourceSpan} source - Source span
119
121
  */
120
122
 
121
123
  /**
122
124
  * @typedef {Object} AnthologyDecl
123
125
  * @property {string} kind - Always 'anthology'
126
+ * @property {string} spelledKind - Kind token as written (may be alias)
124
127
  * @property {string} name - Anthology name
125
128
  * @property {string} [parentName] - Optional parent universe name (from "in UniverseName")
126
- * @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
129
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
130
+ * @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | RelationshipDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
127
131
  * @property {SourceSpan} source - Source span
128
132
  */
129
133
 
130
134
  /**
131
135
  * @typedef {Object} SeriesDecl
132
136
  * @property {string} kind - Always 'series'
137
+ * @property {string} spelledKind - Kind token as written (may be alias)
133
138
  * @property {string} name - Series name
134
139
  * @property {string} [parentName] - Optional parent anthology name (from "in AnthologyName")
140
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
135
141
  * @property {Array<BookDecl | ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
136
142
  * @property {SourceSpan} source - Source span
137
143
  */
@@ -139,8 +145,10 @@
139
145
  /**
140
146
  * @typedef {Object} BookDecl
141
147
  * @property {string} kind - Always 'book'
148
+ * @property {string} spelledKind - Kind token as written (may be alias)
142
149
  * @property {string} name - Book name
143
150
  * @property {string} [parentName] - Optional parent name (from "in ParentName")
151
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
144
152
  * @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
145
153
  * @property {SourceSpan} source - Source span
146
154
  */
@@ -148,8 +156,10 @@
148
156
  /**
149
157
  * @typedef {Object} ChapterDecl
150
158
  * @property {string} kind - Always 'chapter'
159
+ * @property {string} spelledKind - Kind token as written (may be alias)
151
160
  * @property {string} name - Chapter name
152
161
  * @property {string} parentName - Parent name (from "in ParentName")
162
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
153
163
  * @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
154
164
  * @property {SourceSpan} source - Source span
155
165
  */
@@ -157,16 +167,31 @@
157
167
  /**
158
168
  * @typedef {Object} ConceptDecl
159
169
  * @property {string} kind - Always 'concept'
170
+ * @property {string} spelledKind - Kind token as written (may be alias)
160
171
  * @property {string} name - Concept name
161
172
  * @property {string} [parentName] - Optional parent name (from "in ParentName")
173
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
162
174
  * @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
163
175
  * @property {SourceSpan} source - Source span
164
176
  */
165
177
 
178
+ /**
179
+ * @typedef {Object} RelationshipTarget
180
+ * @property {string} id - Target identifier
181
+ * @property {DescribeBlock} [metadata] - Optional metadata block (describe) attached to this target
182
+ */
183
+
184
+ /**
185
+ * @typedef {Object} RelationshipEntry
186
+ * @property {string} relationshipId - Relationship identifier being used
187
+ * @property {RelationshipTarget[]} targets - Array of target identifiers with optional metadata
188
+ */
189
+
166
190
  /**
167
191
  * @typedef {Object} RelationshipsBlock
168
192
  * @property {string} kind - Always 'relationships'
169
- * @property {string[]} values - Array of string literal values
193
+ * @property {RelationshipEntry[]} entries - Array of relationship entries (new syntax)
194
+ * @property {string[]} [values] - Array of string literal values (legacy syntax for relates blocks)
170
195
  * @property {SourceSpan} source - Source span
171
196
  */
172
197
 
@@ -178,11 +203,33 @@
178
203
  * @property {SourceSpan} source - Source span
179
204
  */
180
205
 
206
+ /**
207
+ * @typedef {Object} RelationshipFromSide
208
+ * @property {string} [label] - Optional label string
209
+ * @property {DescribeBlock} [describe] - Optional describe block
210
+ */
211
+
212
+ /**
213
+ * @typedef {Object} RelationshipDecl
214
+ * @property {string} kind - Always 'relationship'
215
+ * @property {string} spelledKind - Kind token as written (may be alias)
216
+ * @property {'symmetric' | 'paired'} type - Relationship type
217
+ * @property {string} [id] - Relationship identifier (for symmetric)
218
+ * @property {string} [leftId] - Left side identifier (for paired)
219
+ * @property {string} [rightId] - Right side identifier (for paired)
220
+ * @property {DescribeBlock} [describe] - Optional pair-level describe block
221
+ * @property {string} [label] - Optional label (for symmetric)
222
+ * @property {Record<string, RelationshipFromSide>} [from] - Per-side metadata (for paired)
223
+ * @property {SourceSpan} source - Source span
224
+ */
225
+
181
226
  /**
182
227
  * @typedef {Object} RelatesDecl
183
228
  * @property {string} kind - Always 'relates'
229
+ * @property {string} spelledKind - Kind token as written (may be alias)
184
230
  * @property {string} a - First endpoint text
185
231
  * @property {string} b - Second endpoint text
232
+ * @property {Record<string, string>} [aliases] - Alias mappings declared in this scope
186
233
  * @property {Array<DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | UnknownBlock>} body - Body declarations
187
234
  * @property {SourceSpan} source - Source span
188
235
  */
@@ -248,7 +295,7 @@
248
295
  */
249
296
 
250
297
  /**
251
- * @typedef {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | NoteBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | ReferenceDecl | DocumentationBlock | DocumentBlock | NamedDocumentBlock | RepositoryDecl | UnknownBlock | SceneDecl | UsingBlock | ActorDecl | TypeBlock | IdentityBlock | SourceBlock | TransformsBlock | TransformBlock} ASTNode
298
+ * @typedef {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | RelationshipDecl | DescribeBlock | NoteBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | ReferenceDecl | DocumentationBlock | DocumentBlock | NamedDocumentBlock | RepositoryDecl | UnknownBlock | SceneDecl | UsingBlock | ActorDecl | TypeBlock | IdentityBlock | SourceBlock | TransformsBlock | TransformBlock} ASTNode
252
299
  */
253
300
 
254
301
  /**
package/src/graph.js CHANGED
@@ -22,6 +22,7 @@ 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
@@ -66,6 +67,15 @@ export function buildGraph(fileASTs) {
66
67
  // Track repositories and references per universe for scoped resolution
67
68
  const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
68
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 = {};
69
79
 
70
80
  // Track entity kinds for duplicate detection (references/repositories)
71
81
  const entityKinds = new Map(); // id -> kind
@@ -190,6 +200,8 @@ export function buildGraph(fileASTs) {
190
200
  repositoriesByUniverse.get(universeName),
191
201
  referencesByUniverse.get(universeName),
192
202
  entityKinds,
203
+ relationshipsByScope,
204
+ relationshipDeclsByUniverse,
193
205
  pendingReferenceDecls,
194
206
  pendingReferenceAttachments,
195
207
  pendingContainerResolutions,
@@ -220,6 +232,8 @@ export function buildGraph(fileASTs) {
220
232
  repositoriesByUniverse.get(universeName),
221
233
  referencesByUniverse.get(universeName),
222
234
  entityKinds,
235
+ relationshipsByScope,
236
+ relationshipDeclsByUniverse,
223
237
  pendingReferenceDecls,
224
238
  pendingReferenceAttachments,
225
239
  pendingContainerResolutions,
@@ -256,6 +270,7 @@ export function buildGraph(fileASTs) {
256
270
  continue;
257
271
  }
258
272
  }
273
+ // Process the declaration itself first
259
274
  processBody(
260
275
  graph,
261
276
  universeName,
@@ -268,6 +283,8 @@ export function buildGraph(fileASTs) {
268
283
  repositoriesByUniverse.get(universeName),
269
284
  referencesByUniverse.get(universeName),
270
285
  entityKinds,
286
+ relationshipsByScope,
287
+ relationshipDeclsByUniverse,
271
288
  pendingReferenceDecls,
272
289
  pendingReferenceAttachments,
273
290
  pendingContainerResolutions,
@@ -298,6 +315,34 @@ export function buildGraph(fileASTs) {
298
315
  // Validate and apply ordering blocks after all nodes and relationships are established
299
316
  validateOrderingBlocks(graph);
300
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
+
301
346
  return graph;
302
347
  }
303
348
 
@@ -350,7 +395,7 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
350
395
  */
351
396
  function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
352
397
  // Handle qualified names (dot notation)
353
- if (name.includes('.')) {
398
+ if (name && name.includes('.')) {
354
399
  const parts = name.split('.');
355
400
  // Start from universe and resolve each part
356
401
  const universeName = startScopeNodeId.split(':')[0];
@@ -506,6 +551,8 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
506
551
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
507
552
  * @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
508
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
509
556
  * @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
510
557
  * @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
511
558
  * @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
@@ -522,6 +569,8 @@ function processBody(
522
569
  reposMap,
523
570
  refsMap,
524
571
  entityKinds,
572
+ relationshipsByScope,
573
+ relationshipDeclsByUniverse,
525
574
  pendingReferenceDecls,
526
575
  pendingReferenceAttachments,
527
576
  pendingContainerResolutions,
@@ -529,8 +578,88 @@ function processBody(
529
578
  // Collect ordering blocks to process after all children are added
530
579
  const orderingBlocks = [];
531
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
+
532
607
  for (const decl of body) {
533
- if (decl.kind === 'anthology') {
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') {
534
663
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
535
664
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
536
665
  let actualParentNodeId = parentNodeId;
@@ -556,6 +685,8 @@ function processBody(
556
685
  reposMap,
557
686
  refsMap,
558
687
  entityKinds,
688
+ relationshipsByScope,
689
+ relationshipDeclsByUniverse,
559
690
  pendingReferenceDecls,
560
691
  pendingReferenceAttachments,
561
692
  pendingContainerResolutions,
@@ -590,6 +721,8 @@ function processBody(
590
721
  reposMap,
591
722
  refsMap,
592
723
  entityKinds,
724
+ relationshipsByScope,
725
+ relationshipDeclsByUniverse,
593
726
  pendingReferenceDecls,
594
727
  pendingReferenceAttachments,
595
728
  pendingContainerResolutions,
@@ -633,6 +766,8 @@ function processBody(
633
766
  reposMap,
634
767
  refsMap,
635
768
  entityKinds,
769
+ relationshipsByScope,
770
+ relationshipDeclsByUniverse,
636
771
  pendingReferenceDecls,
637
772
  pendingReferenceAttachments,
638
773
  pendingContainerResolutions,
@@ -705,6 +840,8 @@ function processBody(
705
840
  reposMap,
706
841
  refsMap,
707
842
  entityKinds,
843
+ relationshipsByScope,
844
+ relationshipDeclsByUniverse,
708
845
  pendingReferenceDecls,
709
846
  pendingReferenceAttachments,
710
847
  pendingContainerResolutions,
@@ -727,6 +864,27 @@ function processBody(
727
864
  graph.nodes[nodeId] = node;
728
865
  graph.nodes[actualParentNodeId].children.push(nodeId);
729
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
+
730
888
  processBody(
731
889
  graph,
732
890
  universeName,
@@ -739,8 +897,10 @@ function processBody(
739
897
  reposMap,
740
898
  refsMap,
741
899
  entityKinds,
900
+ relationshipsByScope,
742
901
  pendingReferenceDecls,
743
902
  pendingReferenceAttachments,
903
+ pendingContainerResolutions,
744
904
  );
745
905
  } else if (decl.kind === 'relates') {
746
906
  // Check for duplicate relates in reverse order
@@ -825,6 +985,8 @@ function processBody(
825
985
  source: decl.source,
826
986
  endpoints: [], // Will be populated during resolution
827
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,
828
990
  };
829
991
 
830
992
  // Add top-level describe if present
@@ -951,6 +1113,9 @@ function processBody(
951
1113
  } else if (decl.kind === 'title') {
952
1114
  // Title blocks are attached to their parent node
953
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
954
1119
  } else if (decl.kind === 'references') {
955
1120
  if (pendingReferenceAttachments) {
956
1121
  pendingReferenceAttachments.push({
@@ -1112,18 +1277,27 @@ function processBody(
1112
1277
  graph.documentsByName[universeName][docName] = docModel;
1113
1278
  }
1114
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
1115
1283
  } else {
1116
1284
  // UnknownBlock - attach to current node (the node whose body contains this block)
1117
- const currentNode = graph.nodes[currentNodeId];
1118
- if (!currentNode.unknownBlocks) {
1119
- currentNode.unknownBlocks = [];
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
+ });
1120
1298
  }
1121
- currentNode.unknownBlocks.push({
1122
- keyword: decl.keyword,
1123
- raw: decl.raw,
1124
- normalized: normalizeProseBlock(decl.raw),
1125
- source: decl.source,
1126
- });
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)
1127
1301
  }
1128
1302
  }
1129
1303
 
@@ -1170,6 +1344,8 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1170
1344
  parent: parentNodeId,
1171
1345
  children: [],
1172
1346
  source: decl.source,
1347
+ spelledKind: decl.spelledKind,
1348
+ aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
1173
1349
  };
1174
1350
 
1175
1351
  // Always set container for book/chapter nodes (may be undefined if not resolved)
@@ -1200,6 +1376,34 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
1200
1376
  }
1201
1377
  }
1202
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
+
1203
1407
  // Note: UnknownBlocks are handled in processBody and attached to the parent node
1204
1408
  // They are not extracted here to avoid duplication
1205
1409
 
@@ -1277,7 +1481,7 @@ function resolveEdges(graph, scopeIndex) {
1277
1481
  }
1278
1482
  }
1279
1483
 
1280
- // Resolve edges (for backward compatibility)
1484
+ // Resolve edges (relates edges in object format)
1281
1485
  for (const edgeId in graph.edges) {
1282
1486
  const edge = graph.edges[edgeId];
1283
1487
  const universeName = edgeId.split(':')[0];
@@ -1433,6 +1637,161 @@ function resolveContainers(graph, scopeIndex, pending) {
1433
1637
  }
1434
1638
  }
1435
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
+
1436
1795
  /**
1437
1796
  * @param {string | undefined} raw
1438
1797
  * @returns {string | undefined}