@sprig-and-prose/sprig-universe 0.2.0 → 0.3.1

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/src/graph.js CHANGED
@@ -27,9 +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').ReferenceBlock} ReferenceBlock
31
- * @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
32
- * @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
30
+ * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
33
31
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
34
32
  * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
35
33
  * @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
@@ -52,20 +50,30 @@ export function buildGraph(fileASTs) {
52
50
  nodes: {},
53
51
  edges: {},
54
52
  diagnostics: [],
55
- referencesByName: {},
56
53
  documentsByName: {},
54
+ repositories: {},
55
+ references: {},
57
56
  };
58
57
 
59
58
  // Track node names within each scope (container) for scoped resolution
60
59
  // Map<containerNodeId, Map<name, nodeId[]>> - allows multiple nodes with same name in different scopes
61
60
  const scopeIndex = new Map(); // containerNodeId -> Map<name, nodeId[]>
62
61
 
63
- // Track named references and documents per universe for duplicate detection
64
- const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
62
+ // Track named documents per universe for duplicate detection
65
63
  const namedDocumentsByUniverse = new Map(); // universeName -> Map<name, { source }>
66
64
 
67
- // Track repositories per universe
68
- const repositoriesByUniverse = new Map(); // universeName -> Map<name, RepositoryDecl>
65
+ // Track repositories and references per universe for scoped resolution
66
+ const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
67
+ const referencesByUniverse = new Map(); // universeName -> Map<id, ReferenceDecl>
68
+
69
+ // Track entity kinds for duplicate detection (references/repositories)
70
+ const entityKinds = new Map(); // id -> kind
71
+
72
+ // Pending resolutions
73
+ /** @type {Array<{ refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string }>} */
74
+ const pendingReferenceDecls = [];
75
+ /** @type {Array<{ nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string }>} */
76
+ const pendingReferenceAttachments = [];
69
77
 
70
78
  // First pass: collect all universe names with their file locations for validation
71
79
  const universeNameToFiles = new Map(); // universeName -> Set<file>
@@ -107,11 +115,6 @@ export function buildGraph(fileASTs) {
107
115
  };
108
116
  }
109
117
 
110
- // Initialize tracking maps for named references and documents (only once per universe)
111
- if (!namedReferencesByUniverse.has(universeName)) {
112
- namedReferencesByUniverse.set(universeName, new Map());
113
- graph.referencesByName[universeName] = {};
114
- }
115
118
  if (!namedDocumentsByUniverse.has(universeName)) {
116
119
  namedDocumentsByUniverse.set(universeName, new Map());
117
120
  graph.documentsByName[universeName] = {};
@@ -119,6 +122,9 @@ export function buildGraph(fileASTs) {
119
122
  if (!repositoriesByUniverse.has(universeName)) {
120
123
  repositoriesByUniverse.set(universeName, new Map());
121
124
  }
125
+ if (!referencesByUniverse.has(universeName)) {
126
+ referencesByUniverse.set(universeName, new Map());
127
+ }
122
128
 
123
129
  // Check if universe node already exists (from a previous file)
124
130
  const existingUniverseNode = graph.nodes[universeNodeId];
@@ -177,9 +183,12 @@ export function buildGraph(fileASTs) {
177
183
  scopeIndex,
178
184
  fileAST.file,
179
185
  universeNodeId,
180
- namedReferencesByUniverse.get(universeName),
181
186
  namedDocumentsByUniverse.get(universeName),
182
187
  repositoriesByUniverse.get(universeName),
188
+ referencesByUniverse.get(universeName),
189
+ entityKinds,
190
+ pendingReferenceDecls,
191
+ pendingReferenceAttachments,
183
192
  );
184
193
  } else {
185
194
  // First time seeing this universe - create node and process body
@@ -203,34 +212,77 @@ export function buildGraph(fileASTs) {
203
212
  scopeIndex,
204
213
  fileAST.file,
205
214
  universeNodeId,
206
- namedReferencesByUniverse.get(universeName),
207
215
  namedDocumentsByUniverse.get(universeName),
208
216
  repositoriesByUniverse.get(universeName),
217
+ referencesByUniverse.get(universeName),
218
+ entityKinds,
219
+ pendingReferenceDecls,
220
+ pendingReferenceAttachments,
209
221
  );
210
222
  }
211
223
  }
212
224
  }
213
225
 
