@sprig-and-prose/sprig-universe 0.1.1 → 0.3.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/ast.js +30 -32
- package/src/cli.js +1 -210
- package/src/graph.js +888 -235
- package/src/ir.js +21 -6
- package/src/parser.js +251 -174
- package/test/fixtures/amaranthine-mini.prose +14 -8
- package/test/fixtures/multi-file-universe-a.prose +9 -4
- package/test/fixtures/multi-file-universe-b.prose +5 -4
- package/test/fixtures/named-duplicate.prose +6 -4
- package/test/fixtures/reference-attachments.prose +19 -0
- package/test/fixtures/reference-commas.prose +15 -0
- package/test/fixtures/reference-inline.prose +14 -0
- package/test/fixtures/reference-raw-url.prose +9 -0
- package/test/fixtures/reference-repo-paths.prose +11 -0
- package/test/fixtures/reference-unknown.prose +7 -0
- package/test/references.test.js +105 -0
- package/test/universe-basic.test.js +21 -166
- package/repositories/sprig-repository-github/index.js +0 -29
package/src/graph.js
CHANGED
|
@@ -27,9 +27,7 @@ import { normalizeProseBlock } from './util/text.js';
|
|
|
27
27
|
* @typedef {import('./ast.js').FromBlock} FromBlock
|
|
28
28
|
* @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
|
|
29
29
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
30
|
-
* @typedef {import('./ast.js').
|
|
31
|
-
* @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
|
|
32
|
-
* @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
|
|
30
|
+
* @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
|
|
33
31
|
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
34
32
|
* @typedef {import('./ast.js').DocumentBlock} DocumentBlock
|
|
35
33
|
* @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
|
|
@@ -38,6 +36,10 @@ import { normalizeProseBlock } from './util/text.js';
|
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
38
|
* Builds a UniverseGraph from parsed AST files
|
|
39
|
+
*
|
|
40
|
+
* Note: There is only ever one universe per manifest. All files must declare
|
|
41
|
+
* the same universe name, and they are merged into a single universe.
|
|
42
|
+
*
|
|
41
43
|
* @param {FileAST[]} fileASTs - Array of parsed file ASTs
|
|
42
44
|
* @returns {UniverseGraph}
|
|
43
45
|
*/
|
|
@@ -48,19 +50,30 @@ export function buildGraph(fileASTs) {
|
|
|
48
50
|
nodes: {},
|
|
49
51
|
edges: {},
|
|
50
52
|
diagnostics: [],
|
|
51
|
-
referencesByName: {},
|
|
52
53
|
documentsByName: {},
|
|
54
|
+
repositories: {},
|
|
55
|
+
references: {},
|
|
53
56
|
};
|
|
54
57
|
|
|
55
|
-
// Track node names within each
|
|
56
|
-
|
|
58
|
+
// Track node names within each scope (container) for scoped resolution
|
|
59
|
+
// Map<containerNodeId, Map<name, nodeId[]>> - allows multiple nodes with same name in different scopes
|
|
60
|
+
const scopeIndex = new Map(); // containerNodeId -> Map<name, nodeId[]>
|
|
57
61
|
|
|
58
|
-
// Track named
|
|
59
|
-
const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
|
|
62
|
+
// Track named documents per universe for duplicate detection
|
|
60
63
|
const namedDocumentsByUniverse = new Map(); // universeName -> Map<name, { source }>
|
|
61
64
|
|
|
62
|
-
// Track repositories per universe
|
|
63
|
-
const repositoriesByUniverse = new Map(); // universeName -> Map<
|
|
65
|
+
// Track repositories and references per universe for scoped resolution
|
|
66
|
+
const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
|
|
67
|
+
const referencesByUniverse = new Map(); // universeName -> Map<id, ReferenceDecl>
|
|
68
|
+
|
|
69
|
+
// Track entity kinds for duplicate detection (references/repositories)
|
|
70
|
+
const entityKinds = new Map(); // id -> kind
|
|
71
|
+
|
|
72
|
+
// Pending resolutions
|
|
73
|
+
/** @type {Array<{ refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string }>} */
|
|
74
|
+
const pendingReferenceDecls = [];
|
|
75
|
+
/** @type {Array<{ nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string }>} */
|
|
76
|
+
const pendingReferenceAttachments = [];
|
|
64
77
|
|
|
65
78
|
// First pass: collect all universe names with their file locations for validation
|
|
66
79
|
const universeNameToFiles = new Map(); // universeName -> Set<file>
|
|
@@ -102,17 +115,6 @@ export function buildGraph(fileASTs) {
|
|
|
102
115
|
};
|
|
103
116
|
}
|
|
104
117
|
|
|
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
118
|
if (!namedDocumentsByUniverse.has(universeName)) {
|
|
117
119
|
namedDocumentsByUniverse.set(universeName, new Map());
|
|
118
120
|
graph.documentsByName[universeName] = {};
|
|
@@ -120,6 +122,9 @@ export function buildGraph(fileASTs) {
|
|
|
120
122
|
if (!repositoriesByUniverse.has(universeName)) {
|
|
121
123
|
repositoriesByUniverse.set(universeName, new Map());
|
|
122
124
|
}
|
|
125
|
+
if (!referencesByUniverse.has(universeName)) {
|
|
126
|
+
referencesByUniverse.set(universeName, new Map());
|
|
127
|
+
}
|
|
123
128
|
|
|
124
129
|
// Check if universe node already exists (from a previous file)
|
|
125
130
|
const existingUniverseNode = graph.nodes[universeNodeId];
|
|
@@ -175,12 +180,15 @@ export function buildGraph(fileASTs) {
|
|
|
175
180
|
universeName,
|
|
176
181
|
universeNodeId,
|
|
177
182
|
universeDecl.body,
|
|
178
|
-
|
|
183
|
+
scopeIndex,
|
|
179
184
|
fileAST.file,
|
|
180
185
|
universeNodeId,
|
|
181
|
-
namedReferencesByUniverse.get(universeName),
|
|
182
186
|
namedDocumentsByUniverse.get(universeName),
|
|
183
187
|
repositoriesByUniverse.get(universeName),
|
|
188
|
+
referencesByUniverse.get(universeName),
|
|
189
|
+
entityKinds,
|
|
190
|
+
pendingReferenceDecls,
|
|
191
|
+
pendingReferenceAttachments,
|
|
184
192
|
);
|
|
185
193
|
} else {
|
|
186
194
|
// First time seeing this universe - create node and process body
|
|
@@ -192,7 +200,8 @@ export function buildGraph(fileASTs) {
|
|
|
192
200
|
universeDecl,
|
|
193
201
|
);
|
|
194
202
|
graph.nodes[universeNodeId] = universeNode;
|
|
195
|
-
|
|
203
|
+
// Add universe name to its own scope
|
|
204
|
+
addNameToScope(graph, scopeIndex, universeNodeId, universeName, universeNodeId);
|
|
196
205
|
|
|
197
206
|
// Process universe body (universeNodeId is both parent and current node for UnknownBlocks)
|
|
198
207
|
processBody(
|
|
@@ -200,88 +209,377 @@ export function buildGraph(fileASTs) {
|
|
|
200
209
|
universeName,
|
|
201
210
|
universeNodeId,
|
|
202
211
|
universeDecl.body,
|
|
203
|
-
|
|
212
|
+
scopeIndex,
|
|
204
213
|
fileAST.file,
|
|
205
214
|
universeNodeId,
|
|
206
|
-
namedReferencesByUniverse.get(universeName),
|
|
207
215
|
namedDocumentsByUniverse.get(universeName),
|
|
208
216
|
repositoriesByUniverse.get(universeName),
|
|
217
|
+
referencesByUniverse.get(universeName),
|
|
218
|
+
entityKinds,
|
|
219
|
+
pendingReferenceDecls,
|
|
220
|
+
pendingReferenceAttachments,
|
|
209
221
|
);
|
|
210
222
|
}
|
|
211
223
|
}
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
226
|
+
// Attach unscoped top-level declarations to the single universe if present
|
|
227
|
+
if (universeNameToFiles.size === 1) {
|
|
228
|
+
const [universeName] = universeNameToFiles.keys();
|
|
229
|
+
const universeModel = graph.universes[universeName];
|
|
230
|
+
const universeNodeId = universeModel?.root;
|
|
231
|
+
if (universeNodeId) {
|
|
232
|
+
for (const fileAST of fileASTs) {
|
|
233
|
+
if (!fileAST.topLevelDecls || fileAST.topLevelDecls.length === 0) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
for (const decl of fileAST.topLevelDecls) {
|
|
237
|
+
if (decl.parentName) {
|
|
238
|
+
const resolved = resolveNameInScope(
|
|
239
|
+
graph,
|
|
240
|
+
scopeIndex,
|
|
241
|
+
decl.parentName,
|
|
242
|
+
universeNodeId,
|
|
243
|
+
decl.source,
|
|
244
|
+
);
|
|
245
|
+
if (!resolved.nodeId && !resolved.ambiguous) {
|
|
246
|
+
graph.diagnostics.push({
|
|
247
|
+
severity: 'error',
|
|
248
|
+
message: `Top-level ${decl.kind} "${decl.name}" references unknown container "${decl.parentName}"`,
|
|
249
|
+
source: diagnosticSource(decl.source),
|
|
250
|
+
});
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
processBody(
|
|
255
|
+
graph,
|
|
256
|
+
universeName,
|
|
257
|
+
universeNodeId,
|
|
258
|
+
[decl],
|
|
259
|
+
scopeIndex,
|
|
260
|
+
fileAST.file,
|
|
261
|
+
universeNodeId,
|
|
262
|
+
namedDocumentsByUniverse.get(universeName),
|
|
263
|
+
repositoriesByUniverse.get(universeName),
|
|
264
|
+
referencesByUniverse.get(universeName),
|
|
265
|
+
entityKinds,
|
|
266
|
+
pendingReferenceDecls,
|
|
267
|
+
pendingReferenceAttachments,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
224
270
|
}
|
|
225
|
-
|
|
226
|
-
graph.repositories[repoName] = {
|
|
227
|
-
kind: kindValue,
|
|
228
|
-
options: repoDecl.options,
|
|
229
|
-
};
|
|
230
271
|
}
|
|
231
272
|
}
|
|
232
273
|
|
|
274
|
+
// Resolve reference declarations and attach references after all names are indexed
|
|
275
|
+
resolveReferenceDecls(
|
|
276
|
+
graph,
|
|
277
|
+
pendingReferenceDecls,
|
|
278
|
+
scopeIndex,
|
|
279
|
+
);
|
|
280
|
+
resolveReferenceAttachments(
|
|
281
|
+
graph,
|
|
282
|
+
pendingReferenceAttachments,
|
|
283
|
+
scopeIndex,
|
|
284
|
+
);
|
|
285
|
+
|
|
233
286
|
// Resolve edge endpoints
|
|
234
|
-
resolveEdges(graph,
|
|
287
|
+
resolveEdges(graph, scopeIndex);
|
|
235
288
|
|
|
236
289
|
return graph;
|
|
237
290
|
}
|
|
238
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Adds a name to a scope index and all ancestor scopes
|
|
294
|
+
* This allows names to be resolved from parent containers
|
|
295
|
+
* @param {UniverseGraph} graph - The graph
|
|
296
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
|
|
297
|
+
* @param {string} scopeNodeId - Container node ID (scope)
|
|
298
|
+
* @param {string} name - Name to add
|
|
299
|
+
* @param {string} nodeId - Node ID to associate with the name
|
|
300
|
+
*/
|
|
301
|
+
function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
|
|
302
|
+
// Add to current scope and all ancestor scopes
|
|
303
|
+
let currentScope = scopeNodeId;
|
|
304
|
+
const visitedScopes = new Set();
|
|
305
|
+
|
|
306
|
+
while (currentScope && !visitedScopes.has(currentScope)) {
|
|
307
|
+
visitedScopes.add(currentScope);
|
|
308
|
+
|
|
309
|
+
if (!scopeIndex.has(currentScope)) {
|
|
310
|
+
scopeIndex.set(currentScope, new Map());
|
|
311
|
+
}
|
|
312
|
+
const scopeMap = scopeIndex.get(currentScope);
|
|
313
|
+
if (!scopeMap.has(name)) {
|
|
314
|
+
scopeMap.set(name, []);
|
|
315
|
+
}
|
|
316
|
+
scopeMap.get(name).push(nodeId);
|
|
317
|
+
|
|
318
|
+
// Move to parent scope
|
|
319
|
+
const node = graph.nodes[currentScope];
|
|
320
|
+
if (node && node.parent) {
|
|
321
|
+
currentScope = node.parent;
|
|
322
|
+
} else {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
|
|
330
|
+
* Returns the first matching node ID found, or null if not found
|
|
331
|
+
* Also checks for ambiguity (multiple matches at the same scope level)
|
|
332
|
+
* @param {UniverseGraph} graph - The graph
|
|
333
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
334
|
+
* @param {string} name - Name to resolve
|
|
335
|
+
* @param {string} startScopeNodeId - Starting scope (container node ID)
|
|
336
|
+
* @param {SourceSpan} [source] - Optional source span for error reporting
|
|
337
|
+
* @returns {{ nodeId: string | null, ambiguous: boolean, ambiguousNodes: string[] }}
|
|
338
|
+
*/
|
|
339
|
+
function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
340
|
+
// Handle qualified names (dot notation)
|
|
341
|
+
if (name.includes('.')) {
|
|
342
|
+
const parts = name.split('.');
|
|
343
|
+
// Start from universe and resolve each part
|
|
344
|
+
const universeName = startScopeNodeId.split(':')[0];
|
|
345
|
+
const universeNodeId = `${universeName}:universe:${universeName}`;
|
|
346
|
+
let currentScope = universeNodeId;
|
|
347
|
+
|
|
348
|
+
for (const part of parts) {
|
|
349
|
+
const scopeMap = scopeIndex.get(currentScope);
|
|
350
|
+
if (!scopeMap || !scopeMap.has(part)) {
|
|
351
|
+
return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
|
|
352
|
+
}
|
|
353
|
+
const candidates = scopeMap.get(part);
|
|
354
|
+
if (candidates.length === 0) {
|
|
355
|
+
return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
|
|
356
|
+
}
|
|
357
|
+
if (candidates.length > 1) {
|
|
358
|
+
// Ambiguity at this level
|
|
359
|
+
if (source) {
|
|
360
|
+
graph.diagnostics.push({
|
|
361
|
+
severity: 'error',
|
|
362
|
+
message: `Ambiguous reference "${part}" in scope "${currentScope}": found ${candidates.length} matches`,
|
|
363
|
+
source,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
|
|
367
|
+
}
|
|
368
|
+
currentScope = candidates[0];
|
|
369
|
+
}
|
|
370
|
+
return { nodeId: currentScope, ambiguous: false, ambiguousNodes: [] };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Unqualified name - walk scope chain
|
|
374
|
+
let currentScope = startScopeNodeId;
|
|
375
|
+
const visitedScopes = new Set();
|
|
376
|
+
|
|
377
|
+
while (currentScope) {
|
|
378
|
+
if (visitedScopes.has(currentScope)) {
|
|
379
|
+
break; // Prevent infinite loops
|
|
380
|
+
}
|
|
381
|
+
visitedScopes.add(currentScope);
|
|
382
|
+
|
|
383
|
+
const scopeMap = scopeIndex.get(currentScope);
|
|
384
|
+
if (scopeMap && scopeMap.has(name)) {
|
|
385
|
+
const candidates = scopeMap.get(name);
|
|
386
|
+
if (candidates.length === 0) {
|
|
387
|
+
// Continue to parent scope
|
|
388
|
+
} else if (candidates.length === 1) {
|
|
389
|
+
return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
|
|
390
|
+
} else {
|
|
391
|
+
// Ambiguity at this scope level
|
|
392
|
+
if (source) {
|
|
393
|
+
const scopeNode = graph.nodes[currentScope];
|
|
394
|
+
const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
|
|
395
|
+
graph.diagnostics.push({
|
|
396
|
+
severity: 'error',
|
|
397
|
+
message: `Ambiguous reference "${name}" in scope "${scopeName}": found ${candidates.length} matches`,
|
|
398
|
+
source,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Move to parent scope
|
|
406
|
+
const node = graph.nodes[currentScope];
|
|
407
|
+
if (node && node.parent) {
|
|
408
|
+
currentScope = node.parent;
|
|
409
|
+
} else {
|
|
410
|
+
// Reached universe root or no parent - check universe scope one more time if we haven't already
|
|
411
|
+
const universeName = startScopeNodeId.split(':')[0];
|
|
412
|
+
const universeNodeId = `${universeName}:universe:${universeName}`;
|
|
413
|
+
if (currentScope !== universeNodeId) {
|
|
414
|
+
// Check universe scope before giving up
|
|
415
|
+
const universeScopeMap = scopeIndex.get(universeNodeId);
|
|
416
|
+
if (universeScopeMap && universeScopeMap.has(name)) {
|
|
417
|
+
const candidates = universeScopeMap.get(name);
|
|
418
|
+
if (candidates.length === 1) {
|
|
419
|
+
return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
|
|
420
|
+
} else if (candidates.length > 1) {
|
|
421
|
+
if (source) {
|
|
422
|
+
graph.diagnostics.push({
|
|
423
|
+
severity: 'error',
|
|
424
|
+
message: `Ambiguous reference "${name}" in universe scope: found ${candidates.length} matches`,
|
|
425
|
+
source,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return { nodeId: null, ambiguous: true, ambiguousNodes: candidates };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Resolve relates endpoints with a universe-wide fallback.
|
|
441
|
+
* @param {UniverseGraph} graph
|
|
442
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
443
|
+
* @param {string} name
|
|
444
|
+
* @param {string} startScopeNodeId
|
|
445
|
+
* @param {SourceSpan} [source]
|
|
446
|
+
* @returns {{ nodeId: string | null, ambiguous: boolean, ambiguousNodes: string[] }}
|
|
447
|
+
*/
|
|
448
|
+
function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
449
|
+
const resolved = resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source);
|
|
450
|
+
if (resolved.nodeId || resolved.ambiguous) {
|
|
451
|
+
return resolved;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const universeName = startScopeNodeId.split(':')[0];
|
|
455
|
+
const matches = [];
|
|
456
|
+
for (const nodeId in graph.nodes) {
|
|
457
|
+
if (!nodeId.startsWith(`${universeName}:`)) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const node = graph.nodes[nodeId];
|
|
461
|
+
if (node && node.name === name) {
|
|
462
|
+
matches.push(nodeId);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (matches.length === 1) {
|
|
467
|
+
return { nodeId: matches[0], ambiguous: false, ambiguousNodes: [] };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (matches.length > 1) {
|
|
471
|
+
if (source) {
|
|
472
|
+
graph.diagnostics.push({
|
|
473
|
+
severity: 'error',
|
|
474
|
+
message: `Ambiguous relates endpoint "${name}" in universe "${universeName}": found ${matches.length} matches`,
|
|
475
|
+
source,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return { nodeId: null, ambiguous: true, ambiguousNodes: matches };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return resolved;
|
|
482
|
+
}
|
|
483
|
+
|
|
239
484
|
/**
|
|
240
485
|
* Processes a body of declarations, creating nodes and edges
|
|
241
486
|
* @param {UniverseGraph} graph
|
|
242
487
|
* @param {string} universeName
|
|
243
488
|
* @param {string} parentNodeId - Parent node ID (for tree relationships)
|
|
244
|
-
|
|
245
|
-
* @param {Map<string, string
|
|
489
|
+
* @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body
|
|
490
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
|
|
246
491
|
* @param {string} file
|
|
247
492
|
* @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
|
|
248
|
-
* @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
|
|
249
493
|
* @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
|
|
250
494
|
* @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
|
|
495
|
+
* @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
|
|
496
|
+
* @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
|
|
497
|
+
* @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
|
|
498
|
+
* @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
|
|
251
499
|
*/
|
|
252
|
-
function processBody(
|
|
500
|
+
function processBody(
|
|
501
|
+
graph,
|
|
502
|
+
universeName,
|
|
503
|
+
parentNodeId,
|
|
504
|
+
body,
|
|
505
|
+
scopeIndex,
|
|
506
|
+
file,
|
|
507
|
+
currentNodeId,
|
|
508
|
+
namedDocsMap,
|
|
509
|
+
reposMap,
|
|
510
|
+
refsMap,
|
|
511
|
+
entityKinds,
|
|
512
|
+
pendingReferenceDecls,
|
|
513
|
+
pendingReferenceAttachments,
|
|
514
|
+
) {
|
|
253
515
|
for (const decl of body) {
|
|
254
516
|
if (decl.kind === 'anthology') {
|
|
255
517
|
const nodeId = makeNodeId(universeName, 'anthology', decl.name);
|
|
256
|
-
|
|
257
|
-
|
|
518
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
|
|
519
|
+
let actualParentNodeId = parentNodeId;
|
|
520
|
+
if (decl.parentName) {
|
|
521
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
522
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
523
|
+
actualParentNodeId = resolved.nodeId;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const node = createNode(nodeId, 'anthology', decl.name, actualParentNodeId, decl);
|
|
258
527
|
graph.nodes[nodeId] = node;
|
|
259
|
-
graph.nodes[
|
|
260
|
-
|
|
261
|
-
processBody(
|
|
528
|
+
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
529
|
+
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
530
|
+
processBody(
|
|
531
|
+
graph,
|
|
532
|
+
universeName,
|
|
533
|
+
nodeId,
|
|
534
|
+
decl.body,
|
|
535
|
+
scopeIndex,
|
|
536
|
+
file,
|
|
537
|
+
nodeId,
|
|
538
|
+
namedDocsMap,
|
|
539
|
+
reposMap,
|
|
540
|
+
refsMap,
|
|
541
|
+
entityKinds,
|
|
542
|
+
pendingReferenceDecls,
|
|
543
|
+
pendingReferenceAttachments,
|
|
544
|
+
);
|
|
262
545
|
} else if (decl.kind === 'series') {
|
|
263
546
|
const nodeId = makeNodeId(universeName, 'series', decl.name);
|
|
264
|
-
|
|
547
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
|
|
265
548
|
|
|
266
549
|
// Handle optional anthology parent
|
|
267
550
|
let actualParentNodeId = parentNodeId;
|
|
268
551
|
if (decl.parentName) {
|
|
269
|
-
const
|
|
270
|
-
if (
|
|
271
|
-
actualParentNodeId =
|
|
552
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
553
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
554
|
+
actualParentNodeId = resolved.nodeId;
|
|
272
555
|
}
|
|
273
|
-
// If
|
|
556
|
+
// If parent not found, fall back to parentNodeId (tolerant parsing)
|
|
274
557
|
}
|
|
275
558
|
|
|
276
559
|
const node = createNode(nodeId, 'series', decl.name, actualParentNodeId, decl);
|
|
277
560
|
graph.nodes[nodeId] = node;
|
|
278
561
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
279
|
-
|
|
280
|
-
processBody(
|
|
562
|
+
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
563
|
+
processBody(
|
|
564
|
+
graph,
|
|
565
|
+
universeName,
|
|
566
|
+
nodeId,
|
|
567
|
+
decl.body,
|
|
568
|
+
scopeIndex,
|
|
569
|
+
file,
|
|
570
|
+
nodeId,
|
|
571
|
+
namedDocsMap,
|
|
572
|
+
reposMap,
|
|
573
|
+
refsMap,
|
|
574
|
+
entityKinds,
|
|
575
|
+
pendingReferenceDecls,
|
|
576
|
+
pendingReferenceAttachments,
|
|
577
|
+
);
|
|
281
578
|
} else if (decl.kind === 'book') {
|
|
282
579
|
const nodeId = makeNodeId(universeName, 'book', decl.name);
|
|
283
|
-
|
|
284
|
-
const
|
|
580
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'book', decl.source);
|
|
581
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
582
|
+
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
285
583
|
// Always set container for book nodes (even if undefined when parentName doesn't resolve)
|
|
286
584
|
const node = createNode(
|
|
287
585
|
nodeId,
|
|
@@ -297,12 +595,27 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
297
595
|
} else {
|
|
298
596
|
graph.nodes[parentNodeId].children.push(nodeId);
|
|
299
597
|
}
|
|
300
|
-
|
|
301
|
-
processBody(
|
|
598
|
+
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
599
|
+
processBody(
|
|
600
|
+
graph,
|
|
601
|
+
universeName,
|
|
602
|
+
nodeId,
|
|
603
|
+
decl.body,
|
|
604
|
+
scopeIndex,
|
|
605
|
+
file,
|
|
606
|
+
nodeId,
|
|
607
|
+
namedDocsMap,
|
|
608
|
+
reposMap,
|
|
609
|
+
refsMap,
|
|
610
|
+
entityKinds,
|
|
611
|
+
pendingReferenceDecls,
|
|
612
|
+
pendingReferenceAttachments,
|
|
613
|
+
);
|
|
302
614
|
} else if (decl.kind === 'chapter') {
|
|
303
615
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
304
|
-
|
|
305
|
-
const
|
|
616
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
|
|
617
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
618
|
+
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
306
619
|
// Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
|
|
307
620
|
const node = createNode(
|
|
308
621
|
nodeId,
|
|
@@ -318,18 +631,32 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
318
631
|
} else {
|
|
319
632
|
graph.nodes[parentNodeId].children.push(nodeId);
|
|
320
633
|
}
|
|
321
|
-
|
|
322
|
-
processBody(
|
|
634
|
+
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
635
|
+
processBody(
|
|
636
|
+
graph,
|
|
637
|
+
universeName,
|
|
638
|
+
nodeId,
|
|
639
|
+
decl.body,
|
|
640
|
+
scopeIndex,
|
|
641
|
+
file,
|
|
642
|
+
nodeId,
|
|
643
|
+
namedDocsMap,
|
|
644
|
+
reposMap,
|
|
645
|
+
refsMap,
|
|
646
|
+
entityKinds,
|
|
647
|
+
pendingReferenceDecls,
|
|
648
|
+
pendingReferenceAttachments,
|
|
649
|
+
);
|
|
323
650
|
} else if (decl.kind === 'concept') {
|
|
324
651
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
325
|
-
|
|
652
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
|
|
326
653
|
|
|
327
654
|
// Handle optional parent
|
|
328
655
|
let actualParentNodeId = parentNodeId;
|
|
329
656
|
if (decl.parentName) {
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
actualParentNodeId =
|
|
657
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
658
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
659
|
+
actualParentNodeId = resolved.nodeId;
|
|
333
660
|
}
|
|
334
661
|
// If parent not found, fall back to parentNodeId (tolerant parsing)
|
|
335
662
|
}
|
|
@@ -337,8 +664,22 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
337
664
|
const node = createNode(nodeId, 'concept', decl.name, actualParentNodeId, decl);
|
|
338
665
|
graph.nodes[nodeId] = node;
|
|
339
666
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
340
|
-
|
|
341
|
-
processBody(
|
|
667
|
+
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
668
|
+
processBody(
|
|
669
|
+
graph,
|
|
670
|
+
universeName,
|
|
671
|
+
nodeId,
|
|
672
|
+
decl.body,
|
|
673
|
+
scopeIndex,
|
|
674
|
+
file,
|
|
675
|
+
nodeId,
|
|
676
|
+
namedDocsMap,
|
|
677
|
+
reposMap,
|
|
678
|
+
refsMap,
|
|
679
|
+
entityKinds,
|
|
680
|
+
pendingReferenceDecls,
|
|
681
|
+
pendingReferenceAttachments,
|
|
682
|
+
);
|
|
342
683
|
} else if (decl.kind === 'relates') {
|
|
343
684
|
// Check for duplicate relates in reverse order
|
|
344
685
|
checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
|
|
@@ -520,51 +861,12 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
520
861
|
// Title blocks are attached to their parent node
|
|
521
862
|
// This is handled in createNode
|
|
522
863
|
} else if (decl.kind === 'references') {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
for (const item of decl.references) {
|
|
530
|
-
if (item.kind === 'reference') {
|
|
531
|
-
// Inline reference block - convert as before
|
|
532
|
-
currentNode.references.push({
|
|
533
|
-
repository: item.repository,
|
|
534
|
-
paths: item.paths,
|
|
535
|
-
kind: item.referenceKind,
|
|
536
|
-
describe: item.describe
|
|
537
|
-
? {
|
|
538
|
-
raw: item.describe.raw,
|
|
539
|
-
normalized: normalizeProseBlock(item.describe.raw),
|
|
540
|
-
source: item.describe.source,
|
|
541
|
-
}
|
|
542
|
-
: undefined,
|
|
543
|
-
source: item.source,
|
|
544
|
-
});
|
|
545
|
-
} else if (item.kind === 'using-in-references') {
|
|
546
|
-
// Using block - resolve each name against named references registry
|
|
547
|
-
for (const name of item.names) {
|
|
548
|
-
const namedRef = graph.referencesByName[universeName]?.[name];
|
|
549
|
-
if (namedRef) {
|
|
550
|
-
// Resolved: expand to reference object
|
|
551
|
-
currentNode.references.push({
|
|
552
|
-
repository: namedRef.repository,
|
|
553
|
-
paths: namedRef.paths,
|
|
554
|
-
kind: namedRef.kind,
|
|
555
|
-
describe: namedRef.describe,
|
|
556
|
-
source: namedRef.source, // Use the named reference's source, not the using block's
|
|
557
|
-
});
|
|
558
|
-
} else {
|
|
559
|
-
// Not resolved: emit error diagnostic
|
|
560
|
-
graph.diagnostics.push({
|
|
561
|
-
severity: 'error',
|
|
562
|
-
message: `Unknown reference '${name}' used in references block`,
|
|
563
|
-
source: item.source,
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
864
|
+
if (pendingReferenceAttachments) {
|
|
865
|
+
pendingReferenceAttachments.push({
|
|
866
|
+
nodeId: currentNodeId,
|
|
867
|
+
items: decl.items,
|
|
868
|
+
universeName,
|
|
869
|
+
});
|
|
568
870
|
}
|
|
569
871
|
} else if (decl.kind === 'documentation') {
|
|
570
872
|
// DocumentationBlock - attach to current node
|
|
@@ -588,35 +890,75 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
588
890
|
source: doc.source,
|
|
589
891
|
});
|
|
590
892
|
}
|
|
591
|
-
} else if (decl.kind === '
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
graph.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
893
|
+
} else if (decl.kind === 'reference') {
|
|
894
|
+
if (refsMap && entityKinds && pendingReferenceDecls) {
|
|
895
|
+
const baseName = decl.name || deriveReferenceName(decl);
|
|
896
|
+
const uniqueName = decl.name
|
|
897
|
+
? baseName
|
|
898
|
+
: pickUniqueName(scopeIndex, currentNodeId, baseName);
|
|
899
|
+
const qualifiedName = makeQualifiedName(graph, currentNodeId, uniqueName);
|
|
900
|
+
const refId = makeEntityId(universeName, 'reference', qualifiedName);
|
|
901
|
+
if (decl.name) {
|
|
902
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, refId, 'reference', decl.source, entityKinds);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (!refsMap.has(refId)) {
|
|
906
|
+
refsMap.set(refId, decl);
|
|
907
|
+
entityKinds.set(refId, 'reference');
|
|
908
|
+
addNameToScope(graph, scopeIndex, currentNodeId, uniqueName, refId);
|
|
909
|
+
if (!graph.references[refId]) {
|
|
910
|
+
graph.references[refId] = {
|
|
911
|
+
id: refId,
|
|
912
|
+
name: uniqueName,
|
|
913
|
+
urls: [],
|
|
914
|
+
source: decl.source,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
pendingReferenceDecls.push({
|
|
918
|
+
refId,
|
|
919
|
+
decl,
|
|
920
|
+
universeName,
|
|
921
|
+
scopeNodeId: currentNodeId,
|
|
609
922
|
});
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
923
|
+
|
|
924
|
+
if (pendingReferenceAttachments && currentNodeId !== makeNodeId(universeName, 'universe', universeName)) {
|
|
925
|
+
pendingReferenceAttachments.push({
|
|
926
|
+
nodeId: currentNodeId,
|
|
927
|
+
items: [
|
|
928
|
+
{
|
|
929
|
+
name: uniqueName,
|
|
930
|
+
source: decl.source,
|
|
931
|
+
},
|
|
932
|
+
],
|
|
933
|
+
universeName,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
} else if (decl.kind === 'repository') {
|
|
939
|
+
// RepositoryDecl - store in universe-level registry
|
|
940
|
+
if (reposMap && entityKinds) {
|
|
941
|
+
const scopeNodeId = resolveContainerScope(graph, scopeIndex, currentNodeId, decl.parentName, decl.source);
|
|
942
|
+
const qualifiedName = makeQualifiedName(graph, scopeNodeId, decl.name);
|
|
943
|
+
const repoId = makeEntityId(universeName, 'repository', qualifiedName);
|
|
944
|
+
checkDuplicateInScope(graph, scopeIndex, scopeNodeId, decl.name, repoId, 'repository', decl.source, entityKinds);
|
|
945
|
+
|
|
946
|
+
if (!reposMap.has(repoId)) {
|
|
947
|
+
reposMap.set(repoId, decl);
|
|
948
|
+
entityKinds.set(repoId, 'repository');
|
|
949
|
+
addNameToScope(graph, scopeIndex, scopeNodeId, decl.name, repoId);
|
|
950
|
+
if (!decl.url) {
|
|
951
|
+
graph.diagnostics.push({
|
|
952
|
+
severity: 'error',
|
|
953
|
+
message: `Repository "${decl.name}" is missing url { '...' }`,
|
|
954
|
+
source: decl.source,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
graph.repositories[repoId] = {
|
|
958
|
+
id: repoId,
|
|
959
|
+
name: decl.name,
|
|
960
|
+
url: decl.url || '',
|
|
961
|
+
title: normalizeTitleValue(decl.title?.raw),
|
|
620
962
|
describe: decl.describe
|
|
621
963
|
? {
|
|
622
964
|
raw: decl.describe.raw,
|
|
@@ -624,34 +966,15 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
624
966
|
source: decl.describe.source,
|
|
625
967
|
}
|
|
626
968
|
: undefined,
|
|
969
|
+
note: decl.note
|
|
970
|
+
? {
|
|
971
|
+
raw: decl.note.raw,
|
|
972
|
+
normalized: normalizeProseBlock(decl.note.raw),
|
|
973
|
+
source: decl.note.source,
|
|
974
|
+
}
|
|
975
|
+
: undefined,
|
|
627
976
|
source: decl.source,
|
|
628
977
|
};
|
|
629
|
-
|
|
630
|
-
graph.referencesByName[universeName][refName] = refModel;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
} else if (decl.kind === 'repository') {
|
|
634
|
-
// RepositoryDecl - store in universe-level registry
|
|
635
|
-
// Only process at universe scope (when reposMap is provided)
|
|
636
|
-
if (reposMap && parentNodeId === currentNodeId) {
|
|
637
|
-
const repoName = decl.name;
|
|
638
|
-
|
|
639
|
-
// Check for duplicate name
|
|
640
|
-
if (reposMap.has(repoName)) {
|
|
641
|
-
const firstOccurrence = reposMap.get(repoName);
|
|
642
|
-
graph.diagnostics.push({
|
|
643
|
-
severity: 'error',
|
|
644
|
-
message: `Duplicate repository "${repoName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
|
|
645
|
-
source: decl.source,
|
|
646
|
-
});
|
|
647
|
-
graph.diagnostics.push({
|
|
648
|
-
severity: 'error',
|
|
649
|
-
message: `First declaration of repository "${repoName}" was here.`,
|
|
650
|
-
source: firstOccurrence.source,
|
|
651
|
-
});
|
|
652
|
-
} else {
|
|
653
|
-
// Store repository declaration
|
|
654
|
-
reposMap.set(repoName, decl);
|
|
655
978
|
}
|
|
656
979
|
}
|
|
657
980
|
} else if (decl.kind === 'named-document') {
|
|
@@ -718,7 +1041,7 @@ function processBody(graph, universeName, parentNodeId, body, nameMap, file, cur
|
|
|
718
1041
|
* @param {string} name
|
|
719
1042
|
* @param {string | undefined} parentNodeId
|
|
720
1043
|
* @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
|
|
721
|
-
* @param {string | undefined} containerNodeId
|
|
1044
|
+
* @param {string | undefined} [containerNodeId]
|
|
722
1045
|
* @returns {NodeModel}
|
|
723
1046
|
*/
|
|
724
1047
|
function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
@@ -766,26 +1089,37 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
766
1089
|
}
|
|
767
1090
|
|
|
768
1091
|
/**
|
|
769
|
-
* Checks for duplicate node names and emits
|
|
1092
|
+
* Checks for duplicate node names within a scope and emits error if found
|
|
770
1093
|
* @param {UniverseGraph} graph
|
|
771
|
-
* @param {string}
|
|
1094
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
1095
|
+
* @param {string} scopeNodeId - Container node ID (scope)
|
|
772
1096
|
* @param {string} name
|
|
773
1097
|
* @param {string} nodeId
|
|
774
1098
|
* @param {string} kind - The kind of the current node being checked
|
|
775
|
-
* @param {Map<string, string>} nameMap
|
|
776
1099
|
* @param {SourceSpan} source
|
|
1100
|
+
* @param {Map<string, string>} [entityKinds]
|
|
777
1101
|
*/
|
|
778
|
-
function
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1102
|
+
function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source, entityKinds) {
|
|
1103
|
+
const scopeMap = scopeIndex.get(scopeNodeId);
|
|
1104
|
+
if (scopeMap && scopeMap.has(name)) {
|
|
1105
|
+
const existingNodeIds = scopeMap.get(name);
|
|
1106
|
+
if (existingNodeIds.length > 0) {
|
|
1107
|
+
const firstNodeId = existingNodeIds[0];
|
|
1108
|
+
const firstNode = graph.nodes[firstNodeId];
|
|
1109
|
+
const firstKind = firstNode?.kind || entityKinds?.get(firstNodeId) || 'unknown';
|
|
1110
|
+
const scopeNode = graph.nodes[scopeNodeId];
|
|
1111
|
+
const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
|
|
1112
|
+
graph.diagnostics.push({
|
|
1113
|
+
severity: 'error',
|
|
1114
|
+
message: `Duplicate name "${name}" in scope "${scopeName}": already defined as ${firstKind}, now also defined as ${kind}`,
|
|
1115
|
+
source: source,
|
|
1116
|
+
});
|
|
1117
|
+
graph.diagnostics.push({
|
|
1118
|
+
severity: 'error',
|
|
1119
|
+
message: `First declaration of "${name}" was here.`,
|
|
1120
|
+
source: firstNode.source,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
789
1123
|
}
|
|
790
1124
|
}
|
|
791
1125
|
|
|
@@ -812,9 +1146,9 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
|
|
|
812
1146
|
/**
|
|
813
1147
|
* Resolves edge endpoint references and relates node endpoints
|
|
814
1148
|
* @param {UniverseGraph} graph
|
|
815
|
-
* @param {Map<string, Map<string, string>>}
|
|
1149
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
816
1150
|
*/
|
|
817
|
-
function resolveEdges(graph,
|
|
1151
|
+
function resolveEdges(graph, scopeIndex) {
|
|
818
1152
|
// Build a set of relates node names to avoid duplicate warnings
|
|
819
1153
|
// (edges are created for backward compatibility alongside relates nodes)
|
|
820
1154
|
const relatesNodeNames = new Set();
|
|
@@ -829,34 +1163,36 @@ function resolveEdges(graph, nameToNodeId) {
|
|
|
829
1163
|
for (const edgeId in graph.edges) {
|
|
830
1164
|
const edge = graph.edges[edgeId];
|
|
831
1165
|
const universeName = edgeId.split(':')[0];
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
if
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1166
|
+
const universeNodeId = `${universeName}:universe:${universeName}`;
|
|
1167
|
+
|
|
1168
|
+
// Check if this edge corresponds to a relates node
|
|
1169
|
+
// If so, skip warnings here (they'll be generated during relates node resolution)
|
|
1170
|
+
const edgeName = `${edge.a.text} and ${edge.b.text}`;
|
|
1171
|
+
const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
|
|
1172
|
+
const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
|
|
1173
|
+
|
|
1174
|
+
// Resolve endpoint A - start from universe scope
|
|
1175
|
+
const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
|
|
1176
|
+
if (resolvedA.nodeId && !resolvedA.ambiguous) {
|
|
1177
|
+
edge.a.target = resolvedA.nodeId;
|
|
1178
|
+
} else if (!hasRelatesNode) {
|
|
1179
|
+
// Only warn if there's no corresponding relates node (legacy edge-only format)
|
|
1180
|
+
if (!resolvedA.ambiguous) {
|
|
847
1181
|
graph.diagnostics.push({
|
|
848
1182
|
severity: 'warning',
|
|
849
1183
|
message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
|
|
850
1184
|
source: edge.source,
|
|
851
1185
|
});
|
|
852
1186
|
}
|
|
1187
|
+
}
|
|
853
1188
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
1189
|
+
// Resolve endpoint B - start from universe scope
|
|
1190
|
+
const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
|
|
1191
|
+
if (resolvedB.nodeId && !resolvedB.ambiguous) {
|
|
1192
|
+
edge.b.target = resolvedB.nodeId;
|
|
1193
|
+
} else if (!hasRelatesNode) {
|
|
1194
|
+
// Only warn if there's no corresponding relates node (legacy edge-only format)
|
|
1195
|
+
if (!resolvedB.ambiguous) {
|
|
860
1196
|
graph.diagnostics.push({
|
|
861
1197
|
severity: 'warning',
|
|
862
1198
|
message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
|
|
@@ -871,18 +1207,21 @@ function resolveEdges(graph, nameToNodeId) {
|
|
|
871
1207
|
const node = graph.nodes[nodeId];
|
|
872
1208
|
if (node.kind === 'relates' && node.unresolvedEndpoints) {
|
|
873
1209
|
const universeName = nodeId.split(':')[0];
|
|
874
|
-
const
|
|
1210
|
+
const universeNodeId = `${universeName}:universe:${universeName}`;
|
|
1211
|
+
|
|
1212
|
+
// Determine the scope where this relates block was declared (parent of relates node)
|
|
1213
|
+
const relatesScope = node.parent || universeNodeId;
|
|
875
1214
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const unresolved = [];
|
|
1215
|
+
const resolvedEndpoints = [];
|
|
1216
|
+
const unresolved = [];
|
|
879
1217
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1218
|
+
for (const endpointName of node.unresolvedEndpoints) {
|
|
1219
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1220
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1221
|
+
resolvedEndpoints.push(resolved.nodeId);
|
|
1222
|
+
} else {
|
|
1223
|
+
unresolved.push(endpointName);
|
|
1224
|
+
if (!resolved.ambiguous) {
|
|
886
1225
|
graph.diagnostics.push({
|
|
887
1226
|
severity: 'warning',
|
|
888
1227
|
message: `Unresolved relates endpoint "${endpointName}" in universe "${universeName}"`,
|
|
@@ -890,31 +1229,345 @@ function resolveEdges(graph, nameToNodeId) {
|
|
|
890
1229
|
});
|
|
891
1230
|
}
|
|
892
1231
|
}
|
|
1232
|
+
}
|
|
893
1233
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1234
|
+
node.endpoints = resolvedEndpoints;
|
|
1235
|
+
if (unresolved.length > 0) {
|
|
1236
|
+
node.unresolvedEndpoints = unresolved;
|
|
1237
|
+
} else {
|
|
1238
|
+
delete node.unresolvedEndpoints;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Resolve from blocks: convert from endpoint names to node IDs
|
|
1242
|
+
if (node.from) {
|
|
1243
|
+
const resolvedFrom = {};
|
|
1244
|
+
for (const endpointName in node.from) {
|
|
1245
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1246
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1247
|
+
resolvedFrom[resolved.nodeId] = node.from[endpointName];
|
|
1248
|
+
} else {
|
|
1249
|
+
// Keep unresolved from blocks keyed by name
|
|
1250
|
+
resolvedFrom[endpointName] = node.from[endpointName];
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
node.from = resolvedFrom;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* @param {string | undefined} raw
|
|
1261
|
+
* @returns {string | undefined}
|
|
1262
|
+
*/
|
|
1263
|
+
function normalizeTitleValue(raw) {
|
|
1264
|
+
if (!raw) return undefined;
|
|
1265
|
+
const trimmed = raw.trim();
|
|
1266
|
+
if (!trimmed) return undefined;
|
|
1267
|
+
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
|
1268
|
+
return unquoted.trim() || undefined;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* @param {UniverseGraph} graph
|
|
1273
|
+
* @param {Array<{refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string}>} pending
|
|
1274
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1275
|
+
*/
|
|
1276
|
+
function resolveReferenceDecls(graph, pending, scopeIndex) {
|
|
1277
|
+
/** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
|
|
1278
|
+
const unknownRepoCounts = new Map();
|
|
1279
|
+
for (const item of pending) {
|
|
1280
|
+
const { refId, decl, scopeNodeId } = item;
|
|
1281
|
+
const model = graph.references[refId] || { id: refId, name: decl.name, urls: [], source: decl.source };
|
|
1282
|
+
const displayName = model.name || decl.name || 'unnamed reference';
|
|
1283
|
+
let urls = [];
|
|
1284
|
+
let repositoryRef = undefined;
|
|
1285
|
+
|
|
1286
|
+
if (decl.url && decl.repositoryName) {
|
|
1287
|
+
graph.diagnostics.push({
|
|
1288
|
+
severity: 'error',
|
|
1289
|
+
message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
|
|
1290
|
+
source: diagnosticSource(decl.source),
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (decl.url) {
|
|
1295
|
+
urls = [decl.url];
|
|
1296
|
+
} else if (decl.repositoryName) {
|
|
1297
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.repositoryName, scopeNodeId, decl.source);
|
|
1298
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1299
|
+
const repo = graph.repositories[resolved.nodeId];
|
|
1300
|
+
if (!repo) {
|
|
1301
|
+
graph.diagnostics.push({
|
|
1302
|
+
severity: 'error',
|
|
1303
|
+
message: `Reference "${displayName}" uses "${decl.repositoryName}" which is not a repository`,
|
|
1304
|
+
source: diagnosticSource(decl.source),
|
|
1305
|
+
});
|
|
1306
|
+
} else if (!repo.url) {
|
|
1307
|
+
graph.diagnostics.push({
|
|
1308
|
+
severity: 'error',
|
|
1309
|
+
message: `Repository "${repo.name}" is missing url { '...' }`,
|
|
1310
|
+
source: decl.source,
|
|
1311
|
+
});
|
|
897
1312
|
} else {
|
|
898
|
-
|
|
1313
|
+
repositoryRef = repo.id;
|
|
1314
|
+
if (decl.paths && decl.paths.length > 0) {
|
|
1315
|
+
urls = decl.paths.map((path) => joinRepositoryUrl(repo.url, path));
|
|
1316
|
+
} else if (decl.paths && decl.paths.length === 0) {
|
|
1317
|
+
graph.diagnostics.push({
|
|
1318
|
+
severity: 'error',
|
|
1319
|
+
message: `Reference "${displayName}" has an empty paths block`,
|
|
1320
|
+
source: diagnosticSource(decl.source),
|
|
1321
|
+
});
|
|
1322
|
+
} else {
|
|
1323
|
+
urls = [repo.url];
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
} else if (!resolved.ambiguous) {
|
|
1327
|
+
const entry = unknownRepoCounts.get(decl.repositoryName) || {
|
|
1328
|
+
count: 0,
|
|
1329
|
+
source: decl.source,
|
|
1330
|
+
};
|
|
1331
|
+
entry.count += 1;
|
|
1332
|
+
if (!entry.source && decl.source) {
|
|
1333
|
+
entry.source = decl.source;
|
|
1334
|
+
}
|
|
1335
|
+
unknownRepoCounts.set(decl.repositoryName, entry);
|
|
1336
|
+
}
|
|
1337
|
+
} else {
|
|
1338
|
+
graph.diagnostics.push({
|
|
1339
|
+
severity: 'error',
|
|
1340
|
+
message: `Reference "${displayName}" must have url { ... } or in <Repository>`,
|
|
1341
|
+
source: diagnosticSource(decl.source),
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
model.name = decl.name || model.name || displayName;
|
|
1346
|
+
model.kind = decl.referenceKind || undefined;
|
|
1347
|
+
model.title = normalizeTitleValue(decl.title?.raw);
|
|
1348
|
+
model.describe = decl.describe
|
|
1349
|
+
? {
|
|
1350
|
+
raw: decl.describe.raw,
|
|
1351
|
+
normalized: normalizeProseBlock(decl.describe.raw),
|
|
1352
|
+
source: decl.describe.source,
|
|
1353
|
+
}
|
|
1354
|
+
: undefined;
|
|
1355
|
+
model.note = decl.note
|
|
1356
|
+
? {
|
|
1357
|
+
raw: decl.note.raw,
|
|
1358
|
+
normalized: normalizeProseBlock(decl.note.raw),
|
|
1359
|
+
source: decl.note.source,
|
|
899
1360
|
}
|
|
1361
|
+
: undefined;
|
|
1362
|
+
model.urls = urls;
|
|
1363
|
+
model.repositoryRef = repositoryRef;
|
|
1364
|
+
model.paths = decl.paths && decl.paths.length > 0 ? decl.paths : undefined;
|
|
1365
|
+
model.source = decl.source;
|
|
1366
|
+
graph.references[refId] = model;
|
|
1367
|
+
}
|
|
900
1368
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1369
|
+
for (const [repoName, info] of unknownRepoCounts.entries()) {
|
|
1370
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1371
|
+
graph.diagnostics.push({
|
|
1372
|
+
severity: 'error',
|
|
1373
|
+
message: `Unknown repository "${repoName}" used by references${countText}.`,
|
|
1374
|
+
source: diagnosticSource(info.source),
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* @param {UniverseGraph} graph
|
|
1381
|
+
* @param {Array<{nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string}>} pending
|
|
1382
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1383
|
+
*/
|
|
1384
|
+
function resolveReferenceAttachments(graph, pending, scopeIndex) {
|
|
1385
|
+
/** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
|
|
1386
|
+
const unknownReferenceCounts = new Map();
|
|
1387
|
+
|
|
1388
|
+
for (const item of pending) {
|
|
1389
|
+
const node = graph.nodes[item.nodeId];
|
|
1390
|
+
if (!node) {
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (!node.references) {
|
|
1394
|
+
node.references = [];
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
for (const entry of item.items) {
|
|
1398
|
+
const name = entry.name;
|
|
1399
|
+
const resolved = resolveNameInScope(graph, scopeIndex, name, item.nodeId, entry.source);
|
|
1400
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1401
|
+
if (graph.references[resolved.nodeId]) {
|
|
1402
|
+
node.references.push(resolved.nodeId);
|
|
1403
|
+
} else {
|
|
1404
|
+
const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
|
|
1405
|
+
existing.count += 1;
|
|
1406
|
+
if (!existing.source && entry.source) {
|
|
1407
|
+
existing.source = entry.source;
|
|
912
1408
|
}
|
|
913
|
-
|
|
1409
|
+
unknownReferenceCounts.set(name, existing);
|
|
1410
|
+
}
|
|
1411
|
+
} else if (!resolved.ambiguous) {
|
|
1412
|
+
const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
|
|
1413
|
+
existing.count += 1;
|
|
1414
|
+
if (!existing.source && entry.source) {
|
|
1415
|
+
existing.source = entry.source;
|
|
914
1416
|
}
|
|
1417
|
+
unknownReferenceCounts.set(name, existing);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
for (const [name, info] of unknownReferenceCounts.entries()) {
|
|
1423
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1424
|
+
graph.diagnostics.push({
|
|
1425
|
+
severity: 'error',
|
|
1426
|
+
message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
|
|
1427
|
+
source: diagnosticSource(info.source),
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* @param {SourceSpan | undefined} source
|
|
1434
|
+
* @returns {SourceSpan | { file: string, line?: number, col?: number, start?: any, end?: any } | undefined}
|
|
1435
|
+
*/
|
|
1436
|
+
function diagnosticSource(source) {
|
|
1437
|
+
if (!source) return undefined;
|
|
1438
|
+
return {
|
|
1439
|
+
file: source.file,
|
|
1440
|
+
line: source.start?.line,
|
|
1441
|
+
col: source.start?.col,
|
|
1442
|
+
start: source.start,
|
|
1443
|
+
end: source.end,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* @param {string} base
|
|
1449
|
+
* @param {string} path
|
|
1450
|
+
* @returns {string}
|
|
1451
|
+
*/
|
|
1452
|
+
function joinRepositoryUrl(base, path) {
|
|
1453
|
+
if (base.endsWith('/') && path.startsWith('/')) {
|
|
1454
|
+
return base + path.slice(1);
|
|
1455
|
+
}
|
|
1456
|
+
if (!base.endsWith('/') && !path.startsWith('/')) {
|
|
1457
|
+
return `${base}/${path}`;
|
|
1458
|
+
}
|
|
1459
|
+
return base + path;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* @param {UniverseGraph} graph
|
|
1464
|
+
* @param {string} containerNodeId
|
|
1465
|
+
* @param {string} name
|
|
1466
|
+
* @returns {string}
|
|
1467
|
+
*/
|
|
1468
|
+
function makeQualifiedName(graph, containerNodeId, name) {
|
|
1469
|
+
const path = [];
|
|
1470
|
+
let current = containerNodeId;
|
|
1471
|
+
const visited = new Set();
|
|
1472
|
+
while (current && !visited.has(current)) {
|
|
1473
|
+
visited.add(current);
|
|
1474
|
+
const node = graph.nodes[current];
|
|
1475
|
+
if (!node) break;
|
|
1476
|
+
if (node.kind !== 'universe') {
|
|
1477
|
+
path.push(node.name);
|
|
1478
|
+
}
|
|
1479
|
+
current = node.parent;
|
|
1480
|
+
}
|
|
1481
|
+
path.reverse();
|
|
1482
|
+
path.push(name);
|
|
1483
|
+
return path.join('.');
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* @param {string} universeName
|
|
1488
|
+
* @param {string} kind
|
|
1489
|
+
* @param {string} qualifiedName
|
|
1490
|
+
* @returns {string}
|
|
1491
|
+
*/
|
|
1492
|
+
function makeEntityId(universeName, kind, qualifiedName) {
|
|
1493
|
+
return `${universeName}:${kind}:${qualifiedName}`;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/**
|
|
1497
|
+
* @param {UniverseGraph} graph
|
|
1498
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1499
|
+
* @param {string} currentNodeId
|
|
1500
|
+
* @param {string | undefined} parentName
|
|
1501
|
+
* @param {SourceSpan} source
|
|
1502
|
+
* @returns {string}
|
|
1503
|
+
*/
|
|
1504
|
+
function resolveContainerScope(graph, scopeIndex, currentNodeId, parentName, source) {
|
|
1505
|
+
if (!parentName) {
|
|
1506
|
+
return currentNodeId;
|
|
1507
|
+
}
|
|
1508
|
+
const resolved = resolveNameInScope(graph, scopeIndex, parentName, currentNodeId, source);
|
|
1509
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1510
|
+
return resolved.nodeId;
|
|
1511
|
+
}
|
|
1512
|
+
return currentNodeId;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1517
|
+
* @param {string} scopeNodeId
|
|
1518
|
+
* @param {string} baseName
|
|
1519
|
+
* @returns {string}
|
|
1520
|
+
*/
|
|
1521
|
+
function pickUniqueName(scopeIndex, scopeNodeId, baseName) {
|
|
1522
|
+
const scopeMap = scopeIndex.get(scopeNodeId);
|
|
1523
|
+
if (!scopeMap || !scopeMap.has(baseName)) {
|
|
1524
|
+
return baseName;
|
|
1525
|
+
}
|
|
1526
|
+
let suffix = 2;
|
|
1527
|
+
let candidate = `${baseName}-${suffix}`;
|
|
1528
|
+
while (scopeMap.has(candidate)) {
|
|
1529
|
+
suffix += 1;
|
|
1530
|
+
candidate = `${baseName}-${suffix}`;
|
|
1531
|
+
}
|
|
1532
|
+
return candidate;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* @param {ReferenceDecl} decl
|
|
1537
|
+
* @returns {string}
|
|
1538
|
+
*/
|
|
1539
|
+
function deriveReferenceName(decl) {
|
|
1540
|
+
const title = normalizeTitleValue(decl.title?.raw);
|
|
1541
|
+
if (title) {
|
|
1542
|
+
return title;
|
|
1543
|
+
}
|
|
1544
|
+
if (decl.paths && decl.paths.length > 0) {
|
|
1545
|
+
const rawPath = decl.paths[0];
|
|
1546
|
+
const trimmed = rawPath.replace(/\/+$/, '');
|
|
1547
|
+
const parts = trimmed.split('/').filter(Boolean);
|
|
1548
|
+
if (parts.length > 0) {
|
|
1549
|
+
const lastPart = parts[parts.length - 1];
|
|
1550
|
+
const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
|
|
1551
|
+
return withoutExt.replace(/\./g, '-');
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (decl.url) {
|
|
1555
|
+
try {
|
|
1556
|
+
const parsed = new URL(decl.url);
|
|
1557
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
1558
|
+
if (segments.length > 0) {
|
|
1559
|
+
const lastSegment = segments[segments.length - 1];
|
|
1560
|
+
const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
|
|
1561
|
+
return withoutExt.replace(/\./g, '-');
|
|
1562
|
+
}
|
|
1563
|
+
if (parsed.hostname) {
|
|
1564
|
+
return parsed.hostname.replace(/\./g, '-');
|
|
915
1565
|
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
// Ignore malformed URLs here; validation handles required fields
|
|
916
1568
|
}
|
|
917
1569
|
}
|
|
1570
|
+
return 'reference';
|
|
918
1571
|
}
|
|
919
1572
|
|
|
920
1573
|
/**
|