@sprig-and-prose/sprig-universe 0.1.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 (45) hide show
  1. package/PHILOSOPHY.md +201 -0
  2. package/README.md +168 -0
  3. package/REFERENCE.md +355 -0
  4. package/biome.json +24 -0
  5. package/package.json +30 -0
  6. package/repositories/sprig-repository-github/index.js +29 -0
  7. package/src/ast.js +257 -0
  8. package/src/cli.js +1510 -0
  9. package/src/graph.js +950 -0
  10. package/src/index.js +46 -0
  11. package/src/ir.js +121 -0
  12. package/src/parser.js +1656 -0
  13. package/src/scanner.js +255 -0
  14. package/src/scene-manifest.js +856 -0
  15. package/src/util/span.js +46 -0
  16. package/src/util/text.js +126 -0
  17. package/src/validator.js +862 -0
  18. package/src/validators/mysql/connection.js +154 -0
  19. package/src/validators/mysql/schema.js +209 -0
  20. package/src/validators/mysql/type-compat.js +219 -0
  21. package/src/validators/mysql/validator.js +332 -0
  22. package/test/fixtures/amaranthine-mini.prose +53 -0
  23. package/test/fixtures/conflicting-universes-a.prose +8 -0
  24. package/test/fixtures/conflicting-universes-b.prose +8 -0
  25. package/test/fixtures/duplicate-names.prose +20 -0
  26. package/test/fixtures/first-line-aware.prose +32 -0
  27. package/test/fixtures/indented-describe.prose +18 -0
  28. package/test/fixtures/multi-file-universe-a.prose +15 -0
  29. package/test/fixtures/multi-file-universe-b.prose +15 -0
  30. package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
  31. package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
  32. package/test/fixtures/multi-file-universe-with-title.prose +10 -0
  33. package/test/fixtures/named-document.prose +17 -0
  34. package/test/fixtures/named-duplicate.prose +22 -0
  35. package/test/fixtures/named-reference.prose +17 -0
  36. package/test/fixtures/relates-errors.prose +38 -0
  37. package/test/fixtures/relates-tier1.prose +14 -0
  38. package/test/fixtures/relates-tier2.prose +16 -0
  39. package/test/fixtures/relates-tier3.prose +21 -0
  40. package/test/fixtures/sprig-meta-mini.prose +62 -0
  41. package/test/fixtures/unresolved-relates.prose +15 -0
  42. package/test/fixtures/using-in-references.prose +35 -0
  43. package/test/fixtures/using-unknown.prose +8 -0
  44. package/test/universe-basic.test.js +804 -0
  45. package/tsconfig.json +15 -0
