@sprig-and-prose/sprig-universe 0.1.0 → 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 +277 -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.0",
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());
@@ -142,6 +141,13 @@ export function buildGraph(fileASTs) {
142
141
  message: `First describe block for universe "${universeName}" was declared here.`,
143
142
  source: existingDescribe.source,
144
143
  });
144
+ } else if (!existingDescribe && newDescribeBlock) {
145
+ // Add describe block if it doesn't exist yet
146
+ existingUniverseNode.describe = {
147
+ raw: newDescribeBlock.raw,
148
+ normalized: normalizeProseBlock(newDescribeBlock.raw),
149
+ source: newDescribeBlock.source,
150
+ };
145
151
  }
146
152
 
147
153
  // Check for conflicting title blocks
@@ -168,7 +174,7 @@ export function buildGraph(fileASTs) {
168
174
  universeName,
169
175
  universeNodeId,
170
176
  universeDecl.body,
171
- nameMap,
177
+ scopeIndex,
172
178
  fileAST.file,
173
179
  universeNodeId,
174
180
  namedReferencesByUniverse.get(universeName),
@@ -185,7 +191,8 @@ export function buildGraph(fileASTs) {
185
191
  universeDecl,
186
192
  );
187
193
  graph.nodes[universeNodeId] = universeNode;
188
- nameMap.set(universeName, universeNodeId);
194
+ // Add universe name to its own scope
195
+ addNameToScope(graph, scopeIndex, universeNodeId, universeName, universeNodeId);
189
196
 
190
197
  // Process universe body (universeNodeId is both parent and current node for UnknownBlocks)
191
198
  processBody(
@@ -193,7 +200,7 @@ export function buildGraph(fileASTs) {
193
200
  universeName,
194
201
  universeNodeId,
195
202
  universeDecl.body,
196
- nameMap,
203
+ scopeIndex,
197
204
  fileAST.file,
198
205
  universeNodeId,
199
206
  namedReferencesByUniverse.get(universeName),
@@ -204,9 +211,10 @@ export function buildGraph(fileASTs) {
204
211
  }
205
212
  }
206
213
 
207
- // 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
208
216
  graph.repositories = {};
209
- for (const [universeName, reposMap] of repositoriesByUniverse.entries()) {
217
+ for (const reposMap of repositoriesByUniverse.values()) {
210
218
  for (const [repoName, repoDecl] of reposMap.entries()) {
211
219
  // Convert repository kind from package name to directory name
212
220
  // '@sprig-and-prose/sprig-repository-github' -> 'sprig-repository-github'
@@ -224,57 +232,205 @@ export function buildGraph(fileASTs) {
224
232
  }
225
233
 
226
234
  // Resolve edge endpoints
227
- resolveEdges(graph, nameToNodeId);
235
+ resolveEdges(graph, scopeIndex);
228
236
 
229
237
  return graph;
230
238
  }
231
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
+
232
387
  /**
233
388
  * Processes a body of declarations, creating nodes and edges
234
389
  * @param {UniverseGraph} graph
235
390
  * @param {string} universeName
236
391
  * @param {string} parentNodeId - Parent node ID (for tree relationships)
237
392
  * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>} body
238
- * @param {Map<string, string>} nameMap
393
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
239
394
  * @param {string} file
240
395
  * @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
241
396
  * @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
242
397
  * @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
243
398
  * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
244
399
  */
245
- 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) {
246
401
  for (const decl of body) {
247
402
  if (decl.kind === 'anthology') {
248
403
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
249
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'anthology', nameMap, decl.source);
404
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
250
405
  const node = createNode(nodeId, 'anthology', decl.name, parentNodeId, decl);
251
406
  graph.nodes[nodeId] = node;
252
407
  graph.nodes[parentNodeId].children.push(nodeId);
253
- nameMap.set(decl.name, nodeId);
254
- 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);
255
410
  } else if (decl.kind === 'series') {
256
411
  const nodeId = makeNodeId(universeName, 'series', decl.name);
257
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'series', nameMap, decl.source);
412
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
258
413
 
259
414
  // Handle optional anthology parent
260
415
  let actualParentNodeId = parentNodeId;
261
416
  if (decl.parentName) {
262
- const anthologyNodeId = nameMap.get(decl.parentName);
263
- if (anthologyNodeId) {
264
- actualParentNodeId = anthologyNodeId;
417
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
418
+ if (resolved.nodeId && !resolved.ambiguous) {
419
+ actualParentNodeId = resolved.nodeId;
265
420
  }
266
- // If anthology not found, fall back to parentNodeId (tolerant parsing)
421
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
267
422
  }
268
423
 
269
424
  const node = createNode(nodeId, 'series', decl.name, actualParentNodeId, decl);
270
425
  graph.nodes[nodeId] = node;
271
426
  graph.nodes[actualParentNodeId].children.push(nodeId);
272
- nameMap.set(decl.name, nodeId);
273
- 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);
274
429
  } else if (decl.kind === 'book') {
275
430
  const nodeId = makeNodeId(universeName, 'book', decl.name);
276
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'book', nameMap, decl.source);
277
- 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;
278
434
  // Always set container for book nodes (even if undefined when parentName doesn't resolve)
279
435
  const node = createNode(
280
436
  nodeId,
@@ -290,12 +446,13 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
290
446
  } else {
291
447
  graph.nodes[parentNodeId].children.push(nodeId);
292
448
  }
293
- nameMap.set(decl.name, nodeId);
294
- 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);
295
451
  } else if (decl.kind === 'chapter') {
296
452
  const nodeId = makeNodeId(universeName, 'chapter', decl.name);
297
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'chapter', nameMap, decl.source);
298
- 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;
299
456
  // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
