@sprig-and-prose/sprig-universe 0.2.0 → 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 +635 -147
- package/src/ir.js +21 -6
- package/src/parser.js +225 -215
- 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
|
|
@@ -52,20 +50,30 @@ export function buildGraph(fileASTs) {
|
|
|
52
50
|
nodes: {},
|
|
53
51
|
edges: {},
|
|
54
52
|
diagnostics: [],
|
|
55
|
-
referencesByName: {},
|
|
56
53
|
documentsByName: {},
|
|
54
|
+
repositories: {},
|
|
55
|
+
references: {},
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
// Track node names within each scope (container) for scoped resolution
|
|
60
59
|
// Map<containerNodeId, Map<name, nodeId[]>> - allows multiple nodes with same name in different scopes
|
|
61
60
|
const scopeIndex = new Map(); // containerNodeId -> Map<name, nodeId[]>
|
|
62
61
|
|
|
63
|
-
// Track named
|
|
64
|
-
const namedReferencesByUniverse = new Map(); // universeName -> Map<name, { source }>
|
|
62
|
+
// Track named documents per universe for duplicate detection
|
|
65
63
|
const namedDocumentsByUniverse = new Map(); // universeName -> Map<name, { source }>
|
|
66
64
|
|
|
67
|
-
// Track repositories per universe
|
|
68
|
-
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 = [];
|
|
69
77
|
|
|
70
78
|
// First pass: collect all universe names with their file locations for validation
|
|
71
79
|
const universeNameToFiles = new Map(); // universeName -> Set<file>
|
|
@@ -107,11 +115,6 @@ export function buildGraph(fileASTs) {
|
|
|
107
115
|
};
|
|
108
116
|
}
|
|
109
117
|
|
|
110
|
-
// Initialize tracking maps for named references and documents (only once per universe)
|
|
111
|
-
if (!namedReferencesByUniverse.has(universeName)) {
|
|
112
|
-
namedReferencesByUniverse.set(universeName, new Map());
|
|
113
|
-
graph.referencesByName[universeName] = {};
|
|
114
|
-
}
|
|
115
118
|
if (!namedDocumentsByUniverse.has(universeName)) {
|
|
116
119
|
namedDocumentsByUniverse.set(universeName, new Map());
|
|
117
120
|
graph.documentsByName[universeName] = {};
|
|
@@ -119,6 +122,9 @@ export function buildGraph(fileASTs) {
|
|
|
119
122
|
if (!repositoriesByUniverse.has(universeName)) {
|
|
120
123
|
repositoriesByUniverse.set(universeName, new Map());
|
|
121
124
|
}
|
|
125
|
+
if (!referencesByUniverse.has(universeName)) {
|
|
126
|
+
referencesByUniverse.set(universeName, new Map());
|
|
127
|
+
}
|
|
122
128
|
|
|
123
129
|
// Check if universe node already exists (from a previous file)
|
|
124
130
|
const existingUniverseNode = graph.nodes[universeNodeId];
|
|
@@ -177,9 +183,12 @@ export function buildGraph(fileASTs) {
|
|
|
177
183
|
scopeIndex,
|
|
178
184
|
fileAST.file,
|
|
179
185
|
universeNodeId,
|
|
180
|
-
namedReferencesByUniverse.get(universeName),
|
|
181
186
|
namedDocumentsByUniverse.get(universeName),
|
|
182
187
|
repositoriesByUniverse.get(universeName),
|
|
188
|
+
referencesByUniverse.get(universeName),
|
|
189
|
+
entityKinds,
|
|
190
|
+
pendingReferenceDecls,
|
|
191
|
+
pendingReferenceAttachments,
|
|
183
192
|
);
|
|
184
193
|
} else {
|
|
185
194
|
// First time seeing this universe - create node and process body
|
|
@@ -203,34 +212,77 @@ export function buildGraph(fileASTs) {
|
|
|
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
|
-
|
|
224
|
-
|
|
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
|
+
}
|
|
225
270
|
}
|
|
226
|
-
|
|
227
|
-
graph.repositories[repoName] = {
|
|
228
|
-
kind: kindValue,
|
|
229
|
-
options: repoDecl.options,
|
|
230
|
-
};
|
|
231
271
|
}
|
|
232
272
|
}
|
|
233
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
|
+
|
|
234
286
|
// Resolve edge endpoints
|
|
235
287
|
resolveEdges(graph, scopeIndex);
|
|
236
288
|
|
|
@@ -384,29 +436,112 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
|
384
436
|
return { nodeId: null, ambiguous: false, ambiguousNodes: [] };
|
|
385
437
|
}
|
|
386
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
|
+
|
|
387
484
|
/**
|
|
388
485
|
* Processes a body of declarations, creating nodes and edges
|
|
389
486
|
* @param {UniverseGraph} graph
|
|
390
487
|
* @param {string} universeName
|
|
391
488
|
* @param {string} parentNodeId - Parent node ID (for tree relationships)
|
|
392
|
-
|
|
489
|
+
* @param {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body
|
|
393
490
|
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index: containerNodeId -> Map<name, nodeId[]>
|
|
394
491
|
* @param {string} file
|
|
395
492
|
* @param {string} currentNodeId - Current node ID (for attaching UnknownBlocks)
|
|
396
|
-
* @param {Map<string, { source: SourceSpan }>} [namedRefsMap] - Map for tracking named references (universe scope only)
|
|
397
493
|
* @param {Map<string, { source: SourceSpan }>} [namedDocsMap] - Map for tracking named documents (universe scope only)
|
|
398
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
|
|
399
499
|
*/
|
|
400
|
-
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
|
+
) {
|
|
401
515
|
for (const decl of body) {
|
|
402
516
|
if (decl.kind === 'anthology') {
|
|
403
517
|
const nodeId = makeNodeId(universeName, 'anthology', decl.name);
|
|
404
518
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
|
|
405
|
-
|
|
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);
|
|
406
527
|
graph.nodes[nodeId] = node;
|
|
407
|
-
graph.nodes[
|
|
528
|
+
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
408
529
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
409
|
-
processBody(
|
|
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
|
+
);
|
|
410
545
|
} else if (decl.kind === 'series') {
|
|
411
546
|
const nodeId = makeNodeId(universeName, 'series', decl.name);
|
|
412
547
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'series', decl.source);
|
|
@@ -425,7 +560,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
425
560
|
graph.nodes[nodeId] = node;
|
|
426
561
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
427
562
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
428
|
-
processBody(
|
|
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
|
+
);
|
|
429
578
|
} else if (decl.kind === 'book') {
|
|
430
579
|
const nodeId = makeNodeId(universeName, 'book', decl.name);
|
|
431
580
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'book', decl.source);
|
|
@@ -447,7 +596,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
447
596
|
graph.nodes[parentNodeId].children.push(nodeId);
|
|
448
597
|
}
|
|
449
598
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
450
|
-
processBody(
|
|
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
|
+
);
|
|
451
614
|
} else if (decl.kind === 'chapter') {
|
|
452
615
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
453
616
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
|
|
@@ -469,7 +632,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
469
632
|
graph.nodes[parentNodeId].children.push(nodeId);
|
|
470
633
|
}
|
|
471
634
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
472
|
-
processBody(
|
|
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
|
+
);
|
|
473
650
|
} else if (decl.kind === 'concept') {
|
|
474
651
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
475
652
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
|
|
@@ -488,7 +665,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
488
665
|
graph.nodes[nodeId] = node;
|
|
489
666
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
490
667
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
491
|
-
processBody(
|
|
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
|
+
);
|
|
492
683
|
} else if (decl.kind === 'relates') {
|
|
493
684
|
// Check for duplicate relates in reverse order
|
|
494
685
|
checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
|
|
@@ -670,51 +861,12 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
670
861
|
// Title blocks are attached to their parent node
|
|
671
862
|
// This is handled in createNode
|
|
672
863
|
} else if (decl.kind === 'references') {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
for (const item of decl.references) {
|
|
680
|
-
if (item.kind === 'reference') {
|
|
681
|
-
// Inline reference block - convert as before
|
|
682
|
-
currentNode.references.push({
|
|
683
|
-
repository: item.repository,
|
|
684
|
-
paths: item.paths,
|
|
685
|
-
kind: item.referenceKind,
|
|
686
|
-
describe: item.describe
|
|
687
|
-
? {
|
|
688
|
-
raw: item.describe.raw,
|
|
689
|
-
normalized: normalizeProseBlock(item.describe.raw),
|
|
690
|
-
source: item.describe.source,
|
|
691
|
-
}
|
|
692
|
-
: undefined,
|
|
693
|
-
source: item.source,
|
|
694
|
-
});
|
|
695
|
-
} else if (item.kind === 'using-in-references') {
|
|
696
|
-
// Using block - resolve each name against named references registry
|
|
697
|
-
for (const name of item.names) {
|
|
698
|
-
const namedRef = graph.referencesByName[universeName]?.[name];
|
|
699
|
-
if (namedRef) {
|
|
700
|
-
// Resolved: expand to reference object
|
|
701
|
-
currentNode.references.push({
|
|
702
|
-
repository: namedRef.repository,
|
|
703
|
-
paths: namedRef.paths,
|
|
704
|
-
kind: namedRef.kind,
|
|
705
|
-
describe: namedRef.describe,
|
|
706
|
-
source: namedRef.source, // Use the named reference's source, not the using block's
|
|
707
|
-
});
|
|
708
|
-
} else {
|
|
709
|
-
// Not resolved: emit error diagnostic
|
|
710
|
-
graph.diagnostics.push({
|
|
711
|
-
severity: 'error',
|
|
712
|
-
message: `Unknown reference '${name}' used in references block`,
|
|
713
|
-
source: item.source,
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
}
|
|
864
|
+
if (pendingReferenceAttachments) {
|
|
865
|
+
pendingReferenceAttachments.push({
|
|
866
|
+
nodeId: currentNodeId,
|
|
867
|
+
items: decl.items,
|
|
868
|
+
universeName,
|
|
869
|
+
});
|
|
718
870
|
}
|
|
719
871
|
} else if (decl.kind === 'documentation') {
|
|
720
872
|
// DocumentationBlock - attach to current node
|
|
@@ -738,35 +890,75 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
738
890
|
source: doc.source,
|
|
739
891
|
});
|
|
740
892
|
}
|
|
741
|
-
} else if (decl.kind === '
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
graph.
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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,
|
|
759
922
|
});
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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),
|
|
770
962
|
describe: decl.describe
|
|
771
963
|
? {
|
|
772
964
|
raw: decl.describe.raw,
|
|
@@ -774,34 +966,15 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
774
966
|
source: decl.describe.source,
|
|
775
967
|
}
|
|
776
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,
|
|
777
976
|
source: decl.source,
|
|
778
977
|
};
|
|
779
|
-
|
|
780
|
-
graph.referencesByName[universeName][refName] = refModel;
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
} else if (decl.kind === 'repository') {
|
|
784
|
-
// RepositoryDecl - store in universe-level registry
|
|
785
|
-
// Only process at universe scope (when reposMap is provided)
|
|
786
|
-
if (reposMap && parentNodeId === currentNodeId) {
|
|
787
|
-
const repoName = decl.name;
|
|
788
|
-
|
|
789
|
-
// Check for duplicate name
|
|
790
|
-
if (reposMap.has(repoName)) {
|
|
791
|
-
const firstOccurrence = reposMap.get(repoName);
|
|
792
|
-
graph.diagnostics.push({
|
|
793
|
-
severity: 'error',
|
|
794
|
-
message: `Duplicate repository "${repoName}" in universe "${universeName}". First declared at ${firstOccurrence.source.file}:${firstOccurrence.source.start.line}:${firstOccurrence.source.start.col}`,
|
|
795
|
-
source: decl.source,
|
|
796
|
-
});
|
|
797
|
-
graph.diagnostics.push({
|
|
798
|
-
severity: 'error',
|
|
799
|
-
message: `First declaration of repository "${repoName}" was here.`,
|
|
800
|
-
source: firstOccurrence.source,
|
|
801
|
-
});
|
|
802
|
-
} else {
|
|
803
|
-
// Store repository declaration
|
|
804
|
-
reposMap.set(repoName, decl);
|
|
805
978
|
}
|
|
806
979
|
}
|
|
807
980
|
} else if (decl.kind === 'named-document') {
|
|
@@ -868,7 +1041,7 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
868
1041
|
* @param {string} name
|
|
869
1042
|
* @param {string | undefined} parentNodeId
|
|
870
1043
|
* @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
|
|
871
|
-
* @param {string | undefined} containerNodeId
|
|
1044
|
+
* @param {string | undefined} [containerNodeId]
|
|
872
1045
|
* @returns {NodeModel}
|
|
873
1046
|
*/
|
|
874
1047
|
function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
@@ -924,15 +1097,16 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
924
1097
|
* @param {string} nodeId
|
|
925
1098
|
* @param {string} kind - The kind of the current node being checked
|
|
926
1099
|
* @param {SourceSpan} source
|
|
1100
|
+
* @param {Map<string, string>} [entityKinds]
|
|
927
1101
|
*/
|
|
928
|
-
function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source) {
|
|
1102
|
+
function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source, entityKinds) {
|
|
929
1103
|
const scopeMap = scopeIndex.get(scopeNodeId);
|
|
930
1104
|
if (scopeMap && scopeMap.has(name)) {
|
|
931
1105
|
const existingNodeIds = scopeMap.get(name);
|
|
932
1106
|
if (existingNodeIds.length > 0) {
|
|
933
1107
|
const firstNodeId = existingNodeIds[0];
|
|
934
1108
|
const firstNode = graph.nodes[firstNodeId];
|
|
935
|
-
const firstKind = firstNode?.kind || 'unknown';
|
|
1109
|
+
const firstKind = firstNode?.kind || entityKinds?.get(firstNodeId) || 'unknown';
|
|
936
1110
|
const scopeNode = graph.nodes[scopeNodeId];
|
|
937
1111
|
const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
|
|
938
1112
|
graph.diagnostics.push({
|
|
@@ -998,7 +1172,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
998
1172
|
const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
|
|
999
1173
|
|
|
1000
1174
|
// Resolve endpoint A - start from universe scope
|
|
1001
|
-
const resolvedA =
|
|
1175
|
+
const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
|
|
1002
1176
|
if (resolvedA.nodeId && !resolvedA.ambiguous) {
|
|
1003
1177
|
edge.a.target = resolvedA.nodeId;
|
|
1004
1178
|
} else if (!hasRelatesNode) {
|
|
@@ -1013,7 +1187,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1013
1187
|
}
|
|
1014
1188
|
|
|
1015
1189
|
// Resolve endpoint B - start from universe scope
|
|
1016
|
-
const resolvedB =
|
|
1190
|
+
const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
|
|
1017
1191
|
if (resolvedB.nodeId && !resolvedB.ambiguous) {
|
|
1018
1192
|
edge.b.target = resolvedB.nodeId;
|
|
1019
1193
|
} else if (!hasRelatesNode) {
|
|
@@ -1042,7 +1216,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1042
1216
|
const unresolved = [];
|
|
1043
1217
|
|
|
1044
1218
|
for (const endpointName of node.unresolvedEndpoints) {
|
|
1045
|
-
const resolved =
|
|
1219
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1046
1220
|
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1047
1221
|
resolvedEndpoints.push(resolved.nodeId);
|
|
1048
1222
|
} else {
|
|
@@ -1068,7 +1242,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1068
1242
|
if (node.from) {
|
|
1069
1243
|
const resolvedFrom = {};
|
|
1070
1244
|
for (const endpointName in node.from) {
|
|
1071
|
-
const resolved =
|
|
1245
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1072
1246
|
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1073
1247
|
resolvedFrom[resolved.nodeId] = node.from[endpointName];
|
|
1074
1248
|
} else {
|
|
@@ -1082,6 +1256,320 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1082
1256
|
}
|
|
1083
1257
|
}
|
|
1084
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
|
+
});
|
|
1312
|
+
} else {
|
|
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,
|
|
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
|
+
}
|
|
1368
|
+
|
|
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;
|
|
1408
|
+
}
|
|
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;
|
|
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, '-');
|
|
1565
|
+
}
|
|
1566
|
+
} catch {
|
|
1567
|
+
// Ignore malformed URLs here; validation handles required fields
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
return 'reference';
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1085
1573
|
/**
|
|
1086
1574
|
* Creates a node ID
|
|
1087
1575
|
* @param {string} universeName
|