package/src/graph.js ADDED
@@ -0,0 +1,950 @@
1
+ /**
2
+ * @fileoverview AST to UniverseGraph transformation
3
+ */
4
+
5
+ import { normalizeProseBlock } from './util/text.js';
6
+
7
+ /**
8
+ * @typedef {import('./ir.js').UniverseGraph} UniverseGraph
9
+ * @typedef {import('./ir.js').NodeModel} NodeModel
10
+ * @typedef {import('./ir.js').EdgeModel} EdgeModel
11
+ * @typedef {import('./ir.js').UniverseModel} UniverseModel
12
+ * @typedef {import('./ir.js').NodeRef} NodeRef
13
+ * @typedef {import('./ir.js').TextBlock} TextBlock
14
+ * @typedef {import('./ir.js').UnknownBlock} UnknownBlock
15
+ * @typedef {import('./ir.js').Diagnostic} Diagnostic
16
+ * @typedef {import('./ir.js').FromView} FromView
17
+ * @typedef {import('./ast.js').FileAST} FileAST
18
+ * @typedef {import('./ast.js').UniverseDecl} UniverseDecl
19
+ * @typedef {import('./ast.js').AnthologyDecl} AnthologyDecl
20
+ * @typedef {import('./ast.js').SeriesDecl} SeriesDecl
21
+ * @typedef {import('./ast.js').BookDecl} BookDecl
22
+ * @typedef {import('./ast.js').ChapterDecl} ChapterDecl
23
+ * @typedef {import('./ast.js').ConceptDecl} ConceptDecl
24
+ * @typedef {import('./ast.js').RelatesDecl} RelatesDecl
25
+ * @typedef {import('./ast.js').DescribeBlock} DescribeBlock
26
+ * @typedef {import('./ast.js').TitleBlock} TitleBlock
27
+ * @typedef {import('./ast.js').FromBlock} FromBlock
28
+ * @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
29
+ * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
30
+ * @typedef {import('./ast.js').ReferenceBlock} ReferenceBlock
31
+ * @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
32
+ * @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
33
+ * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
34
+ * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
35
+ * @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
36
+ * @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
37
+ */
38
+
39
+ /**
40
+ * Builds a UniverseGraph from parsed AST files
41
+ * @param {FileAST[]} fileASTs - Array of parsed file ASTs
42
+ * @returns {UniverseGraph}
43
+ */
44
+ export function buildGraph(fileASTs) {
45
+ const graph = {
46
+ version: 1,
47
+ universes: {},
48
+ nodes: {},
49
+ edges: {},
50
+ diagnostics: [],
51
+ referencesByName: {},
52
+ documentsByName: {},
53
+ };
54
+
55
+ // Track node names within each universe for duplicate detection
56
+ const nameToNodeId = new Map(); // universeName -> Map<name, firstNodeId>
57
+
58
+ // Track named references and documents per universe for duplicate detection
59
+ const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
60
+ const namedDocumentsByUniverse = new Map(); // universeName -> Map<name, { source }>
61
+
62
+ // Track repositories per universe
63
+ const repositoriesByUniverse = new Map(); // universeName -> Map<name, RepositoryDecl>
64
+
65
+ // First pass: collect all universe names with their file locations for validation
66
+ const universeNameToFiles = new Map(); // universeName -> Set<file>
67
+ for (const fileAST of fileASTs) {
68
+ for (const universeDecl of fileAST.universes) {
69
+ const universeName = universeDecl.name;
70
+ if (!universeNameToFiles.has(universeName)) {
71
+ universeNameToFiles.set(universeName, new Set());
72
+ }
73
+ universeNameToFiles.get(universeName).add(fileAST.file);
74
+ }
75
+ }
76
+
77
+ // Validate: all files must declare the same universe name
78
+ if (universeNameToFiles.size > 1) {
79
+ const universeGroups = Array.from(universeNameToFiles.entries()).map(([name, files]) => {
80
+ const fileList = Array.from(files).sort().join(', ');
81
+ return `'${name}' (files: ${fileList})`;
82
+ });
83
+ graph.diagnostics.push({
84
+ severity: 'error',
85
+ message: `Multiple distinct universes found across files: ${universeGroups.join('; ')}. All files must declare the same universe name.`,
86
+ source: undefined, // No specific source for this aggregate error
87
+ });
88
+ // Continue processing but validation will fail later
89
+ }
90
+
91
+ // Process each file
92
+ for (const fileAST of fileASTs) {
93
+ for (const universeDecl of fileAST.universes) {
94
+ const universeName = universeDecl.name;
95
+ const universeNodeId = makeNodeId(universeName, 'universe', universeName);
96
+
97
+ // Initialize universe model (only once per universe)
98
+ if (!graph.universes[universeName]) {
99
+ graph.universes[universeName] = {
100
+ name: universeName,
101
+ root: universeNodeId,
102
+ };
103
+ }
104
+
105
+ // Initialize name map for this universe (only once per universe)
106
+ if (!nameToNodeId.has(universeName)) {
107
+ nameToNodeId.set(universeName, new Map());
108
+ }
109
+ const nameMap = nameToNodeId.get(universeName);
110
+
111
+ // Initialize tracking maps for named references and documents (only once per universe)
112
+ if (!namedReferencesByUniverse.has(universeName)) {
113
+ namedReferencesByUniverse.set(universeName, new Map());
114
+ graph.referencesByName[universeName] = {};
115
+ }
116
+ if (!namedDocumentsByUniverse.has(universeName)) {
117
+ namedDocumentsByUniverse.set(universeName, new Map());
118
+ graph.documentsByName[universeName] = {};
119
+ }
120
+ if (!repositoriesByUniverse.has(universeName)) {
121
+ repositoriesByUniverse.set(universeName, new Map());
122
+ }
123
+
124
+ // Check if universe node already exists (from a previous file)
125
+ const existingUniverseNode = graph.nodes[universeNodeId];
126
+
127
+ if (existingUniverseNode) {
128
+ // Universe already exists - merge bodies instead of overwriting
129
+
130
+ // Check for conflicting describe blocks
131
+ const existingDescribe = existingUniverseNode.describe;
132
+ const newDescribeBlock = universeDecl.body?.find((b) => b.kind === 'describe');
133
+ if (existingDescribe && newDescribeBlock) {
134
+ graph.diagnostics.push({
135
+ severity: 'error',
136
+ message: `Universe "${universeName}" has multiple describe blocks. Move all describe content into one file.`,
137
+ source: newDescribeBlock.source,
138
+ });
139
+ // Also add a diagnostic pointing to the first occurrence
140
+ graph.diagnostics.push({
141
+ severity: 'error',
142
+ message: `First describe block for universe "${universeName}" was declared here.`,
143
+ source: existingDescribe.source,
144
+ });
145
+ }
146
+
147
+ // Check for conflicting title blocks
148
+ const existingTitle = existingUniverseNode.title;
149
+ const newTitleBlock = universeDecl.body?.find((b) => b.kind === 'title');
150
+ if (existingTitle && newTitleBlock) {
151
+ graph.diagnostics.push({
152
+ severity: 'error',
153
+ message: `Universe "${universeName}" has multiple title blocks. Move all title content into one file.`,
154
+ source: newTitleBlock.source,
155
+ });
156
+ // Also add a diagnostic pointing to the first occurrence
157
+ graph.diagnostics.push({
158
+ severity: 'error',
159
+ message: `First title block for universe "${universeName}" was declared here.`,
160
+ source: existingUniverseNode.source, // Use universe node source for first title
161
+ });
162
+ }
163
+
164
+ // If no conflicts, merge the body (references, documentation, unknownBlocks, children)
165
+ // processBody will append to existing arrays and add children
166
+ processBody(
167
+ graph,
168
+ universeName,
169
+ universeNodeId,
170
+ universeDecl.body,
171
+ nameMap,
172
+ fileAST.file,
173
+ universeNodeId,
174
+ namedReferencesByUniverse.get(universeName),
175
+ namedDocumentsByUniverse.get(universeName),
176
+ repositoriesByUniverse.get(universeName),
177
+ );
178
+ } else {
179
+ // First time seeing this universe - create node and process body
180
+ const universeNode = createNode(
181
+ universeNodeId,
182
+ 'universe',
183
+ universeName,
184
+ undefined,
185
+ universeDecl,
186
+ );
187
+ graph.nodes[universeNodeId] = universeNode;
188
+ nameMap.set(universeName, universeNodeId);
189
+
190
+ // Process universe body (universeNodeId is both parent and current node for UnknownBlocks)
191
+ processBody(
192
+ graph,
193
+ universeName,
194
+ universeNodeId,
195
+ universeDecl.body,
196
+ nameMap,
197
+ fileAST.file,
198
+ universeNodeId,
199
+ namedReferencesByUniverse.get(universeName),
200
+ namedDocumentsByUniverse.get(universeName),
201
+ repositoriesByUniverse.get(universeName),
202
+ );
203
+ }
204
+ }
205
+ }
206
+
207
+ // Extract repositories from all universes and convert to config format
208
+ graph.repositories = {};
209
+ for (const [universeName, reposMap] of repositoriesByUniverse.entries()) {
210
+ for (const [repoName, repoDecl] of reposMap.entries()) {
211
+ // Convert repository kind from package name to directory name
212
+ // '@sprig-and-prose/sprig-repository-github' -> 'sprig-repository-github'
213
+ let kindValue = String(repoDecl.repositoryKind);
214
+ if (kindValue.startsWith('@') && kindValue.includes('/')) {
215
+ const parts = kindValue.split('/');
216
+ kindValue = parts[parts.length - 1];
217
+ }
218
+
219
+ graph.repositories[repoName] = {
220
+ kind: kindValue,
221
+ options: repoDecl.options,
222
+ };
223
+ }
224
+ }
225
+
226
+ // Resolve edge endpoints
227
+ resolveEdges(graph, nameToNodeId);
228
+
229
+ return graph;
230
+ }
231
+
232
+ /**
233
+ * Processes a body of declarations, creating nodes and edges
234
+ * @param {UniverseGraph} graph
235
+ * @param {string} universeName
236
+ * @param {string} parentNodeId - Parent node ID (for tree relationships)
237
+ * @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>} body
238
+ * @param {Map<string, string>} nameMap
239
+ * @param {string} file
240
+ * @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
241
+ * @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
242
+ * @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
243
+ * @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
244
+ */
245
+ function processBody(graph, universeName, parentNodeId, body, nameMap, file, currentNodeId, namedRefsMap, namedDocsMap, reposMap) {
246
+ for (const decl of body) {
247
+ if (decl.kind === 'anthology') {
248
+ const nodeId = makeNodeId(universeName, 'anthology', decl.name);
249
+ checkDuplicateName(graph, universeName, decl.name, nodeId, 'anthology', nameMap, decl.source);
250
+ const node = createNode(nodeId, 'anthology', decl.name, parentNodeId, decl);
251
+ graph.nodes[nodeId] = node;
252
+ graph.nodes[parentNodeId].children.push(nodeId);
253
+ nameMap.set(decl.name, nodeId);
254
+ processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
255
+ } else if (decl.kind === 'series') {
256
+ const nodeId = makeNodeId(universeName, 'series', decl.name);
257
+ checkDuplicateName(graph, universeName, decl.name, nodeId, 'series', nameMap, decl.source);
258
+
259
+ // Handle optional anthology parent
260
+ let actualParentNodeId = parentNodeId;
261
+ if (decl.parentName) {
262
+ const anthologyNodeId = nameMap.get(decl.parentName);
263
+ if (anthologyNodeId) {
264
+ actualParentNodeId = anthologyNodeId;
265
+ }
266
+ // If anthology not found, fall back to parentNodeId (tolerant parsing)
267
+ }
268
+
269
+ const node = createNode(nodeId, 'series', decl.name, actualParentNodeId, decl);
270
+ graph.nodes[nodeId] = node;
271
+ graph.nodes[actualParentNodeId].children.push(nodeId);
272
+ nameMap.set(decl.name, nodeId);
273
+ processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
274
+ } else if (decl.kind === 'book') {
275
+ 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);
278
+ // Always set container for book nodes (even if undefined when parentName doesn't resolve)
279
+ const node = createNode(
280
+ nodeId,
281
+ 'book',
282
+ decl.name,
283
+ containerNodeId || parentNodeId,
284
+ decl,
285
+ containerNodeId, // May be undefined if parentName doesn't resolve
286
+ );
287
+ graph.nodes[nodeId] = node;
288
+ if (containerNodeId) {
289
+ graph.nodes[containerNodeId].children.push(nodeId);
290
+ } else {
291
+ graph.nodes[parentNodeId].children.push(nodeId);
292
+ }
293
+ nameMap.set(decl.name, nodeId);
294
+ processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
295
+ } else if (decl.kind === 'chapter') {
296
+ 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);
299
+ // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
300
+ const node = createNode(
301
+ nodeId,
302
+ 'chapter',
303
+ decl.name,
304
+ containerNodeId || parentNodeId,
305
+ decl,
306
+ containerNodeId, // May be undefined if parentName doesn't resolve
307
+ );
308
+ graph.nodes[nodeId] = node;
309
+ if (containerNodeId) {
310
+ graph.nodes[containerNodeId].children.push(nodeId);
311
+ } else {
312
+ graph.nodes[parentNodeId].children.push(nodeId);
313
+ }
314
+ nameMap.set(decl.name, nodeId);
315
+ processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
316
+ } else if (decl.kind === 'concept') {
317
+ const nodeId = makeNodeId(universeName, 'concept', decl.name);
318
+ checkDuplicateName(graph, universeName, decl.name, nodeId, 'concept', nameMap, decl.source);
319
+
320
+ // Handle optional parent
321
+ let actualParentNodeId = parentNodeId;
322
+ if (decl.parentName) {
323
+ const parentNodeIdFromName = nameMap.get(decl.parentName);
324
+ if (parentNodeIdFromName) {
325
+ actualParentNodeId = parentNodeIdFromName;
326
+ }
327
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
328
+ }
329
+
330
+ const node = createNode(nodeId, 'concept', decl.name, actualParentNodeId, decl);
331
+ graph.nodes[nodeId] = node;
332
+ graph.nodes[actualParentNodeId].children.push(nodeId);
333
+ nameMap.set(decl.name, nodeId);
334
+ processBody(graph, universeName, nodeId, decl.body, nameMap, file, nodeId, namedRefsMap, namedDocsMap);
335
+ } else if (decl.kind === 'relates') {
336
+ // Check for duplicate relates in reverse order
337
+ checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
338
+
339
+ // Create relates node - resolution happens later
340
+ const relatesNodeId = makeRelatesNodeId(universeName, decl.a, decl.b);
341
+ // Check for duplicate relates and increment index if needed
342
+ let index = 0;
343
+ let finalRelatesNodeId = relatesNodeId;
344
+ while (graph.nodes[finalRelatesNodeId]) {
345
+ index++;
346
+ finalRelatesNodeId = makeRelatesNodeId(universeName, decl.a, decl.b, index);
347
+ }
348
+
349
+ // Process relates body: extract describe, from blocks, and unknown blocks
350
+ const describeBlocks = decl.body.filter((b) => b.kind === 'describe');
351
+ const fromBlocks = decl.body.filter((b) => b.kind === 'from');
352
+ const unknownBlocks = decl.body.filter((b) => b.kind !== 'describe' && b.kind !== 'from');
353
+
354
+ // Validate: only one top-level describe
355
+ if (describeBlocks.length > 1) {
356
+ graph.diagnostics.push({
357
+ severity: 'error',
358
+ message: `Multiple describe blocks in relates "${decl.a} and ${decl.b}"`,
359
+ source: describeBlocks[1].source,
360
+ });
361
+ }
362
+
363
+ // Validate: from blocks must reference endpoints
364
+ const endpointSet = new Set([decl.a, decl.b]);
365
+ const fromByEndpoint = new Map();
366
+ for (const fromBlock of fromBlocks) {
367
+ if (!endpointSet.has(fromBlock.endpoint)) {
368
+ graph.diagnostics.push({
369
+ severity: 'error',
370
+ message: `from block references "${fromBlock.endpoint}" which is not an endpoint of relates "${decl.a} and ${decl.b}"`,
371
+ source: fromBlock.source,
372
+ });
373
+ } else {
374
+ // Check for duplicate from blocks for same endpoint
375
+ if (fromByEndpoint.has(fromBlock.endpoint)) {
376
+ graph.diagnostics.push({
377
+ severity: 'error',
378
+ message: `Duplicate from block for endpoint "${fromBlock.endpoint}" in relates "${decl.a} and ${decl.b}"`,
379
+ source: fromBlock.source,
380
+ });
381
+ } else {
382
+ fromByEndpoint.set(fromBlock.endpoint, fromBlock);
383
+ }
384
+ }
385
+ }
386
+
387
+ // Build relates node
388
+ const relatesNode = {
389
+ id: finalRelatesNodeId,
390
+ kind: 'relates',
391
+ name: `${decl.a} and ${decl.b}`,
392
+ parent: parentNodeId,
393
+ children: [],
394
+ source: decl.source,
395
+ endpoints: [], // Will be populated during resolution
396
+ unresolvedEndpoints: [decl.a, decl.b], // Will be cleared during resolution
397
+ };
398
+
399
+ // Add top-level describe if present
400
+ if (describeBlocks.length > 0) {
401
+ relatesNode.describe = {
402
+ raw: describeBlocks[0].raw,
403
+ normalized: normalizeProseBlock(describeBlocks[0].raw),
404
+ source: describeBlocks[0].source,
405
+ };
406
+ }
407
+
408
+ // Process from blocks (will be resolved later)
409
+ relatesNode.from = {};
410
+ for (const fromBlock of fromBlocks) {
411
+ if (endpointSet.has(fromBlock.endpoint)) {
412
+ // Process from body
413
+ const relationshipsBlocks = fromBlock.body.filter((b) => b.kind === 'relationships');
414
+ const fromDescribeBlocks = fromBlock.body.filter((b) => b.kind === 'describe');
415
+ const fromUnknownBlocks = fromBlock.body.filter(
416
+ (b) => b.kind !== 'relationships' && b.kind !== 'describe',
417
+ );
418
+
419
+ // Validate: only one relationships and one describe per from
420
+ if (relationshipsBlocks.length > 1) {
421
+ graph.diagnostics.push({
422
+ severity: 'error',
423
+ message: `Multiple relationships blocks in from "${fromBlock.endpoint}"`,
424
+ source: relationshipsBlocks[1].source,
425
+ });
426
+ }
427
+ if (fromDescribeBlocks.length > 1) {
428
+ graph.diagnostics.push({
429
+ severity: 'error',
430
+ message: `Multiple describe blocks in from "${fromBlock.endpoint}"`,
431
+ source: fromDescribeBlocks[1].source,
432
+ });
433
+ }
434
+
435
+ // Validate: relationships must have at least one value
436
+ if (relationshipsBlocks.length > 0 && relationshipsBlocks[0].values.length === 0) {
437
+ graph.diagnostics.push({
438
+ severity: 'error',
439
+ message: `Empty relationships block in from "${fromBlock.endpoint}"`,
440
+ source: relationshipsBlocks[0].source,
441
+ });
442
+ }
443
+
444
+ const fromView = {
445
+ source: fromBlock.source,
446
+ };
447
+
448
+ if (relationshipsBlocks.length > 0) {
449
+ fromView.relationships = {
450
+ values: relationshipsBlocks[0].values,
451
+ source: relationshipsBlocks[0].source,
452
+ };
453
+ }
454
+
455
+ if (fromDescribeBlocks.length > 0) {
456
+ fromView.describe = {
457
+ raw: fromDescribeBlocks[0].raw,
458
+ normalized: normalizeProseBlock(fromDescribeBlocks[0].raw),
459
+ source: fromDescribeBlocks[0].source,
460
+ };
461
+ }
462
+
463
+ if (fromUnknownBlocks.length > 0) {
464
+ fromView.unknownBlocks = fromUnknownBlocks.map((ub) => ({
465
+ keyword: ub.keyword,
466
+ raw: ub.raw,
467
+ normalized: normalizeProseBlock(ub.raw),
468
+ source: ub.source,
469
+ }));
470
+ }
471
+
472
+ // Store by endpoint name for now, will be resolved to node ID later
473
+ relatesNode.from[fromBlock.endpoint] = fromView;
474
+ }
475
+ }
476
+
477
+ // Process unknown blocks at relates level
478
+ if (unknownBlocks.length > 0) {
479
+ relatesNode.unknownBlocks = unknownBlocks.map((ub) => ({
480
+ keyword: ub.keyword,
481
+ raw: ub.raw,
482
+ normalized: normalizeProseBlock(ub.raw),
483
+ source: ub.source,
484
+ }));
485
+ }
486
+
487
+ graph.nodes[finalRelatesNodeId] = relatesNode;
488
+ graph.nodes[parentNodeId].children.push(finalRelatesNodeId);
489
+
490
+ // Also create edge for backward compatibility
491
+ const edgeId = makeEdgeId(universeName, decl.a, decl.b, index);
492
+ const edge = {
493
+ id: edgeId,
494
+ kind: 'relates',
495
+ a: { text: decl.a },
496
+ b: { text: decl.b },
497
+ source: decl.source,
498
+ };
499
+
500
+ if (describeBlocks.length > 0) {
501
+ edge.describe = {
502
+ raw: describeBlocks[0].raw,
503
+ normalized: normalizeProseBlock(describeBlocks[0].raw),
504
+ source: describeBlocks[0].source,
505
+ };
506
+ }
507
+
508
+ graph.edges[edgeId] = edge;
509
+ } else if (decl.kind === 'describe') {
510
+ // Describe blocks are attached to their parent node
511
+ // This is handled in createNode
512
+ } else if (decl.kind === 'title') {
513
+ // Title blocks are attached to their parent node
514
+ // This is handled in createNode
515
+ } else if (decl.kind === 'references') {
516
+ // ReferencesBlock - attach to current node
517
+ const currentNode = graph.nodes[currentNodeId];
518
+ if (!currentNode.references) {
519
+ currentNode.references = [];
520
+ }
521
+ // Serialize references block - handle both inline references and using blocks
522
+ for (const item of decl.references) {
523
+ if (item.kind === 'reference') {
524
+ // Inline reference block - convert as before
525
+ currentNode.references.push({
526
+ repository: item.repository,
527
+ paths: item.paths,
528
+ kind: item.referenceKind,
529
+ describe: item.describe
530
+ ? {
531
+ raw: item.describe.raw,
532
+ normalized: normalizeProseBlock(item.describe.raw),
533
+ source: item.describe.source,
534
+ }
535
+ : undefined,
536
+ source: item.source,
537
+ });
538
+ } else if (item.kind === 'using-in-references') {
539
+ // Using block - resolve each name against named references registry
540
+ for (const name of item.names) {
541
+ const namedRef = graph.referencesByName[universeName]?.[name];
542
+ if (namedRef) {
543
+ // Resolved: expand to reference object
544
+ currentNode.references.push({
545
+ repository: namedRef.repository,
546
+ paths: namedRef.paths,
547
+ kind: namedRef.kind,
548
+ describe: namedRef.describe,
549
+ source: namedRef.source, // Use the named reference's source, not the using block's
550
+ });
551
+ } else {
552
+ // Not resolved: emit error diagnostic
553
+ graph.diagnostics.push({
554
+ severity: 'error',
555
+ message: `Unknown reference '${name}' used in references block`,
556
+ source: item.source,
557
+ });
558
+ }
559
+ }
560
+ }
561
+ }
562
+ } else if (decl.kind === 'documentation') {
563
+ // DocumentationBlock - attach to current node
564
+ const currentNode = graph.nodes[currentNodeId];
565
+ if (!currentNode.documentation) {
566
+ currentNode.documentation = [];
567
+ }
568
+ // Serialize documentation block - each document block becomes an entry
569
+ for (const doc of decl.documents) {
570
+ currentNode.documentation.push({
571
+ title: doc.title,
572
+ kind: doc.documentKind,
573
+ path: doc.path,
574
+ describe: doc.describe
575
+ ? {
576
+ raw: doc.describe.raw,
577
+ normalized: normalizeProseBlock(doc.describe.raw),
578
+ source: doc.describe.source,
579
+ }
580
+ : undefined,
581
+ source: doc.source,
582
+ });
583
+ }
584
+ } else if (decl.kind === 'named-reference') {
585
+ // NamedReferenceBlock - store in universe-level registry
586
+ // Only process at universe scope (when namedRefsMap is provided)
587
+ if (namedRefsMap && parentNodeId === currentNodeId) {
588
+ const refName = decl.name;
589
+
590
+ // Check for duplicate name
591
+ if (namedRefsMap.has(refName)) {
592
+ const firstOccurrence = namedRefsMap.get(refName);
593
+ graph.diagnostics.push({
594
+ severity: 'error',
595
+ message: `Duplicate named reference "${refName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
596
+ source: decl.source,
597
+ });
598
+ graph.diagnostics.push({
599
+ severity: 'error',
600
+ message: `First declaration of named reference "${refName}" was here.`,
601
+ source: firstOccurrence.source,
602
+ });
603
+ } else {
604
+ // Store first occurrence
605
+ namedRefsMap.set(refName, { source: decl.source });
606
+
607
+ // Convert to model and store in registry
608
+ const refModel = {
609
+ name: refName,
610
+ repository: decl.repository,
611
+ paths: decl.paths,
612
+ kind: decl.referenceKind,
613
+ describe: decl.describe
614
+ ? {
615
+ raw: decl.describe.raw,
616
+ normalized: normalizeProseBlock(decl.describe.raw),
617
+ source: decl.describe.source,
618
+ }
619
+ : undefined,
620
+ source: decl.source,
621
+ };
622
+
623
+ graph.referencesByName[universeName][refName] = refModel;
624
+ }
625
+ }
626
+ } else if (decl.kind === 'repository') {
627
+ // RepositoryDecl - store in universe-level registry
628
+ // Only process at universe scope (when reposMap is provided)
629
+ if (reposMap && parentNodeId === currentNodeId) {
630
+ const repoName = decl.name;
631
+
632
+ // Check for duplicate name
633
+ if (reposMap.has(repoName)) {
634
+ const firstOccurrence = reposMap.get(repoName);
635
+ graph.diagnostics.push({
636
+ severity: 'error',
637
+ message: `Duplicate repository "${repoName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
638
+ source: decl.source,
639
+ });
640
+ graph.diagnostics.push({
641
+ severity: 'error',
642
+ message: `First declaration of repository "${repoName}" was here.`,
643
+ source: firstOccurrence.source,
644
+ });
645
+ } else {
646
+ // Store repository declaration
647
+ reposMap.set(repoName, decl);
648
+ }
649
+ }
650
+ } else if (decl.kind === 'named-document') {
651
+ // NamedDocumentBlock - store in universe-level registry
652
+ // Only process at universe scope (when namedDocsMap is provided)
653
+ if (namedDocsMap && parentNodeId === currentNodeId) {
654
+ const docName = decl.name;
655
+
656
+ // Check for duplicate name
657
+ if (namedDocsMap.has(docName)) {
658
+ const firstOccurrence = namedDocsMap.get(docName);
659
+ graph.diagnostics.push({
660
+ severity: 'error',
661
+ message: `Duplicate named document "${docName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
662
+ source: decl.source,
663
+ });
664
+ graph.diagnostics.push({
665
+ severity: 'error',
666
+ message: `First declaration of named document "${docName}" was here.`,
667
+ source: firstOccurrence.source,
668
+ });
669
+ } else {
670
+ // Store first occurrence
671
+ namedDocsMap.set(docName, { source: decl.source });
672
+
673
+ // Convert to model and store in registry
674
+ const docModel = {
675
+ name: docName,
676
+ kind: decl.documentKind,
677
+ path: decl.path,
678
+ describe: decl.describe
679
+ ? {
680
+ raw: decl.describe.raw,
681
+ normalized: normalizeProseBlock(decl.describe.raw),
682
+ source: decl.describe.source,
683
+ }
684
+ : undefined,
685
+ source: decl.source,
686
+ };
687
+
688
+ graph.documentsByName[universeName][docName] = docModel;
689
+ }
690
+ }
691
+ } else {
692
+ // UnknownBlock - attach to current node (the node whose body contains this block)
693
+ const currentNode = graph.nodes[currentNodeId];
694
+ if (!currentNode.unknownBlocks) {
695
+ currentNode.unknownBlocks = [];
696
+ }
697
+ currentNode.unknownBlocks.push({
698
+ keyword: decl.keyword,
699
+ raw: decl.raw,
700
+ normalized: normalizeProseBlock(decl.raw),
701
+ source: decl.source,
702
+ });
703
+ }
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Creates a node model from a declaration
709
+ * @param {string} nodeId
710
+ * @param {'universe' | 'anthology' | 'series' | 'book' | 'chapter'} kind
711
+ * @param {string} name
712
+ * @param {string | undefined} parentNodeId
713
+ * @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
714
+ * @param {string | undefined} containerNodeId
715
+ * @returns {NodeModel}
716
+ */
717
+ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
718
+ const node = {
719
+ id: nodeId,
720
+ kind,
721
+ name,
722
+ parent: parentNodeId,
723
+ children: [],
724
+ source: decl.source,
725
+ };
726
+
727
+ // Always set container for book/chapter nodes (may be undefined if not resolved)
728
+ if (kind === 'book' || kind === 'chapter') {
729
+ node.container = containerNodeId; // May be undefined
730
+ } else if (containerNodeId) {
731
+ // For other node types, only set if provided
732
+ node.container = containerNodeId;
733
+ }
734
+
735
+ // Extract describe block if present
736
+ const describeBlock = decl.body?.find((b) => b.kind === 'describe');
737
+ if (describeBlock) {
738
+ node.describe = {
739
+ raw: describeBlock.raw,
740
+ normalized: normalizeProseBlock(describeBlock.raw),
741
+ source: describeBlock.source,
742
+ };
743
+ }
744
+
745
+ // Extract title block if present
746
+ const titleBlock = decl.body?.find((b) => b.kind === 'title');
747
+ if (titleBlock) {
748
+ // Title is a simple string, so we normalize it (trim whitespace)
749
+ const titleValue = titleBlock.raw.trim();
750
+ if (titleValue.length > 0) {
751
+ node.title = titleValue;
752
+ }
753
+ }
754
+
755
+ // Note: UnknownBlocks are handled in processBody and attached to the parent node
756
+ // They are not extracted here to avoid duplication
757
+
758
+ return node;
759
+ }
760
+
761
+ /**
762
+ * Checks for duplicate node names and emits warning if found
763
+ * @param {UniverseGraph} graph
764
+ * @param {string} universeName
765
+ * @param {string} name
766
+ * @param {string} nodeId
767
+ * @param {string} kind - The kind of the current node being checked
768
+ * @param {Map<string, string>} nameMap
769
+ * @param {SourceSpan} source
770
+ */
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)
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Checks for duplicate relates blocks in reverse order
787
+ * @param {UniverseGraph} graph
788
+ * @param {string} universeName
789
+ * @param {string} a
790
+ * @param {string} b
791
+ * @param {SourceSpan} source
792
+ */
793
+ function checkDuplicateRelates(graph, universeName, a, b, source) {
794
+ // Check if a relates node exists with the reverse order
795
+ const reverseRelatesNodeId = makeRelatesNodeId(universeName, b, a);
796
+ if (graph.nodes[reverseRelatesNodeId]) {
797
+ graph.diagnostics.push({
798
+ severity: 'warning',
799
+ message: `Duplicate relates block: "${a} and ${b}" already exists as "${b} and ${a}"`,
800
+ source,
801
+ });
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Resolves edge endpoint references and relates node endpoints
807
+ * @param {UniverseGraph} graph
808
+ * @param {Map<string, Map<string, string>>} nameToNodeId
809
+ */
810
+ function resolveEdges(graph, nameToNodeId) {
811
+ // Build a set of relates node names to avoid duplicate warnings
812
+ // (edges are created for backward compatibility alongside relates nodes)
813
+ const relatesNodeNames = new Set();
814
+ for (const nodeId in graph.nodes) {
815
+ const node = graph.nodes[nodeId];
816
+ if (node.kind === 'relates') {
817
+ relatesNodeNames.add(node.name);
818
+ }
819
+ }
820
+
821
+ // Resolve edges (for backward compatibility)
822
+ for (const edgeId in graph.edges) {
823
+ const edge = graph.edges[edgeId];
824
+ 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)
840
+ graph.diagnostics.push({
841
+ severity: 'warning',
842
+ message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
843
+ source: edge.source,
844
+ });
845
+ }
846
+
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)
853
+ graph.diagnostics.push({
854
+ severity: 'warning',
855
+ message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
856
+ source: edge.source,
857
+ });
858
+ }
859
+ }
860
+ }
861
+
862
+ // Resolve relates nodes
863
+ for (const nodeId in graph.nodes) {
864
+ const node = graph.nodes[nodeId];
865
+ if (node.kind === 'relates' && node.unresolvedEndpoints) {
866
+ const universeName = nodeId.split(':')[0];
867
+ const nameMap = nameToNodeId.get(universeName);
868
+
869
+ if (nameMap) {
870
+ const resolvedEndpoints = [];
871
+ const unresolved = [];
872
+
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);
879
+ graph.diagnostics.push({
880
+ severity: 'warning',
881
+ message: `Unresolved relates endpoint "${endpointName}" in universe "${universeName}"`,
882
+ source: node.source,
883
+ });
884
+ }
885
+ }
886
+
887
+ node.endpoints = resolvedEndpoints;
888
+ if (unresolved.length > 0) {
889
+ node.unresolvedEndpoints = unresolved;
890
+ } else {
891
+ delete node.unresolvedEndpoints;
892
+ }
893
+
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
+ }
905
+ }
906
+ node.from = resolvedFrom;
907
+ }
908
+ }
909
+ }
910
+ }
911
+ }
912
+
913
+ /**
914
+ * Creates a node ID
915
+ * @param {string} universeName
916
+ * @param {string} kind
917
+ * @param {string} name
918
+ * @returns {string}
919
+ */
920
+ function makeNodeId(universeName, kind, name) {
921
+ return `${universeName}:${kind}:${name}`;
922
+ }
923
+
924
+ /**
925
+ * Creates a relates node ID (deterministic, endpoints in source order)
926
+ * @param {string} universeName
927
+ * @param {string} a
928
+ * @param {string} b
929
+ * @param {number} [index=0]
930
+ * @returns {string}
931
+ */
932
+ function makeRelatesNodeId(universeName, a, b, index = 0) {
933
+ if (index === 0) {
934
+ return `${universeName}:relates:${a}:${b}`;
935
+ }
936
+ return `${universeName}:relates:${a}:${b}:${index}`;
937
+ }
938
+
939
+ /**
940
+ * Creates an edge ID
941
+ * @param {string} universeName
942
+ * @param {string} a
943
+ * @param {string} b
944
+ * @param {number} index
945
+ * @returns {string}
946
+ */
947
+ function makeEdgeId(universeName, a, b, index) {
948
+ return `${universeName}:relates:${a}--${b}:${index}`;
949
+ }
950
+