214
- // Extract repositories and convert to config format
215
- // Note: There's only one universe per manifest, so we iterate over the single entry
216
- graph.repositories = {};
217
- for (const reposMap of repositoriesByUniverse.values()) {
218
- for (const [repoName, repoDecl] of reposMap.entries()) {
219
- // Convert repository kind from package name to directory name
220
- // '@sprig-and-prose/sprig-repository-github' -> 'sprig-repository-github'
221
- let kindValue = String(repoDecl.repositoryKind);
222
- if (kindValue.startsWith('@') && kindValue.includes('/')) {
223
- const parts = kindValue.split('/');
224
- kindValue = parts[parts.length - 1];
226
+ // Attach unscoped top-level declarations to the single universe if present
227
+ if (universeNameToFiles.size === 1) {
228
+ const [universeName] = universeNameToFiles.keys();
229
+ const universeModel = graph.universes[universeName];
230
+ const universeNodeId = universeModel?.root;
231
+ if (universeNodeId) {
232
+ for (const fileAST of fileASTs) {
233
+ if (!fileAST.topLevelDecls || fileAST.topLevelDecls.length === 0) {
234
+ continue;
235
+ }
236
+ for (const decl of fileAST.topLevelDecls) {
237
+ if (decl.parentName) {
238
+ const resolved = resolveNameInScope(
239
+ graph,
240
+ scopeIndex,
241
+ decl.parentName,
242
+ universeNodeId,
243
+ decl.source,
244
+ );
245
+ if (!resolved.nodeId && !resolved.ambiguous) {
246
+ graph.diagnostics.push({
247
+ severity: 'error',
248
+ message: `Top-level ${decl.kind} "${decl.name}" references unknown container "${decl.parentName}"`,
249
+ source: diagnosticSource(decl.source),
250
+ });
251
+ continue;
252
+ }
253
+ }
254
+ processBody(
255
+ graph,
256
+ universeName,
257
+ universeNodeId,
258
+ [decl],
259
+ scopeIndex,
260
+ fileAST.file,
261
+ universeNodeId,
262
+ namedDocumentsByUniverse.get(universeName),
263
+ repositoriesByUniverse.get(universeName),
264
+ referencesByUniverse.get(universeName),
265
+ entityKinds,
266
+ pendingReferenceDecls,
267
+ pendingReferenceAttachments,
268
+ );
269
+ }
225
270
  }
226
-
227
- graph.repositories[repoName] = {
228
- kind: kindValue,
229
- options: repoDecl.options,
230
- };
231
271
  }
232
272
  }
233
273
 
274
+ // Resolve reference declarations and attach references after all names are indexed
275
+ resolveReferenceDecls(
276
+ graph,
277
+ pendingReferenceDecls,
278
+ scopeIndex,
279
+ );
280
+ resolveReferenceAttachments(
281
+ graph,
282
+ pendingReferenceAttachments,
283
+ scopeIndex,
284
+ );
285
+
234
286
  // Resolve edge endpoints
235
287
  resolveEdges(graph, scopeIndex);
236
288
 
@@ -384,29 +436,112 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
384
436
  return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
385
437
  }
386
438
 
439
+ /**
440
+ * Resolve relates endpoints with a universe-wide fallback.
441
+ * @param {UniverseGraph} graph
442
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
443
+ * @param {string} name
444
+ * @param {string} startScopeNodeId
445
+ * @param {SourceSpan} [source]
446
+ * @returns {{ nodeId: string | null, ambiguous: boolean, ambiguousNodes: string[] }}
447
+ */
448
+ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, source) {
449
+ const resolved = resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source);
450
+ if (resolved.nodeId || resolved.ambiguous) {
451
+ return resolved;
452
+ }
453
+
454
+ const universeName = startScopeNodeId.split(':')[0];
455
+ const matches = [];
456
+ for (const nodeId in graph.nodes) {
457
+ if (!nodeId.startsWith(`${universeName}:`)) {
458
+ continue;
459
+ }
460
+ const node = graph.nodes[nodeId];
461
+ if (node && node.name === name) {
462
+ matches.push(nodeId);
463
+ }
464
+ }
465
+
466
+ if (matches.length === 1) {
467
+ return { nodeId: matches[0], ambiguous: false, ambiguousNodes: [] };
468
+ }
469
+
470
+ if (matches.length > 1) {
471
+ if (source) {
472
+ graph.diagnostics.push({
473
+ severity: 'error',
474
+ message: `Ambiguous relates endpoint "${name}" in universe "${universeName}": found ${matches.length} matches`,
475
+ source,
476
+ });
477
+ }
478
+ return { nodeId: null, ambiguous: true, ambiguousNodes: matches };
479
+ }
480
+
481
+ return resolved;
482
+ }
483
+
387
484
  /**
388
485
  * Processes a body of declarations, creating nodes and edges
389
486
  * @param {UniverseGraph} graph
390
487
  * @param {string} universeName
391
488
  * @param {string} parentNodeId - Parent node ID (for tree relationships)
392
- * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>} body
489
+ * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body
393
490
  * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
394
491
  * @param {string} file
395
492
  * @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
396
- * @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
397
493
  * @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
398
494
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
495
+ * @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
496
+ * @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
497
+ * @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
498
+ * @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
399
499
  */
