@sprig-and-prose/sprig-universe 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/graph.js +270 -105
  3. package/src/parser.js +67 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/sprig-universe",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
package/src/graph.js CHANGED
@@ -38,6 +38,10 @@ import { normalizeProseBlock } from './util/text.js';
38
38
 
39
39
  /**
40
40
  * Builds a UniverseGraph from parsed AST files
41
+ *
42
+ * Note: There is only ever one universe per manifest. All files must declare
43
+ * the same universe name, and they are merged into a single universe.
44
+ *
41
45
  * @param {FileAST[]} fileASTs - Array of parsed file ASTs
42
46
  * @returns {UniverseGraph}
43
47
  */
@@ -52,8 +56,9 @@ export function buildGraph(fileASTs) {
52
56
  documentsByName: {},
53
57
  };
54
58
 
55
- // Track node names within each universe for duplicate detection
56
- const nameToNodeId = new Map(); // universeName -> Map<name, firstNodeId>
59
+ // Track node names within each scope (container) for scoped resolution
60
+ // Map<containerNodeId, Map<name, nodeId[]>> - allows multiple nodes with same name in different scopes
61
+ const scopeIndex = new Map(); // containerNodeId -> Map<name, nodeId[]>
57
62
 
58
63
  // Track named references and documents per universe for duplicate detection
59
64
  const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
@@ -102,12 +107,6 @@ export function buildGraph(fileASTs) {
102
107
  };
103
108
  }
104
109
 
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
110
  // Initialize tracking maps for named references and documents (only once per universe)
