@sprig-and-prose/sprig-universe 0.1.1 → 0.3.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/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
@@ -38,6 +36,10 @@ import { normalizeProseBlock } from './util/text.js';
38
36
 
39
37
  /**
40
38
  * Builds a UniverseGraph from parsed AST files
39
+ *
40
+ * Note: There is only ever one universe per manifest. All files must declare
41
+ * the same universe name, and they are merged into a single universe.
42
+ *
41
43
  * @param {FileAST[]} fileASTs - Array of parsed file ASTs
42
44
  * @returns {UniverseGraph}
43
45
  */
@@ -48,19 +50,30 @@ export function buildGraph(fileASTs) {
48
50
  nodes: {},
49
51
  edges: {},
50
52
  diagnostics: [],
51
- referencesByName: {},
52
53
  documentsByName: {},
54
+ repositories: {},
55
+ references: {},
53
56
  };
54
57
 
55
- // Track node names within each universe for duplicate detection
56
- const nameToNodeId = new Map(); // universeName -> Map<name, firstNodeId>
58
+ // Track node names within each scope (container) for scoped resolution
59
+ // Map<containerNodeId, Map<name, nodeId[]>> - allows multiple nodes with same name in different scopes
60
+ const scopeIndex = new Map(); // containerNodeId -> Map<name, nodeId[]>
57
61
 
58
- // Track named references and documents per universe for duplicate detection
59
- const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
62
+ // Track named documents per universe for duplicate detection
60
63
  const namedDocumentsByUniverse = new Map(); // universeName -> Map<name, { source }>
61
64
 
62
- // Track repositories per universe
63
- 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 = [];
64
77
 
65
78
  // First pass: collect all universe names with their file locations for validation
66
79
  const universeNameToFiles = new Map(); // universeName -> Set<file>
@@ -102,17 +115,6 @@ export function buildGraph(fileASTs) {
102
115
  };
103
116
  }
104
117
 
