@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.
- package/PHILOSOPHY.md +201 -0
- package/README.md +168 -0
- package/REFERENCE.md +355 -0
- package/biome.json +24 -0
- package/package.json +30 -0
- package/repositories/sprig-repository-github/index.js +29 -0
- package/src/ast.js +257 -0
- package/src/cli.js +1510 -0
- package/src/graph.js +950 -0
- package/src/index.js +46 -0
- package/src/ir.js +121 -0
- package/src/parser.js +1656 -0
- package/src/scanner.js +255 -0
- package/src/scene-manifest.js +856 -0
- package/src/util/span.js +46 -0
- package/src/util/text.js +126 -0
- package/src/validator.js +862 -0
- package/src/validators/mysql/connection.js +154 -0
- package/src/validators/mysql/schema.js +209 -0
- package/src/validators/mysql/type-compat.js +219 -0
- package/src/validators/mysql/validator.js +332 -0
- package/test/fixtures/amaranthine-mini.prose +53 -0
- package/test/fixtures/conflicting-universes-a.prose +8 -0
- package/test/fixtures/conflicting-universes-b.prose +8 -0
- package/test/fixtures/duplicate-names.prose +20 -0
- package/test/fixtures/first-line-aware.prose +32 -0
- package/test/fixtures/indented-describe.prose +18 -0
- package/test/fixtures/multi-file-universe-a.prose +15 -0
- package/test/fixtures/multi-file-universe-b.prose +15 -0
- package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
- package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
- package/test/fixtures/multi-file-universe-with-title.prose +10 -0
- package/test/fixtures/named-document.prose +17 -0
- package/test/fixtures/named-duplicate.prose +22 -0
- package/test/fixtures/named-reference.prose +17 -0
- package/test/fixtures/relates-errors.prose +38 -0
- package/test/fixtures/relates-tier1.prose +14 -0
- package/test/fixtures/relates-tier2.prose +16 -0
- package/test/fixtures/relates-tier3.prose +21 -0
- package/test/fixtures/sprig-meta-mini.prose +62 -0
- package/test/fixtures/unresolved-relates.prose +15 -0
- package/test/fixtures/using-in-references.prose +35 -0
- package/test/fixtures/using-unknown.prose +8 -0
- package/test/universe-basic.test.js +804 -0
- 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
|
+
|