300
457
  const node = createNode(
301
458
  nodeId,
@@ -311,18 +468,18 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
311
468
  } else {
312
469
  graph.nodes[parentNodeId].children.push(nodeId);
313
470
  }
314
- nameMap.set(decl.name, nodeId);
315
- 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);
316
473
  } else if (decl.kind === 'concept') {
317
474
  const nodeId = makeNodeId(universeName, 'concept', decl.name);
318
- checkDuplicateName(graph, universeName, decl.name, nodeId, 'concept', nameMap, decl.source);
475
+ checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
319
476
 
320
477
  // Handle optional parent
321
478
  let actualParentNodeId = parentNodeId;
322
479
  if (decl.parentName) {
323
- const parentNodeIdFromName = nameMap.get(decl.parentName);
324
- if (parentNodeIdFromName) {
325
- actualParentNodeId = parentNodeIdFromName;
480
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
481
+ if (resolved.nodeId && !resolved.ambiguous) {
482
+ actualParentNodeId = resolved.nodeId;
326
483
  }
327
484
  // If parent not found, fall back to parentNodeId (tolerant parsing)
328
485
  }
@@ -330,8 +487,8 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
330
487
  const node = createNode(nodeId, 'concept', decl.name, actualParentNodeId, decl);
331
488
  graph.nodes[nodeId] = node;
332
489
  graph.nodes[actualParentNodeId].children.push(nodeId);
333
- nameMap.set(decl.name, nodeId);
334
- 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);
335
492
  } else if (decl.kind === 'relates') {
336
493
  // Check for duplicate relates in reverse order
337
494
  checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
@@ -759,26 +916,36 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
759
916
  }
760
917
 
761
918
  /**
762
- * Checks for duplicate node names and emits warning if found
919
+ * Checks for duplicate node names within a scope and emits error if found
763
920
  * @param {UniverseGraph} graph
764
- * @param {string} universeName
921
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
922
+ * @param {string} scopeNodeId - Container node ID (scope)
765
923
  * @param {string} name
766
924
  * @param {string} nodeId
767
925
  * @param {string} kind - The kind of the current node being checked
768
- * @param {Map<string, string>} nameMap
769
926
  * @param {SourceSpan} source
770
927
  */
771
- function checkDuplicateName(graph, universeName, name, nodeId, kind, nameMap, source) {
772
- if (nameMap.has(name)) {
773
- const firstNodeId = nameMap.get(name);
774
- const firstNode = graph.nodes[firstNodeId];
775
- const firstKind = firstNode?.kind || 'unknown';
776
- graph.diagnostics.push({
777
- severity: 'warning',
778
- message: `Duplicate concept name "${name}" in universe "${universeName}": already defined as ${firstKind} (${firstNodeId}), now also defined as ${kind}`,
779
- source: source, // Current duplicate's source span
780
- });
781
- // 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
+ }
782
949
  }
783
950
  }