105
- // Initialize name map for this universe (only once per universe)
106
- if (!nameToNodeId.has(universeName)) {
107
- nameToNodeId.set(universeName, new Map());
108
- }
109
- const nameMap = nameToNodeId.get(universeName);
110
-
111
- // Initialize tracking maps for named references and documents (only once per universe)
112
- if (!namedReferencesByUniverse.has(universeName)) {
113
- namedReferencesByUniverse.set(universeName, new Map());
114
- graph.referencesByName[universeName] = {};
115
- }
116
118
  if (!namedDocumentsByUniverse.has(universeName)) {
117
119
  namedDocumentsByUniverse.set(universeName, new Map());
118
120
  graph.documentsByName[universeName] = {};
@@ -120,6 +122,9 @@ export function buildGraph(fileASTs) {
120
122
  if (!repositoriesByUniverse.has(universeName)) {
121
123
  repositoriesByUniverse.set(universeName, new Map());
122
124
  }
125
+ if (!referencesByUniverse.has(universeName)) {
126
+ referencesByUniverse.set(universeName, new Map());
127
+ }
123
128
 
124
129
  // Check if universe node already exists (from a previous file)
125
130
  const existingUniverseNode = graph.nodes[universeNodeId];
@@ -175,12 +180,15 @@ export function buildGraph(fileASTs) {
175
180
  universeName,
176
181
  universeNodeId,
177
182
  universeDecl.body,
178
- nameMap,
183
+ scopeIndex,
179
184
  fileAST.file,
180
185
  universeNodeId,
181
- namedReferencesByUniverse.get(universeName),
182
186
  namedDocumentsByUniverse.get(universeName),
183
187
  repositoriesByUniverse.get(universeName),
188
+ referencesByUniverse.get(universeName),
189
+ entityKinds,
190
+ pendingReferenceDecls,
191
+ pendingReferenceAttachments,
184
192
  );
185
193
  } else {
186
194
  // First time seeing this universe - create node and process body
@@ -192,7 +200,8 @@ export function buildGraph(fileASTs) {
192
200
  universeDecl,
193
201
  );
194
202
  graph.nodes[universeNodeId] = universeNode;
195
- nameMap.set(universeName, universeNodeId);
203
+ // Add universe name to its own scope
204
+ addNameToScope(graph, scopeIndex, universeNodeId, universeName, universeNodeId);
196
205
 
197
206
  // Process universe body (universeNodeId is both parent and current node for UnknownBlocks)
198
207
  processBody(
@@ -200,88 +209,377 @@ export function buildGraph(fileASTs) {
200
209
  universeName,
201
210
  universeNodeId,
202
211
  universeDecl.body,
203
- nameMap,
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 from all universes and convert to config format
215
- graph.repositories = {};
216
- for (const [universeName, reposMap] of repositoriesByUniverse.entries()) {
217
- for (const [repoName, repoDecl] of reposMap.entries()) {
218
- // Convert repository kind from package name to directory name
219
- // '@sprig-and-prose/sprig-repository-github' -> 'sprig-repository-github'
220
- let kindValue = String(repoDecl.repositoryKind);
221
- if (kindValue.startsWith('@') && kindValue.includes('/')) {
222
- const parts = kindValue.split('/');
223
- 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
+ }
224
270
  }
225
-
226
- graph.repositories[repoName] = {
227
- kind: kindValue,
228
- options: repoDecl.options,
229
- };
230
271
  }
231
272
  }
232
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
+
233
286
  // Resolve edge endpoints
234
- resolveEdges(graph, nameToNodeId);
287
+ resolveEdges(graph, scopeIndex);
235
288
 
236
289
  return graph;
237
290
  }
238
291
 
292
+ /**
293
+ * Adds a name to a scope index and all ancestor scopes
294
+ * This allows names to be resolved from parent containers
295
+ * @param {UniverseGraph} graph - The graph
296
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
297
+ * @param {string} scopeNodeId - Container node ID (scope)
298
+ * @param {string} name - Name to add
299
+ * @param {string} nodeId - Node ID to associate with the name
300
+ */
301
+ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
302
+ // Add to current scope and all ancestor scopes
303
+ let currentScope = scopeNodeId;
304
+ const visitedScopes = new Set();
305
+
306
+ while (currentScope && !visitedScopes.has(currentScope)) {
307
+ visitedScopes.add(currentScope);
308
+
309
+ if (!scopeIndex.has(currentScope)) {
310
+ scopeIndex.set(currentScope, new Map());
311
+ }
312
+ const scopeMap = scopeIndex.get(currentScope);
313
+ if (!scopeMap.has(name)) {
314
+ scopeMap.set(name, []);
315
+ }
316
+ scopeMap.get(name).push(nodeId);
317
+
318
+ // Move to parent scope
319
+ const node = graph.nodes[currentScope];
320
+ if (node && node.parent) {
321
+ currentScope = node.parent;
322
+ } else {
323
+ break;
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
330
+ * Returns the first matching node ID found, or null if not found
331
+ * Also checks for ambiguity (multiple matches at the same scope level)
332
+ * @param {UniverseGraph} graph - The graph
333
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
334
+ * @param {string} name - Name to resolve
335
+ * @param {string} startScopeNodeId - Starting scope (container node ID)
336
+ * @param {SourceSpan} [source] - Optional source span for error reporting
337
+ * @returns {{ nodeId: string | null, ambiguous: boolean, ambiguousNodes: string[] }}
338
+ */
339
+ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
340
+ // Handle qualified names (dot notation)
341
+ if (name.includes('.')) {
342
+ const parts = name.split('.');
343
+ // Start from universe and resolve each part
344
+ const universeName = startScopeNodeId.split(':')[0];
345
+ const universeNodeId = `${universeName}:universe:${universeName}`;
346
+ let currentScope = universeNodeId;
347
+
348
+ for (const part of parts) {
349
+ const scopeMap = scopeIndex.get(currentScope);
350
+ if (!scopeMap || !scopeMap.has(part)) {
351
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
352
+ }
353
+ const candidates = scopeMap.get(part);
354
+ if (candidates.length === 0) {
355
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
356
+ }
357
+ if (candidates.length > 1) {
358
+ // Ambiguity at this level
359
+ if (source) {
360
+ graph.diagnostics.push({
361
+ severity: 'error',
362
+ message: `Ambiguous reference "${part}" in scope "${currentScope}": found ${candidates.length} matches`,
363
+ source,
364
+ });
365
+ }
366
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
367
+ }
368
+ currentScope = candidates[0];
369
+ }
370
+ return { nodeId: currentScope, ambiguous: false, ambiguousNodes: [] };
371
+ }
372
+
373
+ // Unqualified name - walk scope chain
374
+ let currentScope = startScopeNodeId;
375
+ const visitedScopes = new Set();
376
+
377
+ while (currentScope) {
378
+ if (visitedScopes.has(currentScope)) {
379
+ break; // Prevent infinite loops
380
+ }
381
+ visitedScopes.add(currentScope);
382
+
383
+ const scopeMap = scopeIndex.get(currentScope);
384
+ if (scopeMap && scopeMap.has(name)) {
385
+ const candidates = scopeMap.get(name);
386
+ if (candidates.length === 0) {
387
+ // Continue to parent scope
388
+ } else if (candidates.length === 1) {
389
+ return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
390
+ } else {
391
+ // Ambiguity at this scope level
392
+ if (source) {
393
+ const scopeNode = graph.nodes[currentScope];
394
+ const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
395
+ graph.diagnostics.push({
396
+ severity: 'error',
397
+ message: `Ambiguous reference "${name}" in scope "${scopeName}": found ${candidates.length} matches`,
398
+ source,
399
+ });
400
+ }
401
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
402
+ }
403
+ }
404
+
405
+ // Move to parent scope
406
+ const node = graph.nodes[currentScope];
407
+ if (node && node.parent) {
408
+ currentScope = node.parent;
409
+ } else {
410
+ // Reached universe root or no parent - check universe scope one more time if we haven't already
411
+ const universeName = startScopeNodeId.split(':')[0];
412
+ const universeNodeId = `${universeName}:universe:${universeName}`;
413
+ if (currentScope !== universeNodeId) {
414
+ // Check universe scope before giving up
415
+ const universeScopeMap = scopeIndex.get(universeNodeId);
416
+ if (universeScopeMap && universeScopeMap.has(name)) {
417
+ const candidates = universeScopeMap.get(name);
418
+ if (candidates.length === 1) {
419
+ return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
420
+ } else if (candidates.length > 1) {
421
+ if (source) {
422
+ graph.diagnostics.push({
423
+ severity: 'error',
424
+ message: `Ambiguous reference "${name}" in universe scope: found ${candidates.length} matches`,
425
+ source,
426
+ });
427
+ }
428
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
429
+ }
430
+ }
431
+ }
432
+ break;
433
+ }
434
+ }
435
+
436
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
437
+ }
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
+
239
484
  /**
240
485
  * Processes a body of declarations, creating nodes and edges
241
486
  * @param {UniverseGraph} graph
242
487
  * @param {string} universeName
243
488
  * @param {string} parentNodeId - Parent node ID (for tree relationships)
244
- * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>} body
245
- * @param {Map<string, string>} nameMap
489
+ * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body
490
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
246
491
  * @param {string} file
247
492
  * @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
248
- * @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
249
493
  * @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
250
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
251
499
  */
252
- function processBody(graph, universeName, parentNodeId, body, nameMap, 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
+ ) {
253
515
  for (const decl of body) {
254
516
  if (decl.kind === 'anthology') {
255
517
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
256
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'anthology', nameMap, decl.source);
257
- const node = createNode(nodeId, 'anthology', decl.name, parentNodeId, decl);
518
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
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);
258
527
  graph.nodes[nodeId] = node;
259
- graph.nodes[parentNodeId].children.push(nodeId);
260
- nameMap.set(decl.name, nodeId);
261
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
528
+ graph.nodes[actualParentNodeId].children.push(nodeId);
529
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
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
+ );
262
545
  } else if (decl.kind === 'series') {
263
546
  const nodeId = makeNodeId(universeName, 'series', decl.name);
264
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'series', nameMap, decl.source);
547
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
265
548
 
266
549
  // Handle optional anthology parent
267
550
  let actualParentNodeId = parentNodeId;
268
551
  if (decl.parentName) {
269
- const anthologyNodeId = nameMap.get(decl.parentName);
270
- if (anthologyNodeId) {
271
- actualParentNodeId = anthologyNodeId;
552
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
553
+ if (resolved.nodeId && !resolved.ambiguous) {
554
+ actualParentNodeId = resolved.nodeId;
272
555
  }
273
- // If anthology not found, fall back to parentNodeId (tolerant parsing)
556
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
274
557
  }
275
558
 
276
559
  const node = createNode(nodeId, 'series', decl.name, actualParentNodeId, decl);
277
560
  graph.nodes[nodeId] = node;
278
561
  graph.nodes[actualParentNodeId].children.push(nodeId);
279
- nameMap.set(decl.name, nodeId);
280
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
562
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
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
+ );
281
578
  } else if (decl.kind === 'book') {
282
579
  const nodeId = makeNodeId(universeName, 'book', decl.name);
283
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'book', nameMap, decl.source);
284
- const containerNodeId = nameMap.get(decl.parentName);
580
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'book', decl.source);
581
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
582
+ const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
285
583
  // Always set container for book nodes (even if undefined when parentName doesn't resolve)
