@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.
- package/package.json +1 -1
- package/src/graph.js +277 -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());
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
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);
|
|
255
410
|
} else if (decl.kind === 'series') {
|
|
256
411
|
const nodeId = makeNodeId(universeName, 'series', decl.name);
|
|
257
|
-
|
|
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
|
|
263
|
-
if (
|
|
264
|
-
actualParentNodeId =
|
|
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
|
|
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
|
-
|
|
273
|
-
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);
|
|
274
429
|
} else if (decl.kind === 'book') {
|
|
275
430
|
const nodeId = makeNodeId(universeName, 'book', decl.name);
|
|
276
|
-
|
|
277
|
-
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;
|
|
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
|
-
|
|
294
|
-
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);
|
|
295
451
|
} else if (decl.kind === 'chapter') {
|
|
296
452
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
297
|
-
|
|
298
|
-
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;
|
|
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
|
-
|
|
315
|
-
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);
|
|
316
473
|
} else if (decl.kind === 'concept') {
|
|
317
474
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
318
|
-
|
|
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
|
|
324
|
-
if (
|
|
325
|
-
actualParentNodeId =
|
|
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
|
-
|
|
334
|
-
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);
|
|
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
|
|
919
|
+
* Checks for duplicate node names within a scope and emits error if found
|
|
763
920
|
* @param {UniverseGraph} graph
|
|
764
|
-
* @param {string}
|
|
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
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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>>}
|
|
975
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
809
976
|
*/
|
|
810
|
-
function resolveEdges(graph,
|
|
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
|
|
826
|
-
|
|
827
|
-
if
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
const unresolved = [];
|
|
1041
|
+
const resolvedEndpoints = [];
|
|
1042
|
+
const unresolved = [];
|
|
872
1043
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1060
|
+
node.endpoints = resolvedEndpoints;
|
|
1061
|
+
if (unresolved.length > 0) {
|
|
1062
|
+
node.unresolvedEndpoints = unresolved;
|
|
1063
|
+
} else {
|
|
1064
|
+
delete node.unresolvedEndpoints;
|
|
1065
|
+
}
|
|
893
1066
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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,
|