784
951
 
@@ -805,9 +972,9 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
805
972
  /**
806
973
  * Resolves edge endpoint references and relates node endpoints
807
974
  * @param {UniverseGraph} graph
808
- * @param {Map<string, Map<string, string>>} nameToNodeId
975
+ * @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
809
976
  */
810
- function resolveEdges(graph, nameToNodeId) {
977
+ function resolveEdges(graph, scopeIndex) {
811
978
  // Build a set of relates node names to avoid duplicate warnings
812
979
  // (edges are created for backward compatibility alongside relates nodes)
813
980
  const relatesNodeNames = new Set();
@@ -822,34 +989,36 @@ function resolveEdges(graph, nameToNodeId) {
822
989
  for (const edgeId in graph.edges) {
823
990
  const edge = graph.edges[edgeId];
824
991
  const universeName = edgeId.split(':')[0];
825
- const nameMap = nameToNodeId.get(universeName);
826
-
827
- if (nameMap) {
828
- // Check if this edge corresponds to a relates node
829
- // If so, skip warnings here (they'll be generated during relates node resolution)
830
- const edgeName = `${edge.a.text} and ${edge.b.text}`;
831
- const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
832
- const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
833
-
834
- // Resolve endpoint A
835
- const targetA = nameMap.get(edge.a.text);
836
- if (targetA) {
837
- edge.a.target = targetA;
838
- } else if (!hasRelatesNode) {
839
- // 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) {
840
1007
  graph.diagnostics.push({
841
1008
  severity: 'warning',
842
1009
  message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
843
1010
  source: edge.source,
844
1011
  });
845
1012
  }
1013
+ }
846
1014
 
847
- // Resolve endpoint B
848
- const targetB = nameMap.get(edge.b.text);
849
- if (targetB) {
850
- edge.b.target = targetB;
851
- } else if (!hasRelatesNode) {
852
- // 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) {
853
1022
  graph.diagnostics.push({
854
1023
  severity: 'warning',
855
1024
  message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
@@ -864,18 +1033,21 @@ function resolveEdges(graph, nameToNodeId) {
864
1033
  const node = graph.nodes[nodeId];
865
1034
  if (node.kind === 'relates' && node.unresolvedEndpoints) {
866
1035
  const universeName = nodeId.split(':')[0];
867
- 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;
868
1040
 
869
- if (nameMap) {
870
- const resolvedEndpoints = [];
871
- const unresolved = [];
1041
+ const resolvedEndpoints = [];
1042
+ const unresolved = [];
872
1043
 
873
- for (const endpointName of node.unresolvedEndpoints) {
874
- const targetId = nameMap.get(endpointName);
875
- if (targetId) {
876
- resolvedEndpoints.push(targetId);
877
- } else {
878
- 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) {
879
1051
  graph.diagnostics.push({
880
1052
  severity: 'warning',
881
1053
  message: `Unresolved relates endpoint "${endpointName}" in universe "${universeName}"`,
@@ -883,28 +1055,28 @@ function resolveEdges(graph, nameToNodeId) {
883
1055
  });
884
1056
  }
885
1057
  }
1058
+ }
886
1059
 
887
- node.endpoints = resolvedEndpoints;
888
- if (unresolved.length > 0) {
889
- node.unresolvedEndpoints = unresolved;
890
- } else {
891
- delete node.unresolvedEndpoints;
892
- }
1060
+ node.endpoints = resolvedEndpoints;
1061
+ if (unresolved.length > 0) {
1062
+ node.unresolvedEndpoints = unresolved;
1063
+ } else {
1064
+ delete node.unresolvedEndpoints;
1065
+ }
893
1066
 
894
- // Resolve from blocks: convert from endpoint names to node IDs
895
- if (node.from) {
896
- const resolvedFrom = {};
897
- for (const endpointName in node.from) {
898
- const targetId = nameMap.get(endpointName);
899
- if (targetId) {
900
- resolvedFrom[targetId] = node.from[endpointName];
901
- } else {
902
- // Keep unresolved from blocks keyed by name
903
- resolvedFrom[endpointName] = node.from[endpointName];
904
- }
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];
905
1077
  }
906
- node.from = resolvedFrom;
907
1078
  }
1079
+ node.from = resolvedFrom;
908
1080
  }
909
1081
  }
910
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,