286
584
  const node = createNode(
287
585
  nodeId,
@@ -297,12 +595,27 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
297
595
  } else {
298
596
  graph.nodes[parentNodeId].children.push(nodeId);
299
597
  }
300
- nameMap.set(decl.name, nodeId);
301
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
598
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
599
+ processBody(
600
+ graph,
601
+ universeName,
602
+ nodeId,
603
+ decl.body,
604
+ scopeIndex,
605
+ file,
606
+ nodeId,
607
+ namedDocsMap,
608
+ reposMap,
609
+ refsMap,
610
+ entityKinds,
611
+ pendingReferenceDecls,
612
+ pendingReferenceAttachments,
613
+ );
302
614
  } else if (decl.kind === 'chapter') {
303
615
  const nodeId = makeNodeId(universeName, 'chapter', decl.name);
304
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'chapter', nameMap, decl.source);
305
- const containerNodeId = nameMap.get(decl.parentName);
616
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
617
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
618
+ const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
306
619
  // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
307
620
  const node = createNode(
308
621
  nodeId,
@@ -318,18 +631,32 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
318
631
  } else {
319
632
  graph.nodes[parentNodeId].children.push(nodeId);
320
633
  }
321
- nameMap.set(decl.name, nodeId);
322
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
634
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
635
+ processBody(
636
+ graph,
637
+ universeName,
638
+ nodeId,
639
+ decl.body,
640
+ scopeIndex,
641
+ file,
642
+ nodeId,
643
+ namedDocsMap,
644
+ reposMap,
645
+ refsMap,
646
+ entityKinds,
647
+ pendingReferenceDecls,
648
+ pendingReferenceAttachments,
649
+ );
323
650
  } else if (decl.kind === 'concept') {
324
651
  const nodeId = makeNodeId(universeName, 'concept', decl.name);
325
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'concept', nameMap, decl.source);
652
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
326
653
 
327
654
  // Handle optional parent
328
655
  let actualParentNodeId = parentNodeId;
329
656
  if (decl.parentName) {
330
- const parentNodeIdFromName = nameMap.get(decl.parentName);
331
- if (parentNodeIdFromName) {
332
- actualParentNodeId = parentNodeIdFromName;
657
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
658
+ if (resolved.nodeId && !resolved.ambiguous) {
659
+ actualParentNodeId = resolved.nodeId;
333
660
  }
334
661
  // If parent not found, fall back to parentNodeId (tolerant parsing)
335
662
  }
@@ -337,8 +664,22 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
337
664
  const node = createNode(nodeId, 'concept', decl.name, actualParentNodeId, decl);
338
665
  graph.nodes[nodeId] = node;
339
666
  graph.nodes[actualParentNodeId].children.push(nodeId);
340
- nameMap.set(decl.name, nodeId);
341
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
667
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
668
+ processBody(
669
+ graph,
670
+ universeName,
671
+ nodeId,
672
+ decl.body,
673
+ scopeIndex,
674
+ file,
675
+ nodeId,
676
+ namedDocsMap,
677
+ reposMap,
678
+ refsMap,
679
+ entityKinds,
680
+ pendingReferenceDecls,
681
+ pendingReferenceAttachments,
682
+ );
342
683
  } else if (decl.kind === 'relates') {
343
684
  // Check for duplicate relates in reverse order
344
685
  checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
@@ -520,51 +861,12 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
520
861
  // Title blocks are attached to their parent node
521
862
  // This is handled in createNode
522
863
  } else if (decl.kind === 'references') {
523
- // ReferencesBlock - attach to current node
524
- const currentNode = graph.nodes[currentNodeId];
525
- if (!currentNode.references) {
526
- currentNode.references = [];
527
- }
528
- // Serialize references block - handle both inline references and using blocks
529
- for (const item of decl.references) {
530
- if (item.kind === 'reference') {
531
- // Inline reference block - convert as before
532
- currentNode.references.push({
533
- repository: item.repository,
534
- paths: item.paths,
535
- kind: item.referenceKind,
536
- describe: item.describe
537
- ? {
538
- raw: item.describe.raw,
539
- normalized: normalizeProseBlock(item.describe.raw),
540
- source: item.describe.source,
541
- }
542
- : undefined,
543
- source: item.source,
544
- });
545
- } else if (item.kind === 'using-in-references') {
546
- // Using block - resolve each name against named references registry
547
- for (const name of item.names) {
548
- const namedRef = graph.referencesByName[universeName]?.[name];
549
- if (namedRef) {
550
- // Resolved: expand to reference object
551
- currentNode.references.push({
552
- repository: namedRef.repository,
553
- paths: namedRef.paths,
554
- kind: namedRef.kind,
555
- describe: namedRef.describe,
556
- source: namedRef.source, // Use the named reference's source, not the using block's
557
- });
558
- } else {
559
- // Not resolved: emit error diagnostic
560
- graph.diagnostics.push({
561
- severity: 'error',
562
- message: `Unknown reference '${name}' used in references block`,
563
- source: item.source,
564
- });
565
- }
566
- }
567
- }
864
+ if (pendingReferenceAttachments) {
865
+ pendingReferenceAttachments.push({
866
+ nodeId: currentNodeId,
867
+ items: decl.items,
868
+ universeName,
869
+ });
568
870
  }
569
871
  } else if (decl.kind === 'documentation') {
570
872
  // DocumentationBlock - attach to current node
@@ -588,35 +890,75 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
588
890
  source: doc.source,
589
891
  });