112
111
  if (!namedReferencesByUniverse.has(universeName)) {
113
112
  namedReferencesByUniverse.set(universeName, new Map());
@@ -175,7 +174,7 @@ export function buildGraph(fileASTs) {
175
174
  universeName,
176
175
  universeNodeId,
177
176
  universeDecl.body,
178
- nameMap,
177
+ scopeIndex,
179
178
  fileAST.file,
180
179
  universeNodeId,
181
180
  namedReferencesByUniverse.get(universeName),
@@ -192,7 +191,8 @@ export function buildGraph(fileASTs) {
192
191
  universeDecl,
193
192
  );
194
193
  graph.nodes[universeNodeId] = universeNode;
195
- nameMap.set(universeName, universeNodeId);
194
+ // Add universe name to its own scope
195
+ addNameToScope(graph, scopeIndex, universeNodeId, universeName, universeNodeId);
196
196
 
197
197
  // Process universe body (universeNodeId is both parent and current node for UnknownBlocks)
198
198
  processBody(
@@ -200,7 +200,7 @@ export function buildGraph(fileASTs) {
200
200
  universeName,
201
201
  universeNodeId,
202
202
  universeDecl.body,
203
- nameMap,
203
+ scopeIndex,
204
204
  fileAST.file,
205
205
  universeNodeId,
206
206
  namedReferencesByUniverse.get(universeName),
@@ -211,9 +211,10 @@ export function buildGraph(fileASTs) {
211
211
  }
212
212
  }
213
213
 
214
- // Extract repositories from all universes and convert to config format
214
+ // Extract repositories and convert to config format
215
+ // Note: There's only one universe per manifest, so we iterate over the single entry
215
216
  graph.repositories = {};
216
- for (const [universeName, reposMap] of repositoriesByUniverse.entries()) {
217
+ for (const reposMap of repositoriesByUniverse.values()) {
217
218
  for (const [repoName, repoDecl] of reposMap.entries()) {
218
219
  // Convert repository kind from package name to directory name
219
220
  // '@sprig-and-prose/sprig-repository-github' -> 'sprig-repository-github'
@@ -231,57 +232,205 @@ export function buildGraph(fileASTs) {
231
232
  }
232
233
 
233
234
  // Resolve edge endpoints
234
- resolveEdges(graph, nameToNodeId);
235
+ resolveEdges(graph, scopeIndex);
235
236
 
236
237
  return graph;
237
238
  }
238
239
 
240
+ /**
241
+ * Adds a name to a scope index and all ancestor scopes
242
+ * This allows names to be resolved from parent containers
243
+ * @param {UniverseGraph} graph - The graph
244
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
245
+ * @param {string} scopeNodeId - Container node ID (scope)
246
+ * @param {string} name - Name to add
247
+ * @param {string} nodeId - Node ID to associate with the name
248
+ */
249
+ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
250
+ // Add to current scope and all ancestor scopes
251
+ let currentScope = scopeNodeId;
252
+ const visitedScopes = new Set();
253
+
254
+ while (currentScope && !visitedScopes.has(currentScope)) {
255
+ visitedScopes.add(currentScope);
256
+
257
+ if (!scopeIndex.has(currentScope)) {
258
+ scopeIndex.set(currentScope, new Map());
259
+ }
260
+ const scopeMap = scopeIndex.get(currentScope);
261
+ if (!scopeMap.has(name)) {
262
+ scopeMap.set(name, []);
263
+ }
264
+ scopeMap.get(name).push(nodeId);
265
+
266
+ // Move to parent scope
267
+ const node = graph.nodes[currentScope];
268
+ if (node && node.parent) {
269
+ currentScope = node.parent;
270
+ } else {
271
+ break;
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
278
+ * Returns the first matching node ID found, or null if not found
279
+ * Also checks for ambiguity (multiple matches at the same scope level)
280
+ * @param {UniverseGraph} graph - The graph
281
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
282
+ * @param {string} name - Name to resolve
283
+ * @param {string} startScopeNodeId - Starting scope (container node ID)
284
+ * @param {SourceSpan} [source] - Optional source span for error reporting
285
+ * @returns {{ nodeId: string | null, ambiguous: boolean, ambiguousNodes: string[] }}
286
+ */
287
+ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
288
+ // Handle qualified names (dot notation)
289
+ if (name.includes('.')) {
290
+ const parts = name.split('.');
291
+ // Start from universe and resolve each part
292
+ const universeName = startScopeNodeId.split(':')[0];
293
+ const universeNodeId = `${universeName}:universe:${universeName}`;
294
+ let currentScope = universeNodeId;
295
+
296
+ for (const part of parts) {
297
+ const scopeMap = scopeIndex.get(currentScope);
298
+ if (!scopeMap || !scopeMap.has(part)) {
299
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
300
+ }
301
+ const candidates = scopeMap.get(part);
302
+ if (candidates.length === 0) {
303
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
304
+ }
305
+ if (candidates.length > 1) {
306
+ // Ambiguity at this level
307
+ if (source) {
308
+ graph.diagnostics.push({
309
+ severity: 'error',
310
+ message: `Ambiguous reference "${part}" in scope "${currentScope}": found ${candidates.length} matches`,
311
+ source,
312
+ });
313
+ }
314
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
315
+ }
316
+ currentScope = candidates[0];
317
+ }
318
+ return { nodeId: currentScope, ambiguous: false, ambiguousNodes: [] };
319
+ }
320
+
321
+ // Unqualified name - walk scope chain
322
+ let currentScope = startScopeNodeId;
323
+ const visitedScopes = new Set();
324
+
325
+ while (currentScope) {
326
+ if (visitedScopes.has(currentScope)) {
327
+ break; // Prevent infinite loops
328
+ }
329
+ visitedScopes.add(currentScope);
330
+
331
+ const scopeMap = scopeIndex.get(currentScope);
332
+ if (scopeMap && scopeMap.has(name)) {
333
+ const candidates = scopeMap.get(name);
334
+ if (candidates.length === 0) {
335
+ // Continue to parent scope
336
+ } else if (candidates.length === 1) {
337
+ return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
338
+ } else {
339
+ // Ambiguity at this scope level
340
+ if (source) {
341
+ const scopeNode = graph.nodes[currentScope];
342
+ const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
343
+ graph.diagnostics.push({
344
+ severity: 'error',
345
+ message: `Ambiguous reference "${name}" in scope "${scopeName}": found ${candidates.length} matches`,
346
+ source,
347
+ });
348
+ }
349
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
350
+ }
351
+ }
352
+
353
+ // Move to parent scope
354
+ const node = graph.nodes[currentScope];
355
+ if (node && node.parent) {
356
+ currentScope = node.parent;
357
+ } else {
358
+ // Reached universe root or no parent - check universe scope one more time if we haven't already
359
+ const universeName = startScopeNodeId.split(':')[0];
360
+ const universeNodeId = `${universeName}:universe:${universeName}`;
361
+ if (currentScope !== universeNodeId) {
362
+ // Check universe scope before giving up
363
+ const universeScopeMap = scopeIndex.get(universeNodeId);
364
+ if (universeScopeMap && universeScopeMap.has(name)) {
365
+ const candidates = universeScopeMap.get(name);
366
+ if (candidates.length === 1) {
367
+ return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
368
+ } else if (candidates.length > 1) {
369
+ if (source) {
370
+ graph.diagnostics.push({
371
+ severity: 'error',
372
+ message: `Ambiguous reference "${name}" in universe scope: found ${candidates.length} matches`,
373
+ source,
374
+ });
375
+ }
376
+ return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
377
+ }
378
+ }
379
+ }
380
+ break;
381
+ }
382
+ }
383
+
384
+ return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
385
+ }
386
+
239
387
  /**
240
388
  * Processes a body of declarations, creating nodes and edges
241
389
  * @param {UniverseGraph} graph
242
390
  * @param {string} universeName
243
391
  * @param {string} parentNodeId - Parent node ID (for tree relationships)
244
392
  * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>} body
245
- * @param {Map<string, string>} nameMap
393
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
246
394
  * @param {string} file
247
395
  * @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
248
396
  * @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
249
397
  * @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
250
398
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
251
399
  */
252
- function processBody(graph, universeName, parentNodeId, body, nameMap, file, currentNodeId, namedRefsMap, namedDocsMap, reposMap) {
400
+ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file, currentNodeId, namedRefsMap, namedDocsMap, reposMap) {
253
401
  for (const decl of body) {
254
402
  if (decl.kind === 'anthology') {
255
403
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
256
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'anthology', nameMap, decl.source);
404
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
257
405
  const node = createNode(nodeId, 'anthology', decl.name, parentNodeId, decl);
258
406
  graph.nodes[nodeId] = node;
259
407
  graph.nodes[parentNodeId].children.push(nodeId);
260
- nameMap.set(decl.name, nodeId);
261
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
408
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
409
+ processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
262
410
  } else if (decl.kind === 'series') {
263
411
  const nodeId = makeNodeId(universeName, 'series', decl.name);
264
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'series', nameMap, decl.source);
412
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
265
413
 
266
414
  // Handle optional anthology parent
267
415
  let actualParentNodeId = parentNodeId;
268
416
  if (decl.parentName) {
269
- const anthologyNodeId = nameMap.get(decl.parentName);
270
- if (anthologyNodeId) {
271
- actualParentNodeId = anthologyNodeId;
417
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
418
+ if (resolved.nodeId && !resolved.ambiguous) {
419
+ actualParentNodeId = resolved.nodeId;
272
420
  }
273
- // If anthology not found, fall back to parentNodeId (tolerant parsing)
421
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
274
422
  }
275
423
 
276
424
  const node = createNode(nodeId, 'series', decl.name, actualParentNodeId, decl);
277
425
  graph.nodes[nodeId] = node;
278
426
  graph.nodes[actualParentNodeId].children.push(nodeId);
279
- nameMap.set(decl.name, nodeId);
280
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
427
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
428
+ processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
281
429
  } else if (decl.kind === 'book') {
282
430
  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);
431
+ 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;
285
434
  // Always set container for book nodes (even if undefined when parentName doesn't resolve)
286
435
  const node = createNode(
287
436
  nodeId,
@@ -297,12 +446,13 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
297
446
  } else {
298
447
  graph.nodes[parentNodeId].children.push(nodeId);
299
448
  }
300
- nameMap.set(decl.name, nodeId);
301
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
449
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
450
+ processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
302
451
  } else if (decl.kind === 'chapter') {
303
452
  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);
453
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
454
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
455
+ const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
306
456
  // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
307
457
  const node = createNode(
308
458
  nodeId,
@@ -318,18 +468,18 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
318
468
  } else {
319
469
  graph.nodes[parentNodeId].children.push(nodeId);
320
470
  }
321
- nameMap.set(decl.name, nodeId);
322
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
471
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
472
+ processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
323
473
  } else if (decl.kind === 'concept') {
324
474
  const nodeId = makeNodeId(universeName, 'concept', decl.name);
325
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'concept', nameMap, decl.source);
475
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
326
476
 
327
477
  // Handle optional parent
328
478
  let actualParentNodeId = parentNodeId;
329
479
  if (decl.parentName) {
330
- const parentNodeIdFromName = nameMap.get(decl.parentName);
331
- if (parentNodeIdFromName) {
332
- actualParentNodeId = parentNodeIdFromName;
480
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
481
+ if (resolved.nodeId && !resolved.ambiguous) {
482
+ actualParentNodeId = resolved.nodeId;
333
483
  }
334
484
  // If parent not found, fall back to parentNodeId (tolerant parsing)
335
485
  }
@@ -337,8 +487,8 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
337
487
  const node = createNode(nodeId, 'concept', decl.name, actualParentNodeId, decl);
338
488
  graph.nodes[nodeId] = node;
339
489
  graph.nodes[actualParentNodeId].children.push(nodeId);
340
- nameMap.set(decl.name, nodeId);
341
- processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
490
+ addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
491
+ processBody(graph, universeName, nodeId, decl.body, scopeIndex, file, nodeId, namedRefsMap, namedDocsMap);
342
492
  } else if (decl.kind === 'relates') {
343
493
  // Check for duplicate relates in reverse order
344
494
  checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
@@ -766,26 +916,36 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
766
916
  }
767
917
 
768
918
  /**
769
- * Checks for duplicate node names and emits warning if found
919
+ * Checks for duplicate node names within a scope and emits error if found
770
920
  * @param {UniverseGraph} graph
771
- * @param {string} universeName
921
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
922
+ * @param {string} scopeNodeId - Container node ID (scope)
772
923
  * @param {string} name
773
924
  * @param {string} nodeId
774
925
  * @param {string} kind - The kind of the current node being checked
775
- * @param {Map<string, string>} nameMap
776
926
  * @param {SourceSpan} source
777
927
  */
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)
928
+ function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source) {
929
+ const scopeMap = scopeIndex.get(scopeNodeId);
930
+ if (scopeMap && scopeMap.has(name)) {
931
+ const existingNodeIds = scopeMap.get(name);
932
+ if (existingNodeIds.length > 0) {
933
+ const firstNodeId = existingNodeIds[0];
934
+ const firstNode = graph.nodes[firstNodeId];
935
+ const firstKind = firstNode?.kind || 'unknown';
936
+ const scopeNode = graph.nodes[scopeNodeId];
937
+ const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
938
+ graph.diagnostics.push({
939
+ severity: 'error',
940
+ message: `Duplicate name "${name}" in scope "${scopeName}": already defined as ${firstKind}, now also defined as ${kind}`,
941
+ source: source,
942
+ });
943
+ graph.diagnostics.push({
944
+ severity: 'error',
945
+ message: `First declaration of "${name}" was here.`,
946
+ source: firstNode.source,
947
+ });
948
+ }
789
949
  }
790
950
  }
791
951
 
@@ -812,9 +972,9 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
812
972
  /**
813
973
  * Resolves edge endpoint references and relates node endpoints
814
974
  * @param {UniverseGraph} graph
815
- * @param {Map<string, Map<string, string>>} nameToNodeId
975
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
816
976
  */
817
- function resolveEdges(graph, nameToNodeId) {
977
+ function resolveEdges(graph, scopeIndex) {
818
978
  // Build a set of relates node names to avoid duplicate warnings
819
979
  // (edges are created for backward compatibility alongside relates nodes)
820
980
  const relatesNodeNames = new Set();
@@ -829,34 +989,36 @@ function resolveEdges(graph, nameToNodeId) {
829
989
  for (const edgeId in graph.edges) {
830
990
  const edge = graph.edges[edgeId];
831
991
  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)
992
+ const universeNodeId = `${universeName}:universe:${universeName}`;
993
+
994
+ // Check if this edge corresponds to a relates node
995
+ // If so, skip warnings here (they'll be generated during relates node resolution)
996
+ const edgeName = `${edge.a.text} and ${edge.b.text}`;
997
+ const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
998
+ const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
999
+
1000
+ // Resolve endpoint A - start from universe scope
1001
+ const resolvedA = resolveNameInScope(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
1002
+ if (resolvedA.nodeId && !resolvedA.ambiguous) {
1003
+ edge.a.target = resolvedA.nodeId;
1004
+ } else if (!hasRelatesNode) {
1005
+ // Only warn if there's no corresponding relates node (legacy edge-only format)
1006
+ if (!resolvedA.ambiguous) {
847
1007
  graph.diagnostics.push({
848
1008
  severity: 'warning',
849
1009
  message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
850
1010
  source: edge.source,
851
1011
  });
852
1012
  }
1013
+ }
853
1014
 
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)
1015
+ // Resolve endpoint B - start from universe scope
1016
+ const resolvedB = resolveNameInScope(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
1017
+ if (resolvedB.nodeId && !resolvedB.ambiguous) {
1018
+ edge.b.target = resolvedB.nodeId;
1019
+ } else if (!hasRelatesNode) {
1020
+ // Only warn if there's no corresponding relates node (legacy edge-only format)
1021
+ if (!resolvedB.ambiguous) {
860
1022
  graph.diagnostics.push({
861
1023
  severity: 'warning',
862
1024
  message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
@@ -871,18 +1033,21 @@ function resolveEdges(graph, nameToNodeId) {
871
1033
  const node = graph.nodes[nodeId];
872
1034
  if (node.kind === 'relates' && node.unresolvedEndpoints) {
873
1035
  const universeName = nodeId.split(':')[0];
874
- const nameMap = nameToNodeId.get(universeName);
1036
+ const universeNodeId = `${universeName}:universe:${universeName}`;
1037
+
1038
+ // Determine the scope where this relates block was declared (parent of relates node)
1039
+ const relatesScope = node.parent || universeNodeId;
875
1040
 
876
- if (nameMap) {
877
- const resolvedEndpoints = [];
878
- const unresolved = [];
1041
+ const resolvedEndpoints = [];
1042
+ const unresolved = [];
879
1043
 
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);
1044
+ for (const endpointName of node.unresolvedEndpoints) {
1045
+ const resolved = resolveNameInScope(graph, scopeIndex, endpointName, relatesScope, node.source);
1046
+ if (resolved.nodeId && !resolved.ambiguous) {
1047
+ resolvedEndpoints.push(resolved.nodeId);
1048
+ } else {
1049
+ unresolved.push(endpointName);
1050
+ if (!resolved.ambiguous) {
886
1051
  graph.diagnostics.push({
887
1052
  severity: 'warning',
888
1053
  message: `Unresolved relates endpoint "${endpointName}" in universe "${universeName}"`,
@@ -890,28 +1055,28 @@ function resolveEdges(graph, nameToNodeId) {
890
1055
  });
891
1056
  }
892
1057
  }
1058
+ }
893
1059
 
894
- node.endpoints = resolvedEndpoints;
895
- if (unresolved.length > 0) {
896
- node.unresolvedEndpoints = unresolved;
897
- } else {
898
- delete node.unresolvedEndpoints;
899
- }
1060
+ node.endpoints = resolvedEndpoints;
1061
+ if (unresolved.length > 0) {
1062
+ node.unresolvedEndpoints = unresolved;
1063
+ } else {
1064
+ delete node.unresolvedEndpoints;
1065
+ }
900
1066
 
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
- }
1067
+ // Resolve from blocks: convert from endpoint names to node IDs
1068
+ if (node.from) {
1069
+ const resolvedFrom = {};
1070
+ for (const endpointName in node.from) {
1071
+ const resolved = resolveNameInScope(graph, scopeIndex, endpointName, relatesScope, node.source);
1072
+ if (resolved.nodeId && !resolved.ambiguous) {
1073
+ resolvedFrom[resolved.nodeId] = node.from[endpointName];
1074
+ } else {
1075
+ // Keep unresolved from blocks keyed by name
1076
+ resolvedFrom[endpointName] = node.from[endpointName];
912
1077
  }
913
- node.from = resolvedFrom;
914
1078
  }
1079
+ node.from = resolvedFrom;
915
1080
  }
916
1081
  }
917
1082
  }
package/src/parser.js CHANGED
@@ -106,6 +106,8 @@ class Parser {
106
106
  parseFile() {
107
107
  const universes = [];
108
108
  const scenes = [];
109
+ const topLevelDeclsByUniverse = new Map();
110
+ const topLevelDeclsUnscoped = [];
109
111
  const startToken = this.peek();
110
112
 
111
113
  while (!this.match('EOF')) {
@@ -113,6 +115,38 @@ class Parser {
113
115
  universes.push(this.parseUniverse());
114
116
  } else if (this.match('KEYWORD') && this.peek()?.value === 'scene') {
115
117
  scenes.push(this.parseScene());
118
+ } else if (this.match('KEYWORD')) {
119
+ const keyword = this.peek()?.value;
120
+ let decl = null;
121
+ if (keyword === 'series') {
122
+ decl = this.parseSeries();
123
+ } else if (keyword === 'book') {
124
+ decl = this.parseBook();
125
+ } else if (keyword === 'chapter') {
126
+ decl = this.parseChapter();
127
+ } else if (keyword === 'concept') {
128
+ decl = this.parseConcept();
129
+ } else if (keyword === 'anthology') {
130
+ decl = this.parseAnthology();
131
+ }
132
+
133
+ if (decl) {
134
+ if (decl.parentName) {
135
+ const universeName = decl.parentName;
136
+ if (!topLevelDeclsByUniverse.has(universeName)) {
137
+ topLevelDeclsByUniverse.set(universeName, []);
138
+ }
139
+ topLevelDeclsByUniverse.get(universeName).push(decl);
140
+ } else {
141
+ topLevelDeclsUnscoped.push(decl);
142
+ }
143
+ } else {
144
+ // Skip unknown top-level content (tolerant parsing)
145
+ const token = this.advance();
146
+ if (token && token.type !== 'EOF') {
147
+ // Could emit warning here, but for now just skip
148
+ }
149
+ }
116
150
  } else {
117
151
  // Skip unknown top-level content (tolerant parsing)
118
152
  const token = this.advance();
@@ -122,6 +156,39 @@ class Parser {
122
156
  }
123
157
  }
124
158
 
159
+ // Merge top-level declarations into their target universes
160
+ if (topLevelDeclsByUniverse.size > 0 || topLevelDeclsUnscoped.length > 0) {
161
+ const universesByName = new Map();
162
+ for (const universe of universes) {
163
+ universesByName.set(universe.name, universe);
164
+ }
165
+
166
+ for (const [universeName, decls] of topLevelDeclsByUniverse.entries()) {
167
+ const universe = universesByName.get(universeName);
168
+ if (universe) {
169
+ universe.body.push(...decls);
170
+ } else {
171
+ const firstDecl = decls[0];
172
+ const lastDecl = decls[decls.length - 1];
173
+ universes.push({
174
+ kind: 'universe',
175
+ name: universeName,
176
+ body: decls,
177
+ source: {
178
+ file: this.file,
179
+ start: firstDecl.source.start,
180
+ end: lastDecl.source.end,
181
+ },
182
+ });
183
+ universesByName.set(universeName, universes[universes.length - 1]);
184
+ }
185
+ }
186
+
187
+ if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
188
+ universes[0].body.push(...topLevelDeclsUnscoped);
189
+ }
190
+ }
191
+
125
192
  return {
126
193
  file: this.file,
127
194
  universes,