@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.
- package/package.json +1 -1
- package/src/graph.js +270 -105
- package/src/parser.js +67 -0
package/package.json
CHANGED
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
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
processBody(graph, universeName, nodeId, decl.body,
|
|
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
|
-
|
|
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
|
|
270
|
-
if (
|
|
271
|
-
actualParentNodeId =
|
|
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
|
|
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
|
-
|
|
280
|
-
processBody(graph, universeName, nodeId, decl.body,
|
|
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
|
-
|
|
284
|
-
const
|
|
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
|
-
|
|
301
|
-
processBody(graph, universeName, nodeId, decl.body,
|
|
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
|
-
|
|
305
|
-
const
|
|
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
|
-
|
|
322
|
-
processBody(graph, universeName, nodeId, decl.body,
|
|
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
|
-
|
|
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
|
|
331
|
-
if (
|
|
332
|
-
actualParentNodeId =
|
|
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
|
-
|
|
341
|
-
processBody(graph, universeName, nodeId, decl.body,
|
|
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
|
|
919
|
+
* Checks for duplicate node names within a scope and emits error if found
|
|
770
920
|
* @param {UniverseGraph} graph
|
|
771
|
-
* @param {string}
|
|
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
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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>>}
|
|
975
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
816
976
|
*/
|
|
817
|
-
function resolveEdges(graph,
|
|
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
|
|
833
|
-
|
|
834
|
-
if
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
const unresolved = [];
|
|
1041
|
+
const resolvedEndpoints = [];
|
|
1042
|
+
const unresolved = [];
|
|
879
1043
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1060
|
+
node.endpoints = resolvedEndpoints;
|
|
1061
|
+
if (unresolved.length > 0) {
|
|
1062
|
+
node.unresolvedEndpoints = unresolved;
|
|
1063
|
+
} else {
|
|
1064
|
+
delete node.unresolvedEndpoints;
|
|
1065
|
+
}
|
|
900
1066
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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,
|