590
892
  }
591
- } else if (decl.kind === 'named-reference') {
592
- // NamedReferenceBlock - store in universe-level registry
593
- // Only process at universe scope (when namedRefsMap is provided)
594
- if (namedRefsMap && parentNodeId === currentNodeId) {
595
- const refName = decl.name;
596
-
597
- // Check for duplicate name
598
- if (namedRefsMap.has(refName)) {
599
- const firstOccurrence = namedRefsMap.get(refName);
600
- graph.diagnostics.push({
601
- severity: 'error',
602
- message: `Duplicate named reference "${refName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
603
- source: decl.source,
604
- });
605
- graph.diagnostics.push({
606
- severity: 'error',
607
- message: `First declaration of named reference "${refName}" was here.`,
608
- source: firstOccurrence.source,
893
+ } else if (decl.kind === 'reference') {
894
+ if (refsMap && entityKinds && pendingReferenceDecls) {
895
+ const baseName = decl.name || deriveReferenceName(decl);
896
+ const uniqueName = decl.name
897
+ ? baseName
898
+ : pickUniqueName(scopeIndex, currentNodeId, baseName);
899
+ const qualifiedName = makeQualifiedName(graph, currentNodeId, uniqueName);
900
+ const refId = makeEntityId(universeName, 'reference', qualifiedName);
901
+ if (decl.name) {
902
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, refId, 'reference', decl.source, entityKinds);
903
+ }
904
+
905
+ if (!refsMap.has(refId)) {
906
+ refsMap.set(refId, decl);
907
+ entityKinds.set(refId, 'reference');
908
+ addNameToScope(graph, scopeIndex, currentNodeId, uniqueName, refId);
909
+ if (!graph.references[refId]) {
910
+ graph.references[refId] = {
911
+ id: refId,
912
+ name: uniqueName,
913
+ urls: [],
914
+ source: decl.source,
915
+ };
916
+ }
917
+ pendingReferenceDecls.push({
918
+ refId,
919
+ decl,
920
+ universeName,
921
+ scopeNodeId: currentNodeId,
609
922
  });
610
- } else {
611
- // Store first occurrence
612
- namedRefsMap.set(refName, { source: decl.source });
613
-
614
- // Convert to model and store in registry
615
- const refModel = {
616
- name: refName,
617
- repository: decl.repository,
618
- paths: decl.paths,
619
- kind: decl.referenceKind,
923
+
924
+ if (pendingReferenceAttachments && currentNodeId !== makeNodeId(universeName, 'universe', universeName)) {
925
+ pendingReferenceAttachments.push({
926
+ nodeId: currentNodeId,
927
+ items: [
928
+ {
929
+ name: uniqueName,
930
+ source: decl.source,
931
+ },
932
+ ],
933
+ universeName,
934
+ });
935
+ }
936
+ }
937
+ }
938
+ } else if (decl.kind === 'repository') {
939
+ // RepositoryDecl - store in universe-level registry
940
+ if (reposMap && entityKinds) {
941
+ const scopeNodeId = resolveContainerScope(graph, scopeIndex, currentNodeId, decl.parentName, decl.source);
942
+ const qualifiedName = makeQualifiedName(graph, scopeNodeId, decl.name);
943
+ const repoId = makeEntityId(universeName, 'repository', qualifiedName);
944
+ checkDuplicateInScope(graph, scopeIndex, scopeNodeId, decl.name, repoId, 'repository', decl.source, entityKinds);
945
+
946
+ if (!reposMap.has(repoId)) {
947
+ reposMap.set(repoId, decl);
948
+ entityKinds.set(repoId, 'repository');
949
+ addNameToScope(graph, scopeIndex, scopeNodeId, decl.name, repoId);
950
+ if (!decl.url) {
951
+ graph.diagnostics.push({
952
+ severity: 'error',
953
+ message: `Repository "${decl.name}" is missing url { '...' }`,
954
+ source: decl.source,
955
+ });
956
+ }
957
+ graph.repositories[repoId] = {
958
+ id: repoId,
959
+ name: decl.name,
960
+ url: decl.url || '',
961
+ title: normalizeTitleValue(decl.title?.raw),
620
962
  describe: decl.describe
621
963
  ? {
622
964
  raw: decl.describe.raw,
@@ -624,34 +966,15 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
624
966
  source: decl.describe.source,
625
967
  }
626
968
  : undefined,
969
+ note: decl.note
970
+ ? {
971
+ raw: decl.note.raw,
972
+ normalized: normalizeProseBlock(decl.note.raw),
973
+ source: decl.note.source,
974
+ }
975
+ : undefined,
627
976
  source: decl.source,
628
977
  };
629
-
630
- graph.referencesByName[universeName][refName] = refModel;
631
- }
632
- }
633
- } else if (decl.kind === 'repository') {
634
- // RepositoryDecl - store in universe-level registry
635
- // Only process at universe scope (when reposMap is provided)
636
- if (reposMap && parentNodeId === currentNodeId) {
637
- const repoName = decl.name;
638
-
639
- // Check for duplicate name
640
- if (reposMap.has(repoName)) {
641
- const firstOccurrence = reposMap.get(repoName);
642
- graph.diagnostics.push({
643
- severity: 'error',
644
- message: `Duplicate repository "${repoName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
645
- source: decl.source,
646
- });
647
- graph.diagnostics.push({
648
- severity: 'error',
649
- message: `First declaration of repository "${repoName}" was here.`,
650
- source: firstOccurrence.source,
651
- });
652
- } else {
653
- // Store repository declaration
654
- reposMap.set(repoName, decl);
655
978
  }
656
979
  }
657
980
  } else if (decl.kind === 'named-document') {
@@ -718,7 +1041,7 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
718
1041
  * @param {string} name
719
1042
  * @param {string | undefined} parentNodeId
720
1043
  * @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
721
- * @param {string | undefined} containerNodeId
1044
+ * @param {string | undefined} [containerNodeId]
722
1045
  * @returns {NodeModel}
723
1046
  */
724
1047
  function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
@@ -766,26 +1089,37 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
766
1089
  }