400
- function processBody(graph, universeName, parentNodeId, body, scopeIndex, file, currentNodeId, namedRefsMap, namedDocsMap, reposMap) {
500
+ function processBody(
501
+ graph,
502
+ universeName,
503
+ parentNodeId,
504
+ body,
505
+ scopeIndex,
506
+ file,
507
+ currentNodeId,
508
+ namedDocsMap,
509
+ reposMap,
510
+ refsMap,
511
+ entityKinds,
512
+ pendingReferenceDecls,
513
+ pendingReferenceAttachments,
514
+ ) {
401
515
  for (const decl of body) {
402
516
  if (decl.kind === 'anthology') {
403
517
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
404
518
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
405
- const node = createNode(nodeId, 'anthology', decl.name, parentNodeId, decl);
519
+ let actualParentNodeId = parentNodeId;
520
+ if (decl.parentName) {
521
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
522
+ if (resolved.nodeId && !resolved.ambiguous) {
523
+ actualParentNodeId = resolved.nodeId;
524
+ }
525
+ }
526
+ const node = createNode(nodeId, 'anthology', decl.name, actualParentNodeId, decl);
406
527
  graph.nodes[nodeId] = node;
407
- graph.nodes[parentNodeId].children.push(nodeId);
528
+ graph.nodes[actualParentNodeId].children.push(nodeId);
408
529
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
409
- processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
530
+ processBody(
531
+ graph,
532
+ universeName,
533
+ nodeId,
534
+ decl.body,
535
+ scopeIndex,
536
+ file,
537
+ nodeId,
538
+ namedDocsMap,
539
+ reposMap,
540
+ refsMap,
541
+ entityKinds,
542
+ pendingReferenceDecls,
543
+ pendingReferenceAttachments,
544
+ );
410
545
  } else if (decl.kind === 'series') {
411
546
  const nodeId = makeNodeId(universeName, 'series', decl.name);
412
547
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
@@ -425,34 +560,99 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
425
560
  graph.nodes[nodeId] = node;
426
561
  graph.nodes[actualParentNodeId].children.push(nodeId);
427
562
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
428
- processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
563
+ processBody(
564
+ graph,
565
+ universeName,
566
+ nodeId,
567
+ decl.body,
568
+ scopeIndex,
569
+ file,
570
+ nodeId,
571
+ namedDocsMap,
572
+ reposMap,
573
+ refsMap,
574
+ entityKinds,
575
+ pendingReferenceDecls,
576
+ pendingReferenceAttachments,
577
+ );
429
578
  } else if (decl.kind === 'book') {
430
579
  const nodeId = makeNodeId(universeName, 'book', decl.name);
431
580
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'book', decl.source);
432
- const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
433
- const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
434
- // Always set container for book nodes (even if undefined when parentName doesn't resolve)
581
+
582
+ // Handle optional parent
583
+ let actualParentNodeId = parentNodeId;
584
+ let containerNodeId = undefined;
585
+ if (decl.parentName) {
586
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
587
+ if (resolved.nodeId && !resolved.ambiguous) {
588
+ actualParentNodeId = resolved.nodeId;
589
+ containerNodeId = resolved.nodeId;
590
+ }
591
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
592
+ }
593
+
435
594
  const node = createNode(
436
595
  nodeId,
437
596
  'book',
438
597
  decl.name,
439
- containerNodeId || parentNodeId,
598
+ actualParentNodeId,
440
599
  decl,
441
- containerNodeId, // May be undefined if parentName doesn't resolve
600
+ containerNodeId, // May be undefined if no parentName or if parentName doesn't resolve
442
601
  );
443
602
  graph.nodes[nodeId] = node;
444
- if (containerNodeId) {
445
- graph.nodes[containerNodeId].children.push(nodeId);
446
- } else {
447
- graph.nodes[parentNodeId].children.push(nodeId);
448
- }
603
+ graph.nodes[actualParentNodeId].children.push(nodeId);
449
604
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
450
- processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
605
+ processBody(
606
+ graph,
607
+ universeName,
608
+ nodeId,
609
+ decl.body,
610
+ scopeIndex,
611
+ file,
612
+ nodeId,
613
+ namedDocsMap,
614
+ reposMap,
615
+ refsMap,
616
+ entityKinds,
617
+ pendingReferenceDecls,
618
+ pendingReferenceAttachments,
619
+ );
451
620
  } else if (decl.kind === 'chapter') {
452
621
  const nodeId = makeNodeId(universeName, 'chapter', decl.name);
453
622
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
623
+
624
+ // Validate: chapter must have an "in" block
625
+ if (!decl.parentName) {
626
+ graph.diagnostics.push({
627
+ severity: 'error',
628
+ message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
629
+ source: decl.source,
630
+ });
631
+ // Continue processing but mark as invalid
632
+ }
633
+
454
634
  const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
455
635
  const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
636
+
637
+ // Validate: chapter container must be a book
638
+ if (containerNodeId) {
639
+ const containerNode = graph.nodes[containerNodeId];
640
+ if (containerNode && containerNode.kind !== 'book') {
641
+ graph.diagnostics.push({
642
+ severity: 'error',
643
+ message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
644
+ source: decl.source,
645
+ });
646
+ }
647
+ } else if (decl.parentName) {
648
+ // Container not found
649
+ graph.diagnostics.push({
650
+ severity: 'error',
651
+ message: `Chapter "${decl.name}" references unknown book "${decl.parentName}"`,
652
+ source: decl.source,
653
+ });
654
+ }
655
+
456
656
  // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
457
657
  const node = createNode(
458
658
  nodeId,
@@ -469,7 +669,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
469
669
  graph.nodes[parentNodeId].children.push(nodeId);
470
670
  }
471
671
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
472
- processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
672
+ processBody(
673
+ graph,
674
+ universeName,
675
+ nodeId,
676
+ decl.body,
677
+ scopeIndex,
678
+ file,
679
+ nodeId,
680
+ namedDocsMap,
681
+ reposMap,
682
+ refsMap,
683
+ entityKinds,
684
+ pendingReferenceDecls,
685
+ pendingReferenceAttachments,
686
+ );
473
687
  } else if (decl.kind === 'concept') {
474
688
  const nodeId = makeNodeId(universeName, 'concept', decl.name);
475
689
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
@@ -488,7 +702,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
488
702
  graph.nodes[nodeId] = node;
489
703
  graph.nodes[actualParentNodeId].children.push(nodeId);
490
704
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
491
- processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
705
+ processBody(
706
+ graph,
707
+ universeName,
708
+ nodeId,
709
+ decl.body,
710
+ scopeIndex,
711
+ file,
712
+ nodeId,
713
+ namedDocsMap,
714
+ reposMap,
715
+ refsMap,
716
+ entityKinds,
717
+ pendingReferenceDecls,
718
+ pendingReferenceAttachments,
719
+ );
492
720
  } else if (decl.kind === 'relates') {
493
721
  // Check for duplicate relates in reverse order
494
722
  checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
@@ -670,51 +898,12 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
670
898
  // Title blocks are attached to their parent node
671
899
  // This is handled in createNode
672
900
  } else if (decl.kind === 'references') {
673
- // ReferencesBlock - attach to current node
674
- const currentNode = graph.nodes[currentNodeId];
675
- if (!currentNode.references) {
676
- currentNode.references = [];
677
- }
678
- // Serialize references block - handle both inline references and using blocks
679
- for (const item of decl.references) {
680
- if (item.kind === 'reference') {
681
- // Inline reference block - convert as before
682
- currentNode.references.push({
683
- repository: item.repository,
684
- paths: item.paths,
685
- kind: item.referenceKind,
686
- describe: item.describe
687
- ? {
688
- raw: item.describe.raw,
689
- normalized: normalizeProseBlock(item.describe.raw),
690
- source: item.describe.source,
691
- }
692
- : undefined,
693
- source: item.source,
694
- });
695
- } else if (item.kind === 'using-in-references') {
696
- // Using block - resolve each name against named references registry
697
- for (const name of item.names) {
698
- const namedRef = graph.referencesByName[universeName]?.[name];
699
- if (namedRef) {
700
- // Resolved: expand to reference object
701
- currentNode.references.push({
702
- repository: namedRef.repository,
703
- paths: namedRef.paths,
704
- kind: namedRef.kind,
705
- describe: namedRef.describe,
706
- source: namedRef.source, // Use the named reference's source, not the using block's
707
- });
708
- } else {
709
- // Not resolved: emit error diagnostic
710
- graph.diagnostics.push({
711
- severity: 'error',
712
- message: `Unknown reference '${name}' used in references block`,
713
- source: item.source,
714
- });
715
- }
716
- }
717
- }
901
+ if (pendingReferenceAttachments) {
902
+ pendingReferenceAttachments.push({
903
+ nodeId: currentNodeId,
904
+ items: decl.items,
905
+ universeName,
906
+ });
718
907
  }
719
908
  } else if (decl.kind === 'documentation') {
720
909
  // DocumentationBlock - attach to current node
@@ -738,35 +927,75 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
738
927
  source: doc.source,
739
928
  });
740
929
  }
741
- } else if (decl.kind === 'named-reference') {
742
- // NamedReferenceBlock - store in universe-level registry
743
- // Only process at universe scope (when namedRefsMap is provided)
744
- if (namedRefsMap && parentNodeId === currentNodeId) {
745
- const refName = decl.name;
746
-
747
- // Check for duplicate name
748
- if (namedRefsMap.has(refName)) {
749
- const firstOccurrence = namedRefsMap.get(refName);
750
- graph.diagnostics.push({
751
- severity: 'error',
752
- message: `Duplicate named reference "${refName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
753
- source: decl.source,
754
- });
755
- graph.diagnostics.push({
756
- severity: 'error',
757
- message: `First declaration of named reference "${refName}" was here.`,
758
- source: firstOccurrence.source,
930
+ } else if (decl.kind === 'reference') {
931
+ if (refsMap && entityKinds && pendingReferenceDecls) {
932
+ const baseName = decl.name || deriveReferenceName(decl);
933
+ const uniqueName = decl.name
934
+ ? baseName
935
+ : pickUniqueName(scopeIndex, currentNodeId, baseName);
936
+ const qualifiedName = makeQualifiedName(graph, currentNodeId, uniqueName);
937
+ const refId = makeEntityId(universeName, 'reference', qualifiedName);
938
+ if (decl.name) {
939
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, refId, 'reference', decl.source, entityKinds);
940
+ }
941
+
942
+ if (!refsMap.has(refId)) {
943
+ refsMap.set(refId, decl);
944
+ entityKinds.set(refId, 'reference');
945
+ addNameToScope(graph, scopeIndex, currentNodeId, uniqueName, refId);
946
+ if (!graph.references[refId]) {
947
+ graph.references[refId] = {
948
+ id: refId,
949
+ name: uniqueName,
950
+ urls: [],
951
+ source: decl.source,
952
+ };
953
+ }
954
+ pendingReferenceDecls.push({
955
+ refId,
956
+ decl,
957
+ universeName,
958
+ scopeNodeId: currentNodeId,
759
959
  });
760
- } else {
761
- // Store first occurrence
762
- namedRefsMap.set(refName, { source: decl.source });
763
-
764
- // Convert to model and store in registry
765
- const refModel = {
766
- name: refName,
767
- repository: decl.repository,
768
- paths: decl.paths,
769
- kind: decl.referenceKind,
960
+
961
+ if (pendingReferenceAttachments && currentNodeId !== makeNodeId(universeName, 'universe', universeName)) {
962
+ pendingReferenceAttachments.push({
963
+ nodeId: currentNodeId,
964
+ items: [
965
+ {
966
+ name: uniqueName,
967
+ source: decl.source,
968
+ },
969
+ ],
970
+ universeName,
971
+ });
972
+ }
973
+ }
974
+ }
975
+ } else if (decl.kind === 'repository') {
976
+ // RepositoryDecl - store in universe-level registry
977
+ if (reposMap && entityKinds) {
978
+ const scopeNodeId = resolveContainerScope(graph, scopeIndex, currentNodeId, decl.parentName, decl.source);
979
+ const qualifiedName = makeQualifiedName(graph, scopeNodeId, decl.name);
980
+ const repoId = makeEntityId(universeName, 'repository', qualifiedName);
981
+ checkDuplicateInScope(graph, scopeIndex, scopeNodeId, decl.name, repoId, 'repository', decl.source, entityKinds);
982
+
983
+ if (!reposMap.has(repoId)) {
984
+ reposMap.set(repoId, decl);
985
+ entityKinds.set(repoId, 'repository');
986
+ addNameToScope(graph, scopeIndex, scopeNodeId, decl.name, repoId);
987
+ if (!decl.url) {
988
+ graph.diagnostics.push({
989
+ severity: 'error',
990
+ message: `Repository "${decl.name}" is missing url { '...' }`,
991
+ source: decl.source,
992
+ });
993
+ }
994
+ graph.repositories[repoId] = {
995
+ id: repoId,
996
+ name: decl.name,
997
+ url: decl.url || '',
998
+ title: normalizeTitleValue(decl.title?.raw),
770
999
  describe: decl.describe
771
1000
  ? {
772
1001
  raw: decl.describe.raw,
@@ -774,34 +1003,15 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
774
1003
  source: decl.describe.source,
775
1004
  }
776
1005
  : undefined,
1006
+ note: decl.note
1007
+ ? {
1008
+ raw: decl.note.raw,
1009
+ normalized: normalizeProseBlock(decl.note.raw),
1010
+ source: decl.note.source,
1011
+ }
1012
+ : undefined,
777
1013
  source: decl.source,
778
1014
  };
779
-
780
- graph.referencesByName[universeName][refName] = refModel;
781
- }
782
- }
783
- } else if (decl.kind === 'repository') {
784
- // RepositoryDecl - store in universe-level registry
785
- // Only process at universe scope (when reposMap is provided)
786
- if (reposMap && parentNodeId === currentNodeId) {
787
- const repoName = decl.name;
788
-
789
- // Check for duplicate name
790
- if (reposMap.has(repoName)) {
791
- const firstOccurrence = reposMap.get(repoName);
792
- graph.diagnostics.push({
793
- severity: 'error',
794
- message: `Duplicate repository "${repoName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
795
- source: decl.source,
796
- });
797
- graph.diagnostics.push({
798
- severity: 'error',
799
- message: `First declaration of repository "${repoName}" was here.`,
800
- source: firstOccurrence.source,
801
- });
802
- } else {
803
- // Store repository declaration
804
- reposMap.set(repoName, decl);
805
1015
  }
806
1016
  }
807
1017
  } else if (decl.kind === 'named-document') {
@@ -868,7 +1078,7 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
868
1078
  * @param {string} name
869
1079
  * @param {string | undefined} parentNodeId
870
1080
  * @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
871
- * @param {string | undefined} containerNodeId
1081
+ * @param {string | undefined} [containerNodeId]
872
1082
  * @returns {NodeModel}
873
1083
  */
874
1084
  function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
@@ -924,15 +1134,16 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
924
1134
  * @param {string} nodeId
925
1135
  * @param {string} kind - The kind of the current node being checked
926
1136
  * @param {SourceSpan} source
1137
+ * @param {Map<string, string>} [entityKinds]
927
1138
  */
928
- function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source) {
1139
+ function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source, entityKinds) {
929
1140
  const scopeMap = scopeIndex.get(scopeNodeId);
930
1141
  if (scopeMap && scopeMap.has(name)) {
931
1142
  const existingNodeIds = scopeMap.get(name);
932
1143
  if (existingNodeIds.length > 0) {
933
1144
  const firstNodeId = existingNodeIds[0];
934
1145
  const firstNode = graph.nodes[firstNodeId];
935
- const firstKind = firstNode?.kind || 'unknown';
1146
+ const firstKind = firstNode?.kind || entityKinds?.get(firstNodeId) || 'unknown';
936
1147
  const scopeNode = graph.nodes[scopeNodeId];
937
1148
  const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
938
1149
  graph.diagnostics.push({
@@ -998,7 +1209,7 @@ function resolveEdges(graph, scopeIndex) {
998
1209
  const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
999
1210
 
1000
1211
  // Resolve endpoint A - start from universe scope
1001
- const resolvedA = resolveNameInScope(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1212
+ const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1002
1213
  if (resolvedA.nodeId && !resolvedA.ambiguous) {
1003
1214
  edge.a.target = resolvedA.nodeId;
1004
1215
  } else if (!hasRelatesNode) {
@@ -1013,7 +1224,7 @@ function resolveEdges(graph, scopeIndex) {
1013
1224
  }
1014
1225
 
1015
1226
  // Resolve endpoint B - start from universe scope
1016
- const resolvedB = resolveNameInScope(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1227
+ const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1017
1228
  if (resolvedB.nodeId && !resolvedB.ambiguous) {
1018
1229
  edge.b.target = resolvedB.nodeId;
1019
1230
  } else if (!hasRelatesNode) {
@@ -1042,7 +1253,7 @@ function resolveEdges(graph, scopeIndex) {
1042
1253
  const unresolved = [];
1043
1254
 
1044
1255
  for (const endpointName of node.unresolvedEndpoints) {
1045
- const resolved = resolveNameInScope(graph, scopeIndex, endpointName, relatesScope, node.source);
1256
+ const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
1046
1257
  if (resolved.nodeId && !resolved.ambiguous) {
1047
1258
  resolvedEndpoints.push(resolved.nodeId);
1048
1259
  } else {
@@ -1068,7 +1279,7 @@ function resolveEdges(graph, scopeIndex) {
1068
1279
  if (node.from) {
1069
1280
  const resolvedFrom = {};
1070
1281
  for (const endpointName in node.from) {
1071
- const resolved = resolveNameInScope(graph, scopeIndex, endpointName, relatesScope, node.source);
1282
+ const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
1072
1283
  if (resolved.nodeId && !resolved.ambiguous) {
1073
1284
  resolvedFrom[resolved.nodeId] = node.from[endpointName];
1074
1285
  } else {
@@ -1082,6 +1293,320 @@ function resolveEdges(graph, scopeIndex) {
1082
1293
  }
1083
1294
  }
1084
1295
 
1296
+ /**
1297
+ * @param {string | undefined} raw
1298
+ * @returns {string | undefined}
1299
+ */
1300
+ function normalizeTitleValue(raw) {
1301
+ if (!raw) return undefined;
1302
+ const trimmed = raw.trim();
1303
+ if (!trimmed) return undefined;
1304
+ const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
1305
+ return unquoted.trim() || undefined;
1306
+ }
1307
+
1308
+ /**
1309
+ * @param {UniverseGraph} graph
1310
+ * @param {Array<{refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string}>} pending
1311
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1312
+ */
1313
+ function resolveReferenceDecls(graph, pending, scopeIndex) {
1314
+ /** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
1315
+ const unknownRepoCounts = new Map();
1316
+ for (const item of pending) {
1317
+ const { refId, decl, scopeNodeId } = item;
1318
+ const model = graph.references[refId] || { id: refId, name: decl.name, urls: [], source: decl.source };
1319
+ const displayName = model.name || decl.name || 'unnamed reference';
1320
+ let urls = [];
1321
+ let repositoryRef = undefined;
1322
+
1323
+ if (decl.url && decl.repositoryName) {
1324
+ graph.diagnostics.push({
1325
+ severity: 'error',
1326
+ message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
1327
+ source: diagnosticSource(decl.source),
1328
+ });
1329
+ }
1330
+
1331
+ if (decl.url) {
1332
+ urls = [decl.url];
1333
+ } else if (decl.repositoryName) {
1334
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.repositoryName, scopeNodeId, decl.source);
1335
+ if (resolved.nodeId && !resolved.ambiguous) {
1336
+ const repo = graph.repositories[resolved.nodeId];
1337
+ if (!repo) {
1338
+ graph.diagnostics.push({
1339
+ severity: 'error',
1340
+ message: `Reference "${displayName}" uses "${decl.repositoryName}" which is not a repository`,
1341
+ source: diagnosticSource(decl.source),
1342
+ });
1343
+ } else if (!repo.url) {
1344
+ graph.diagnostics.push({
1345
+ severity: 'error',
1346
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1347
+ source: decl.source,
1348
+ });
1349
+ } else {
1350
+ repositoryRef = repo.id;
1351
+ if (decl.paths && decl.paths.length > 0) {
1352
+ urls = decl.paths.map((path) => joinRepositoryUrl(repo.url, path));
1353
+ } else if (decl.paths && decl.paths.length === 0) {
1354
+ graph.diagnostics.push({
1355
+ severity: 'error',
1356
+ message: `Reference "${displayName}" has an empty paths block`,
1357
+ source: diagnosticSource(decl.source),
1358
+ });
1359
+ } else {
1360
+ urls = [repo.url];
1361
+ }
1362
+ }
1363
+ } else if (!resolved.ambiguous) {
1364
+ const entry = unknownRepoCounts.get(decl.repositoryName) || {
1365
+ count: 0,
1366
+ source: decl.source,
1367
+ };
1368
+ entry.count += 1;
1369
+ if (!entry.source && decl.source) {
1370
+ entry.source = decl.source;
1371
+ }
1372
+ unknownRepoCounts.set(decl.repositoryName, entry);
1373
+ }
1374
+ } else {
1375
+ graph.diagnostics.push({
1376
+ severity: 'error',
1377
+ message: `Reference "${displayName}" must have url { ... } or in <Repository>`,
1378
+ source: diagnosticSource(decl.source),
1379
+ });
1380
+ }
1381
+
1382
+ model.name = decl.name || model.name || displayName;
1383
+ model.kind = decl.referenceKind || undefined;
1384
+ model.title = normalizeTitleValue(decl.title?.raw);
1385
+ model.describe = decl.describe
1386
+ ? {
1387
+ raw: decl.describe.raw,
1388
+ normalized: normalizeProseBlock(decl.describe.raw),
1389
+ source: decl.describe.source,
1390
+ }
1391
+ : undefined;
1392
+ model.note = decl.note
1393
+ ? {
1394
+ raw: decl.note.raw,
1395
+ normalized: normalizeProseBlock(decl.note.raw),
1396
+ source: decl.note.source,
1397
+ }
1398
+ : undefined;
1399
+ model.urls = urls;
1400
+ model.repositoryRef = repositoryRef;
1401
+ model.paths = decl.paths && decl.paths.length > 0 ? decl.paths : undefined;
1402
+ model.source = decl.source;
1403
+ graph.references[refId] = model;
1404
+ }
1405
+
1406
+ for (const [repoName, info] of unknownRepoCounts.entries()) {
1407
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1408
+ graph.diagnostics.push({
1409
+ severity: 'error',
1410
+ message: `Unknown repository "${repoName}" used by references${countText}.`,
1411
+ source: diagnosticSource(info.source),
1412
+ });
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * @param {UniverseGraph} graph
1418
+ * @param {Array<{nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string}>} pending
1419
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1420
+ */
1421
+ function resolveReferenceAttachments(graph, pending, scopeIndex) {
1422
+ /** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
1423
+ const unknownReferenceCounts = new Map();
1424
+
1425
+ for (const item of pending) {
1426
+ const node = graph.nodes[item.nodeId];
1427
+ if (!node) {
1428
+ continue;
1429
+ }
1430
+ if (!node.references) {
1431
+ node.references = [];
1432
+ }
1433
+
1434
+ for (const entry of item.items) {
1435
+ const name = entry.name;
1436
+ const resolved = resolveNameInScope(graph, scopeIndex, name, item.nodeId, entry.source);
1437
+ if (resolved.nodeId && !resolved.ambiguous) {
1438
+ if (graph.references[resolved.nodeId]) {
1439
+ node.references.push(resolved.nodeId);
1440
+ } else {
1441
+ const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
1442
+ existing.count += 1;
1443
+ if (!existing.source && entry.source) {
1444
+ existing.source = entry.source;
1445
+ }
1446
+ unknownReferenceCounts.set(name, existing);
1447
+ }
1448
+ } else if (!resolved.ambiguous) {
1449
+ const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
1450
+ existing.count += 1;
1451
+ if (!existing.source && entry.source) {
1452
+ existing.source = entry.source;
1453
+ }
1454
+ unknownReferenceCounts.set(name, existing);
1455
+ }
1456
+ }
1457
+ }
1458
+
1459
+ for (const [name, info] of unknownReferenceCounts.entries()) {
1460
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1461
+ graph.diagnostics.push({
1462
+ severity: 'error',
1463
+ message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
1464
+ source: diagnosticSource(info.source),
1465
+ });
1466
+ }
1467
+ }
1468
+
1469
+ /**
1470
+ * @param {SourceSpan | undefined} source
1471
+ * @returns {SourceSpan | { file: string, line?: number, col?: number, start?: any, end?: any } | undefined}
1472
+ */
1473
+ function diagnosticSource(source) {
1474
+ if (!source) return undefined;
1475
+ return {
1476
+ file: source.file,
1477
+ line: source.start?.line,
1478
+ col: source.start?.col,
1479
+ start: source.start,
1480
+ end: source.end,
1481
+ };
1482
+ }
1483
+
1484
+ /**
1485
+ * @param {string} base
1486
+ * @param {string} path
1487
+ * @returns {string}
1488
+ */
1489
+ function joinRepositoryUrl(base, path) {
1490
+ if (base.endsWith('/') && path.startsWith('/')) {
1491
+ return base + path.slice(1);
1492
+ }
1493
+ if (!base.endsWith('/') && !path.startsWith('/')) {
1494
+ return `${base}/${path}`;
1495
+ }
1496
+ return base + path;
1497
+ }
1498
+
1499
+ /**
1500
+ * @param {UniverseGraph} graph
1501
+ * @param {string} containerNodeId
1502
+ * @param {string} name
1503
+ * @returns {string}
1504
+ */
1505
+ function makeQualifiedName(graph, containerNodeId, name) {
1506
+ const path = [];
1507
+ let current = containerNodeId;
1508
+ const visited = new Set();
1509
+ while (current && !visited.has(current)) {
1510
+ visited.add(current);
1511
+ const node = graph.nodes[current];
1512
+ if (!node) break;
1513
+ if (node.kind !== 'universe') {
1514
+ path.push(node.name);
1515
+ }
1516
+ current = node.parent;
1517
+ }
1518
+ path.reverse();
1519
+ path.push(name);
1520
+ return path.join('.');
1521
+ }
1522
+
1523
+ /**
1524
+ * @param {string} universeName
1525
+ * @param {string} kind
1526
+ * @param {string} qualifiedName
1527
+ * @returns {string}
1528
+ */
1529
+ function makeEntityId(universeName, kind, qualifiedName) {
1530
+ return `${universeName}:${kind}:${qualifiedName}`;
1531
+ }
1532
+
1533
+ /**
1534
+ * @param {UniverseGraph} graph
1535
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1536
+ * @param {string} currentNodeId
1537
+ * @param {string | undefined} parentName
1538
+ * @param {SourceSpan} source
1539
+ * @returns {string}
1540
+ */
1541
+ function resolveContainerScope(graph, scopeIndex, currentNodeId, parentName, source) {
1542
+ if (!parentName) {
1543
+ return currentNodeId;
1544
+ }
1545
+ const resolved = resolveNameInScope(graph, scopeIndex, parentName, currentNodeId, source);
1546
+ if (resolved.nodeId && !resolved.ambiguous) {
1547
+ return resolved.nodeId;
1548
+ }
1549
+ return currentNodeId;
1550
+ }
1551
+
1552
+ /**
1553
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1554
+ * @param {string} scopeNodeId
1555
+ * @param {string} baseName
1556
+ * @returns {string}
1557
+ */
1558
+ function pickUniqueName(scopeIndex, scopeNodeId, baseName) {
1559
+ const scopeMap = scopeIndex.get(scopeNodeId);
1560
+ if (!scopeMap || !scopeMap.has(baseName)) {
1561
+ return baseName;
1562
+ }
1563
+ let suffix = 2;
1564
+ let candidate = `${baseName}-${suffix}`;
1565
+ while (scopeMap.has(candidate)) {
1566
+ suffix += 1;
1567
+ candidate = `${baseName}-${suffix}`;
1568
+ }
1569
+ return candidate;
1570
+ }
1571
+
1572
+ /**
1573
+ * @param {ReferenceDecl} decl
1574
+ * @returns {string}
1575
+ */
1576
+ function deriveReferenceName(decl) {
1577
+ const title = normalizeTitleValue(decl.title?.raw);
1578
+ if (title) {
1579
+ return title;
1580
+ }
1581
+ if (decl.paths && decl.paths.length > 0) {
1582
+ const rawPath = decl.paths[0];
1583
+ const trimmed = rawPath.replace(/\/+$/, '');
1584
+ const parts = trimmed.split('/').filter(Boolean);
1585
+ if (parts.length > 0) {
1586
+ const lastPart = parts[parts.length - 1];
1587
+ const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
1588
+ return withoutExt.replace(/\./g, '-');
1589
+ }
1590
+ }
1591
+ if (decl.url) {
1592
+ try {
1593
+ const parsed = new URL(decl.url);
1594
+ const segments = parsed.pathname.split('/').filter(Boolean);
1595
+ if (segments.length > 0) {
1596
+ const lastSegment = segments[segments.length - 1];
1597
+ const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
1598
+ return withoutExt.replace(/\./g, '-');
1599
+ }
1600
+ if (parsed.hostname) {
1601
+ return parsed.hostname.replace(/\./g, '-');
1602
+ }
1603
+ } catch {
1604
+ // Ignore malformed URLs here; validation handles required fields
1605
+ }
1606
+ }
1607
+ return 'reference';
1608
+ }
1609
+
1085
1610
  /**
1086
1611
  * Creates a node ID
1087
1612
  * @param {string} universeName