767
1090
 
768
1091
  /**
769
- * Checks for duplicate node names and emits warning if found
1092
+ * Checks for duplicate node names within a scope and emits error if found
770
1093
  * @param {UniverseGraph} graph
771
- * @param {string} universeName
1094
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
1095
+ * @param {string} scopeNodeId - Container node ID (scope)
772
1096
  * @param {string} name
773
1097
  * @param {string} nodeId
774
1098
  * @param {string} kind - The kind of the current node being checked
775
- * @param {Map<string, string>} nameMap
776
1099
  * @param {SourceSpan} source
1100
+ * @param {Map<string, string>} [entityKinds]
777
1101
  */
778
- function checkDuplicateName(graph, universeName, name, nodeId, kind, nameMap, source) {
779
- if (nameMap.has(name)) {
780
- const firstNodeId = nameMap.get(name);
781
- const firstNode = graph.nodes[firstNodeId];
782
- const firstKind = firstNode?.kind || 'unknown';
783
- graph.diagnostics.push({
784
- severity: 'warning',
785
- message: `Duplicate concept name "${name}" in universe "${universeName}": already defined as ${firstKind} (${firstNodeId}), now also defined as ${kind}`,
786
- source: source, // Current duplicate's source span
787
- });
788
- // Note: Resolution will choose the first encountered (already in nameMap)
1102
+ function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source, entityKinds) {
1103
+ const scopeMap = scopeIndex.get(scopeNodeId);
1104
+ if (scopeMap && scopeMap.has(name)) {
1105
+ const existingNodeIds = scopeMap.get(name);
1106
+ if (existingNodeIds.length > 0) {
1107
+ const firstNodeId = existingNodeIds[0];
1108
+ const firstNode = graph.nodes[firstNodeId];
1109
+ const firstKind = firstNode?.kind || entityKinds?.get(firstNodeId) || 'unknown';
1110
+ const scopeNode = graph.nodes[scopeNodeId];
1111
+ const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
1112
+ graph.diagnostics.push({
1113
+ severity: 'error',
1114
+ message: `Duplicate name "${name}" in scope "${scopeName}": already defined as ${firstKind}, now also defined as ${kind}`,
1115
+ source: source,
1116
+ });
1117
+ graph.diagnostics.push({
1118
+ severity: 'error',
1119
+ message: `First declaration of "${name}" was here.`,
1120
+ source: firstNode.source,
1121
+ });
1122
+ }
789
1123
  }
790
1124
  }
791
1125
 
@@ -812,9 +1146,9 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
812
1146
  /**
813
1147
  * Resolves edge endpoint references and relates node endpoints
814
1148
  * @param {UniverseGraph} graph
815
- * @param {Map<string, Map<string, string>>} nameToNodeId
1149
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
816
1150
  */
817
- function resolveEdges(graph, nameToNodeId) {
1151
+ function resolveEdges(graph, scopeIndex) {
818
1152
  // Build a set of relates node names to avoid duplicate warnings
819
1153
  // (edges are created for backward compatibility alongside relates nodes)
820
1154
  const relatesNodeNames = new Set();
@@ -829,34 +1163,36 @@ function resolveEdges(graph, nameToNodeId) {
829
1163
  for (const edgeId in graph.edges) {
830
1164
  const edge = graph.edges[edgeId];
831
1165
  const universeName = edgeId.split(':')[0];
832
- const nameMap = nameToNodeId.get(universeName);
833
-
834
- if (nameMap) {
835
- // Check if this edge corresponds to a relates node
836
- // If so, skip warnings here (they'll be generated during relates node resolution)
837
- const edgeName = `${edge.a.text} and ${edge.b.text}`;
838
- const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
839
- const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
840
-
841
- // Resolve endpoint A
842
- const targetA = nameMap.get(edge.a.text);
843
- if (targetA) {
844
- edge.a.target = targetA;
845
- } else if (!hasRelatesNode) {
846
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1166
+ const universeNodeId = `${universeName}:universe:${universeName}`;
1167
+
1168
+ // Check if this edge corresponds to a relates node
1169
+ // If so, skip warnings here (they'll be generated during relates node resolution)
1170
+ const edgeName = `${edge.a.text} and ${edge.b.text}`;
1171
+ const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
1172
+ const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
1173
+
1174
+ // Resolve endpoint A - start from universe scope
1175
+ const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1176
+ if (resolvedA.nodeId && !resolvedA.ambiguous) {
1177
+ edge.a.target = resolvedA.nodeId;
1178
+ } else if (!hasRelatesNode) {
1179
+ // Only warn if there's no corresponding relates node (legacy edge-only format)
1180
+ if (!resolvedA.ambiguous) {
847
1181
  graph.diagnostics.push({
848
1182
  severity: 'warning',
849
1183
  message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
850
1184
  source: edge.source,
851
1185
  });
852
1186
  }
1187
+ }
853
1188
 
854
- // Resolve endpoint B
855
- const targetB = nameMap.get(edge.b.text);
856
- if (targetB) {
857
- edge.b.target = targetB;
858
- } else if (!hasRelatesNode) {
859
- // Only warn if there's no corresponding relates node (legacy edge-only format)
1189
+ // Resolve endpoint B - start from universe scope
1190
+ const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1191
+ if (resolvedB.nodeId && !resolvedB.ambiguous) {
1192
+ edge.b.target = resolvedB.nodeId;
1193
+ } else if (!hasRelatesNode) {
1194
+ // Only warn if there's no corresponding relates node (legacy edge-only format)
1195
+ if (!resolvedB.ambiguous) {
860
1196
  graph.diagnostics.push({
861
1197
  severity: 'warning',
862
1198
  message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
@@ -871,18 +1207,21 @@ function resolveEdges(graph, nameToNodeId) {
871
1207
  const node = graph.nodes[nodeId];
872
1208
  if (node.kind === 'relates' && node.unresolvedEndpoints) {
873
1209
  const universeName = nodeId.split(':')[0];
874
- const nameMap = nameToNodeId.get(universeName);
1210
+ const universeNodeId = `${universeName}:universe:${universeName}`;
1211
+
1212
+ // Determine the scope where this relates block was declared (parent of relates node)
1213
+ const relatesScope = node.parent || universeNodeId;
875
1214
 
876
- if (nameMap) {
877
- const resolvedEndpoints = [];
878
- const unresolved = [];
1215
+ const resolvedEndpoints = [];
1216
+ const unresolved = [];
879
1217
 
880
- for (const endpointName of node.unresolvedEndpoints) {
881
- const targetId = nameMap.get(endpointName);
882
- if (targetId) {
883
- resolvedEndpoints.push(targetId);
884
- } else {
885
- unresolved.push(endpointName);
1218
+ for (const endpointName of node.unresolvedEndpoints) {
1219
+ const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
1220
+ if (resolved.nodeId && !resolved.ambiguous) {
1221
+ resolvedEndpoints.push(resolved.nodeId);
1222
+ } else {
1223
+ unresolved.push(endpointName);
1224
+ if (!resolved.ambiguous) {
886
1225
  graph.diagnostics.push({
887
1226
  severity: 'warning',
888
1227
  message: `Unresolved relates endpoint "${endpointName}" in universe "${universeName}"`,
@@ -890,31 +1229,345 @@ function resolveEdges(graph, nameToNodeId) {
890
1229
  });
891
1230
  }
892
1231
  }
1232
+ }
893
1233
 
894
- node.endpoints = resolvedEndpoints;
895
- if (unresolved.length > 0) {
896
- node.unresolvedEndpoints = unresolved;
1234
+ node.endpoints = resolvedEndpoints;
1235
+ if (unresolved.length > 0) {
1236
+ node.unresolvedEndpoints = unresolved;
1237
+ } else {
1238
+ delete node.unresolvedEndpoints;
1239
+ }
1240
+
1241
+ // Resolve from blocks: convert from endpoint names to node IDs
1242
+ if (node.from) {
1243
+ const resolvedFrom = {};
1244
+ for (const endpointName in node.from) {
1245
+ const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
1246
+ if (resolved.nodeId && !resolved.ambiguous) {
1247
+ resolvedFrom[resolved.nodeId] = node.from[endpointName];
1248
+ } else {
1249
+ // Keep unresolved from blocks keyed by name
1250
+ resolvedFrom[endpointName] = node.from[endpointName];
1251
+ }
1252
+ }
1253
+ node.from = resolvedFrom;
1254
+ }
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * @param {string | undefined} raw
1261
+ * @returns {string | undefined}
1262
+ */
1263
+ function normalizeTitleValue(raw) {
1264
+ if (!raw) return undefined;
1265
+ const trimmed = raw.trim();
1266
+ if (!trimmed) return undefined;
1267
+ const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
1268
+ return unquoted.trim() || undefined;
1269
+ }
1270
+
1271
+ /**
1272
+ * @param {UniverseGraph} graph
1273
+ * @param {Array<{refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string}>} pending
1274
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1275
+ */
1276
+ function resolveReferenceDecls(graph, pending, scopeIndex) {
1277
+ /** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
1278
+ const unknownRepoCounts = new Map();
1279
+ for (const item of pending) {
1280
+ const { refId, decl, scopeNodeId } = item;
1281
+ const model = graph.references[refId] || { id: refId, name: decl.name, urls: [], source: decl.source };
1282
+ const displayName = model.name || decl.name || 'unnamed reference';
1283
+ let urls = [];
1284
+ let repositoryRef = undefined;
1285
+
1286
+ if (decl.url && decl.repositoryName) {
1287
+ graph.diagnostics.push({
1288
+ severity: 'error',
1289
+ message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
1290
+ source: diagnosticSource(decl.source),
1291
+ });
1292
+ }
1293
+
1294
+ if (decl.url) {
1295
+ urls = [decl.url];
1296
+ } else if (decl.repositoryName) {
1297
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.repositoryName, scopeNodeId, decl.source);
1298
+ if (resolved.nodeId && !resolved.ambiguous) {
1299
+ const repo = graph.repositories[resolved.nodeId];
1300
+ if (!repo) {
1301
+ graph.diagnostics.push({
1302
+ severity: 'error',
1303
+ message: `Reference "${displayName}" uses "${decl.repositoryName}" which is not a repository`,
1304
+ source: diagnosticSource(decl.source),
1305
+ });
1306
+ } else if (!repo.url) {
1307
+ graph.diagnostics.push({
1308
+ severity: 'error',
1309
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1310
+ source: decl.source,
1311
+ });
897
1312
  } else {
898
- delete node.unresolvedEndpoints;
1313
+ repositoryRef = repo.id;
1314
+ if (decl.paths && decl.paths.length > 0) {
1315
+ urls = decl.paths.map((path) => joinRepositoryUrl(repo.url, path));
1316
+ } else if (decl.paths && decl.paths.length === 0) {
1317
+ graph.diagnostics.push({
1318
+ severity: 'error',
1319
+ message: `Reference "${displayName}" has an empty paths block`,
1320
+ source: diagnosticSource(decl.source),
1321
+ });
1322
+ } else {
1323
+ urls = [repo.url];
1324
+ }
1325
+ }
1326
+ } else if (!resolved.ambiguous) {
1327
+ const entry = unknownRepoCounts.get(decl.repositoryName) || {
1328
+ count: 0,
1329
+ source: decl.source,
1330
+ };
1331
+ entry.count += 1;
1332
+ if (!entry.source && decl.source) {
1333
+ entry.source = decl.source;
1334
+ }
1335
+ unknownRepoCounts.set(decl.repositoryName, entry);
1336
+ }
1337
+ } else {
1338
+ graph.diagnostics.push({
1339
+ severity: 'error',
1340
+ message: `Reference "${displayName}" must have url { ... } or in <Repository>`,
1341
+ source: diagnosticSource(decl.source),
1342
+ });
1343
+ }
1344
+
1345
+ model.name = decl.name || model.name || displayName;
1346
+ model.kind = decl.referenceKind || undefined;
1347
+ model.title = normalizeTitleValue(decl.title?.raw);
1348
+ model.describe = decl.describe
1349
+ ? {
1350
+ raw: decl.describe.raw,
1351
+ normalized: normalizeProseBlock(decl.describe.raw),
1352
+ source: decl.describe.source,
1353
+ }
1354
+ : undefined;
1355
+ model.note = decl.note
1356
+ ? {
1357
+ raw: decl.note.raw,
1358
+ normalized: normalizeProseBlock(decl.note.raw),
1359
+ source: decl.note.source,
899
1360
  }
1361
+ : undefined;
1362
+ model.urls = urls;
1363
+ model.repositoryRef = repositoryRef;
1364
+ model.paths = decl.paths && decl.paths.length > 0 ? decl.paths : undefined;
1365
+ model.source = decl.source;
1366
+ graph.references[refId] = model;
1367
+ }
900
1368
 
901
- // Resolve from blocks: convert from endpoint names to node IDs
902
- if (node.from) {
903
- const resolvedFrom = {};
904
- for (const endpointName in node.from) {
905
- const targetId = nameMap.get(endpointName);
906
- if (targetId) {
907
- resolvedFrom[targetId] = node.from[endpointName];
908
- } else {
909
- // Keep unresolved from blocks keyed by name
910
- resolvedFrom[endpointName] = node.from[endpointName];
911
- }
1369
+ for (const [repoName, info] of unknownRepoCounts.entries()) {
1370
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1371
+ graph.diagnostics.push({
1372
+ severity: 'error',
1373
+ message: `Unknown repository "${repoName}" used by references${countText}.`,
1374
+ source: diagnosticSource(info.source),
1375
+ });
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * @param {UniverseGraph} graph
1381
+ * @param {Array<{nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string}>} pending
1382
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1383
+ */
1384
+ function resolveReferenceAttachments(graph, pending, scopeIndex) {
1385
+ /** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
1386
+ const unknownReferenceCounts = new Map();
1387
+
1388
+ for (const item of pending) {
1389
+ const node = graph.nodes[item.nodeId];
1390
+ if (!node) {
1391
+ continue;
1392
+ }
1393
+ if (!node.references) {
1394
+ node.references = [];
1395
+ }
1396
+
1397
+ for (const entry of item.items) {
1398
+ const name = entry.name;
1399
+ const resolved = resolveNameInScope(graph, scopeIndex, name, item.nodeId, entry.source);
1400
+ if (resolved.nodeId && !resolved.ambiguous) {
1401
+ if (graph.references[resolved.nodeId]) {
1402
+ node.references.push(resolved.nodeId);
1403
+ } else {
1404
+ const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
1405
+ existing.count += 1;
1406
+ if (!existing.source && entry.source) {
1407
+ existing.source = entry.source;
912
1408
  }
913
- node.from = resolvedFrom;
1409
+ unknownReferenceCounts.set(name, existing);
1410
+ }
1411
+ } else if (!resolved.ambiguous) {
1412
+ const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
1413
+ existing.count += 1;
1414
+ if (!existing.source && entry.source) {
1415
+ existing.source = entry.source;
914
1416
  }
1417
+ unknownReferenceCounts.set(name, existing);
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ for (const [name, info] of unknownReferenceCounts.entries()) {
1423
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1424
+ graph.diagnostics.push({
1425
+ severity: 'error',
1426
+ message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
1427
+ source: diagnosticSource(info.source),
1428
+ });
1429
+ }
1430
+ }
1431
+
1432
+ /**
1433
+ * @param {SourceSpan | undefined} source
1434
+ * @returns {SourceSpan | { file: string, line?: number, col?: number, start?: any, end?: any } | undefined}
1435
+ */
1436
+ function diagnosticSource(source) {
1437
+ if (!source) return undefined;
1438
+ return {
1439
+ file: source.file,
1440
+ line: source.start?.line,
1441
+ col: source.start?.col,
1442
+ start: source.start,
1443
+ end: source.end,
1444
+ };
1445
+ }
1446
+
1447
+ /**
1448
+ * @param {string} base
1449
+ * @param {string} path
1450
+ * @returns {string}
1451
+ */
1452
+ function joinRepositoryUrl(base, path) {
1453
+ if (base.endsWith('/') && path.startsWith('/')) {
1454
+ return base + path.slice(1);
1455
+ }
1456
+ if (!base.endsWith('/') && !path.startsWith('/')) {
1457
+ return `${base}/${path}`;
1458
+ }
1459
+ return base + path;
1460
+ }
1461
+
1462
+ /**
1463
+ * @param {UniverseGraph} graph
1464
+ * @param {string} containerNodeId
1465
+ * @param {string} name
1466
+ * @returns {string}
1467
+ */
1468
+ function makeQualifiedName(graph, containerNodeId, name) {
1469
+ const path = [];
1470
+ let current = containerNodeId;
1471
+ const visited = new Set();
1472
+ while (current && !visited.has(current)) {
1473
+ visited.add(current);
1474
+ const node = graph.nodes[current];
1475
+ if (!node) break;
1476
+ if (node.kind !== 'universe') {
1477
+ path.push(node.name);
1478
+ }
1479
+ current = node.parent;
1480
+ }
1481
+ path.reverse();
1482
+ path.push(name);
1483
+ return path.join('.');
1484
+ }
1485
+
1486
+ /**
1487
+ * @param {string} universeName
1488
+ * @param {string} kind
1489
+ * @param {string} qualifiedName
1490
+ * @returns {string}
1491
+ */
1492
+ function makeEntityId(universeName, kind, qualifiedName) {
1493
+ return `${universeName}:${kind}:${qualifiedName}`;
1494
+ }
1495
+
1496
+ /**
1497
+ * @param {UniverseGraph} graph
1498
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1499
+ * @param {string} currentNodeId
1500
+ * @param {string | undefined} parentName
1501
+ * @param {SourceSpan} source
1502
+ * @returns {string}
1503
+ */
1504
+ function resolveContainerScope(graph, scopeIndex, currentNodeId, parentName, source) {
1505
+ if (!parentName) {
1506
+ return currentNodeId;
1507
+ }
1508
+ const resolved = resolveNameInScope(graph, scopeIndex, parentName, currentNodeId, source);
1509
+ if (resolved.nodeId && !resolved.ambiguous) {
1510
+ return resolved.nodeId;
1511
+ }
1512
+ return currentNodeId;
1513
+ }
1514
+
1515
+ /**
1516
+ * @param {Map<string, Map<string, string[]>>} scopeIndex
1517
+ * @param {string} scopeNodeId
1518
+ * @param {string} baseName
1519
+ * @returns {string}
1520
+ */
1521
+ function pickUniqueName(scopeIndex, scopeNodeId, baseName) {
1522
+ const scopeMap = scopeIndex.get(scopeNodeId);
1523
+ if (!scopeMap || !scopeMap.has(baseName)) {
1524
+ return baseName;
1525
+ }
1526
+ let suffix = 2;
1527
+ let candidate = `${baseName}-${suffix}`;
1528
+ while (scopeMap.has(candidate)) {
1529
+ suffix += 1;
1530
+ candidate = `${baseName}-${suffix}`;
1531
+ }
1532
+ return candidate;
1533
+ }
1534
+
1535
+ /**
1536
+ * @param {ReferenceDecl} decl
1537
+ * @returns {string}
1538
+ */
1539
+ function deriveReferenceName(decl) {
1540
+ const title = normalizeTitleValue(decl.title?.raw);
1541
+ if (title) {
1542
+ return title;
1543
+ }
1544
+ if (decl.paths && decl.paths.length > 0) {
1545
+ const rawPath = decl.paths[0];
1546
+ const trimmed = rawPath.replace(/\/+$/, '');
1547
+ const parts = trimmed.split('/').filter(Boolean);
1548
+ if (parts.length > 0) {
1549
+ const lastPart = parts[parts.length - 1];
1550
+ const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
1551
+ return withoutExt.replace(/\./g, '-');
1552
+ }
1553
+ }
1554
+ if (decl.url) {
1555
+ try {
1556
+ const parsed = new URL(decl.url);
1557
+ const segments = parsed.pathname.split('/').filter(Boolean);
1558
+ if (segments.length > 0) {
1559
+ const lastSegment = segments[segments.length - 1];
1560
+ const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
1561
+ return withoutExt.replace(/\./g, '-');
1562
+ }
1563
+ if (parsed.hostname) {
1564
+ return parsed.hostname.replace(/\./g, '-');
915
1565
  }
1566
+ } catch {
1567
+ // Ignore malformed URLs here; validation handles required fields
916
1568
  }
917
1569
  }
1570
+ return 'reference';
918
1571
  }
919
1572
 
920
1573
  /**