@sprig-and-prose/sprig-universe 0.2.0 → 0.3.1
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 +31 -33
- package/src/cli.js +1 -210
- package/src/graph.js +682 -157
- package/src/ir.js +21 -6
- package/src/parser.js +245 -218
- 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,34 +560,99 @@ 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);
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
581
|
+
|
|
582
|
+
// Handle optional parent
|
|
583
|
+
let actualParentNodeId = parentNodeId;
|
|
584
|
+
let containerNodeId = undefined;
|
|
585
|
+
if (decl.parentName) {
|
|
586
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
587
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
588
|
+
actualParentNodeId = resolved.nodeId;
|
|
589
|
+
containerNodeId = resolved.nodeId;
|
|
590
|
+
}
|
|
591
|
+
// If parent not found, fall back to parentNodeId (tolerant parsing)
|
|
592
|
+
}
|
|
593
|
+
|
|
435
594
|
const node = createNode(
|
|
436
595
|
nodeId,
|
|
437
596
|
'book',
|
|
438
597
|
decl.name,
|
|
439
|
-
|
|
598
|
+
actualParentNodeId,
|
|
440
599
|
decl,
|
|
441
|
-
containerNodeId, // May be undefined if parentName doesn't resolve
|
|
600
|
+
containerNodeId, // May be undefined if no parentName or if parentName doesn't resolve
|
|
442
601
|
);
|
|
443
602
|
graph.nodes[nodeId] = node;
|
|
444
|
-
|
|
445
|
-
graph.nodes[containerNodeId].children.push(nodeId);
|
|
446
|
-
} else {
|
|
447
|
-
graph.nodes[parentNodeId].children.push(nodeId);
|
|
448
|
-
}
|
|
603
|
+
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
449
604
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
450
|
-
processBody(
|
|
605
|
+
processBody(
|
|
606
|
+
graph,
|
|
607
|
+
universeName,
|
|
608
|
+
nodeId,
|
|
609
|
+
decl.body,
|
|
610
|
+
scopeIndex,
|
|
611
|
+
file,
|
|
612
|
+
nodeId,
|
|
613
|
+
namedDocsMap,
|
|
614
|
+
reposMap,
|
|
615
|
+
refsMap,
|
|
616
|
+
entityKinds,
|
|
617
|
+
pendingReferenceDecls,
|
|
618
|
+
pendingReferenceAttachments,
|
|
619
|
+
);
|
|
451
620
|
} else if (decl.kind === 'chapter') {
|
|
452
621
|
const nodeId = makeNodeId(universeName, 'chapter', decl.name);
|
|
453
622
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
|
|
623
|
+
|
|
624
|
+
// Validate: chapter must have an "in" block
|
|
625
|
+
if (!decl.parentName) {
|
|
626
|
+
graph.diagnostics.push({
|
|
627
|
+
severity: 'error',
|
|
628
|
+
message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
|
|
629
|
+
source: decl.source,
|
|
630
|
+
});
|
|
631
|
+
// Continue processing but mark as invalid
|
|
632
|
+
}
|
|
633
|
+
|
|
454
634
|
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
455
635
|
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
636
|
+
|
|
637
|
+
// Validate: chapter container must be a book
|
|
638
|
+
if (containerNodeId) {
|
|
639
|
+
const containerNode = graph.nodes[containerNodeId];
|
|
640
|
+
if (containerNode && containerNode.kind !== 'book') {
|
|
641
|
+
graph.diagnostics.push({
|
|
642
|
+
severity: 'error',
|
|
643
|
+
message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
|
|
644
|
+
source: decl.source,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
} else if (decl.parentName) {
|
|
648
|
+
// Container not found
|
|
649
|
+
graph.diagnostics.push({
|
|
650
|
+
severity: 'error',
|
|
651
|
+
message: `Chapter "${decl.name}" references unknown book "${decl.parentName}"`,
|
|
652
|
+
source: decl.source,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
456
656
|
// Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
|
|
457
657
|
const node = createNode(
|
|
458
658
|
nodeId,
|
|
@@ -469,7 +669,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
469
669
|
graph.nodes[parentNodeId].children.push(nodeId);
|
|
470
670
|
}
|
|
471
671
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
472
|
-
processBody(
|
|
672
|
+
processBody(
|
|
673
|
+
graph,
|
|
674
|
+
universeName,
|
|
675
|
+
nodeId,
|
|
676
|
+
decl.body,
|
|
677
|
+
scopeIndex,
|
|
678
|
+
file,
|
|
679
|
+
nodeId,
|
|
680
|
+
namedDocsMap,
|
|
681
|
+
reposMap,
|
|
682
|
+
refsMap,
|
|
683
|
+
entityKinds,
|
|
684
|
+
pendingReferenceDecls,
|
|
685
|
+
pendingReferenceAttachments,
|
|
686
|
+
);
|
|
473
687
|
} else if (decl.kind === 'concept') {
|
|
474
688
|
const nodeId = makeNodeId(universeName, 'concept', decl.name);
|
|
475
689
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'concept', decl.source);
|
|
@@ -488,7 +702,21 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
488
702
|
graph.nodes[nodeId] = node;
|
|
489
703
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
490
704
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
491
|
-
processBody(
|
|
705
|
+
processBody(
|
|
706
|
+
graph,
|
|
707
|
+
universeName,
|
|
708
|
+
nodeId,
|
|
709
|
+
decl.body,
|
|
710
|
+
scopeIndex,
|
|
711
|
+
file,
|
|
712
|
+
nodeId,
|
|
713
|
+
namedDocsMap,
|
|
714
|
+
reposMap,
|
|
715
|
+
refsMap,
|
|
716
|
+
entityKinds,
|
|
717
|
+
pendingReferenceDecls,
|
|
718
|
+
pendingReferenceAttachments,
|
|
719
|
+
);
|
|
492
720
|
} else if (decl.kind === 'relates') {
|
|
493
721
|
// Check for duplicate relates in reverse order
|
|
494
722
|
checkDuplicateRelates(graph, universeName, decl.a, decl.b, decl.source);
|
|
@@ -670,51 +898,12 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
670
898
|
// Title blocks are attached to their parent node
|
|
671
899
|
// This is handled in createNode
|
|
672
900
|
} 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
|
-
}
|
|
901
|
+
if (pendingReferenceAttachments) {
|
|
902
|
+
pendingReferenceAttachments.push({
|
|
903
|
+
nodeId: currentNodeId,
|
|
904
|
+
items: decl.items,
|
|
905
|
+
universeName,
|
|
906
|
+
});
|
|
718
907
|
}
|
|
719
908
|
} else if (decl.kind === 'documentation') {
|
|
720
909
|
// DocumentationBlock - attach to current node
|
|
@@ -738,35 +927,75 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
738
927
|
source: doc.source,
|
|
739
928
|
});
|
|
740
929
|
}
|
|
741
|
-
} else if (decl.kind === '
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
graph.
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
930
|
+
} else if (decl.kind === 'reference') {
|
|
931
|
+
if (refsMap && entityKinds && pendingReferenceDecls) {
|
|
932
|
+
const baseName = decl.name || deriveReferenceName(decl);
|
|
933
|
+
const uniqueName = decl.name
|
|
934
|
+
? baseName
|
|
935
|
+
: pickUniqueName(scopeIndex, currentNodeId, baseName);
|
|
936
|
+
const qualifiedName = makeQualifiedName(graph, currentNodeId, uniqueName);
|
|
937
|
+
const refId = makeEntityId(universeName, 'reference', qualifiedName);
|
|
938
|
+
if (decl.name) {
|
|
939
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, refId, 'reference', decl.source, entityKinds);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!refsMap.has(refId)) {
|
|
943
|
+
refsMap.set(refId, decl);
|
|
944
|
+
entityKinds.set(refId, 'reference');
|
|
945
|
+
addNameToScope(graph, scopeIndex, currentNodeId, uniqueName, refId);
|
|
946
|
+
if (!graph.references[refId]) {
|
|
947
|
+
graph.references[refId] = {
|
|
948
|
+
id: refId,
|
|
949
|
+
name: uniqueName,
|
|
950
|
+
urls: [],
|
|
951
|
+
source: decl.source,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
pendingReferenceDecls.push({
|
|
955
|
+
refId,
|
|
956
|
+
decl,
|
|
957
|
+
universeName,
|
|
958
|
+
scopeNodeId: currentNodeId,
|
|
759
959
|
});
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
960
|
+
|
|
961
|
+
if (pendingReferenceAttachments && currentNodeId !== makeNodeId(universeName, 'universe', universeName)) {
|
|
962
|
+
pendingReferenceAttachments.push({
|
|
963
|
+
nodeId: currentNodeId,
|
|
964
|
+
items: [
|
|
965
|
+
{
|
|
966
|
+
name: uniqueName,
|
|
967
|
+
source: decl.source,
|
|
968
|
+
},
|
|
969
|
+
],
|
|
970
|
+
universeName,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
} else if (decl.kind === 'repository') {
|
|
976
|
+
// RepositoryDecl - store in universe-level registry
|
|
977
|
+
if (reposMap && entityKinds) {
|
|
978
|
+
const scopeNodeId = resolveContainerScope(graph, scopeIndex, currentNodeId, decl.parentName, decl.source);
|
|
979
|
+
const qualifiedName = makeQualifiedName(graph, scopeNodeId, decl.name);
|
|
980
|
+
const repoId = makeEntityId(universeName, 'repository', qualifiedName);
|
|
981
|
+
checkDuplicateInScope(graph, scopeIndex, scopeNodeId, decl.name, repoId, 'repository', decl.source, entityKinds);
|
|
982
|
+
|
|
983
|
+
if (!reposMap.has(repoId)) {
|
|
984
|
+
reposMap.set(repoId, decl);
|
|
985
|
+
entityKinds.set(repoId, 'repository');
|
|
986
|
+
addNameToScope(graph, scopeIndex, scopeNodeId, decl.name, repoId);
|
|
987
|
+
if (!decl.url) {
|
|
988
|
+
graph.diagnostics.push({
|
|
989
|
+
severity: 'error',
|
|
990
|
+
message: `Repository "${decl.name}" is missing url { '...' }`,
|
|
991
|
+
source: decl.source,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
graph.repositories[repoId] = {
|
|
995
|
+
id: repoId,
|
|
996
|
+
name: decl.name,
|
|
997
|
+
url: decl.url || '',
|
|
998
|
+
title: normalizeTitleValue(decl.title?.raw),
|
|
770
999
|
describe: decl.describe
|
|
771
1000
|
? {
|
|
772
1001
|
raw: decl.describe.raw,
|
|
@@ -774,34 +1003,15 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
774
1003
|
source: decl.describe.source,
|
|
775
1004
|
}
|
|
776
1005
|
: undefined,
|
|
1006
|
+
note: decl.note
|
|
1007
|
+
? {
|
|
1008
|
+
raw: decl.note.raw,
|
|
1009
|
+
normalized: normalizeProseBlock(decl.note.raw),
|
|
1010
|
+
source: decl.note.source,
|
|
1011
|
+
}
|
|
1012
|
+
: undefined,
|
|
777
1013
|
source: decl.source,
|
|
778
1014
|
};
|
|
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
1015
|
}
|
|
806
1016
|
}
|
|
807
1017
|
} else if (decl.kind === 'named-document') {
|
|
@@ -868,7 +1078,7 @@ function processBody(graph, universeName, parentNodeId, body, scopeIndex, file,
|
|
|
868
1078
|
* @param {string} name
|
|
869
1079
|
* @param {string | undefined} parentNodeId
|
|
870
1080
|
* @param {UniverseDecl | AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl} decl
|
|
871
|
-
* @param {string | undefined} containerNodeId
|
|
1081
|
+
* @param {string | undefined} [containerNodeId]
|
|
872
1082
|
* @returns {NodeModel}
|
|
873
1083
|
*/
|
|
874
1084
|
function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
@@ -924,15 +1134,16 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
924
1134
|
* @param {string} nodeId
|
|
925
1135
|
* @param {string} kind - The kind of the current node being checked
|
|
926
1136
|
* @param {SourceSpan} source
|
|
1137
|
+
* @param {Map<string, string>} [entityKinds]
|
|
927
1138
|
*/
|
|
928
|
-
function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source) {
|
|
1139
|
+
function checkDuplicateInScope(graph, scopeIndex, scopeNodeId, name, nodeId, kind, source, entityKinds) {
|
|
929
1140
|
const scopeMap = scopeIndex.get(scopeNodeId);
|
|
930
1141
|
if (scopeMap && scopeMap.has(name)) {
|
|
931
1142
|
const existingNodeIds = scopeMap.get(name);
|
|
932
1143
|
if (existingNodeIds.length > 0) {
|
|
933
1144
|
const firstNodeId = existingNodeIds[0];
|
|
934
1145
|
const firstNode = graph.nodes[firstNodeId];
|
|
935
|
-
const firstKind = firstNode?.kind || 'unknown';
|
|
1146
|
+
const firstKind = firstNode?.kind || entityKinds?.get(firstNodeId) || 'unknown';
|
|
936
1147
|
const scopeNode = graph.nodes[scopeNodeId];
|
|
937
1148
|
const scopeName = scopeNode ? (scopeNode.name || scopeNodeId) : scopeNodeId;
|
|
938
1149
|
graph.diagnostics.push({
|
|
@@ -998,7 +1209,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
998
1209
|
const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
|
|
999
1210
|
|
|
1000
1211
|
// Resolve endpoint A - start from universe scope
|
|
1001
|
-
const resolvedA =
|
|
1212
|
+
const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
|
|
1002
1213
|
if (resolvedA.nodeId && !resolvedA.ambiguous) {
|
|
1003
1214
|
edge.a.target = resolvedA.nodeId;
|
|
1004
1215
|
} else if (!hasRelatesNode) {
|
|
@@ -1013,7 +1224,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1013
1224
|
}
|
|
1014
1225
|
|
|
1015
1226
|
// Resolve endpoint B - start from universe scope
|
|
1016
|
-
const resolvedB =
|
|
1227
|
+
const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
|
|
1017
1228
|
if (resolvedB.nodeId && !resolvedB.ambiguous) {
|
|
1018
1229
|
edge.b.target = resolvedB.nodeId;
|
|
1019
1230
|
} else if (!hasRelatesNode) {
|
|
@@ -1042,7 +1253,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1042
1253
|
const unresolved = [];
|
|
1043
1254
|
|
|
1044
1255
|
for (const endpointName of node.unresolvedEndpoints) {
|
|
1045
|
-
const resolved =
|
|
1256
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1046
1257
|
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1047
1258
|
resolvedEndpoints.push(resolved.nodeId);
|
|
1048
1259
|
} else {
|
|
@@ -1068,7 +1279,7 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1068
1279
|
if (node.from) {
|
|
1069
1280
|
const resolvedFrom = {};
|
|
1070
1281
|
for (const endpointName in node.from) {
|
|
1071
|
-
const resolved =
|
|
1282
|
+
const resolved = resolveRelatesEndpoint(graph, scopeIndex, endpointName, relatesScope, node.source);
|
|
1072
1283
|
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1073
1284
|
resolvedFrom[resolved.nodeId] = node.from[endpointName];
|
|
1074
1285
|
} else {
|
|
@@ -1082,6 +1293,320 @@ function resolveEdges(graph, scopeIndex) {
|
|
|
1082
1293
|
}
|
|
1083
1294
|
}
|
|
1084
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* @param {string | undefined} raw
|
|
1298
|
+
* @returns {string | undefined}
|
|
1299
|
+
*/
|
|
1300
|
+
function normalizeTitleValue(raw) {
|
|
1301
|
+
if (!raw) return undefined;
|
|
1302
|
+
const trimmed = raw.trim();
|
|
1303
|
+
if (!trimmed) return undefined;
|
|
1304
|
+
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
|
1305
|
+
return unquoted.trim() || undefined;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* @param {UniverseGraph} graph
|
|
1310
|
+
* @param {Array<{refId: string, decl: ReferenceDecl, universeName: string, scopeNodeId: string}>} pending
|
|
1311
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1312
|
+
*/
|
|
1313
|
+
function resolveReferenceDecls(graph, pending, scopeIndex) {
|
|
1314
|
+
/** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
|
|
1315
|
+
const unknownRepoCounts = new Map();
|
|
1316
|
+
for (const item of pending) {
|
|
1317
|
+
const { refId, decl, scopeNodeId } = item;
|
|
1318
|
+
const model = graph.references[refId] || { id: refId, name: decl.name, urls: [], source: decl.source };
|
|
1319
|
+
const displayName = model.name || decl.name || 'unnamed reference';
|
|
1320
|
+
let urls = [];
|
|
1321
|
+
let repositoryRef = undefined;
|
|
1322
|
+
|
|
1323
|
+
if (decl.url && decl.repositoryName) {
|
|
1324
|
+
graph.diagnostics.push({
|
|
1325
|
+
severity: 'error',
|
|
1326
|
+
message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
|
|
1327
|
+
source: diagnosticSource(decl.source),
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (decl.url) {
|
|
1332
|
+
urls = [decl.url];
|
|
1333
|
+
} else if (decl.repositoryName) {
|
|
1334
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.repositoryName, scopeNodeId, decl.source);
|
|
1335
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1336
|
+
const repo = graph.repositories[resolved.nodeId];
|
|
1337
|
+
if (!repo) {
|
|
1338
|
+
graph.diagnostics.push({
|
|
1339
|
+
severity: 'error',
|
|
1340
|
+
message: `Reference "${displayName}" uses "${decl.repositoryName}" which is not a repository`,
|
|
1341
|
+
source: diagnosticSource(decl.source),
|
|
1342
|
+
});
|
|
1343
|
+
} else if (!repo.url) {
|
|
1344
|
+
graph.diagnostics.push({
|
|
1345
|
+
severity: 'error',
|
|
1346
|
+
message: `Repository "${repo.name}" is missing url { '...' }`,
|
|
1347
|
+
source: decl.source,
|
|
1348
|
+
});
|
|
1349
|
+
} else {
|
|
1350
|
+
repositoryRef = repo.id;
|
|
1351
|
+
if (decl.paths && decl.paths.length > 0) {
|
|
1352
|
+
urls = decl.paths.map((path) => joinRepositoryUrl(repo.url, path));
|
|
1353
|
+
} else if (decl.paths && decl.paths.length === 0) {
|
|
1354
|
+
graph.diagnostics.push({
|
|
1355
|
+
severity: 'error',
|
|
1356
|
+
message: `Reference "${displayName}" has an empty paths block`,
|
|
1357
|
+
source: diagnosticSource(decl.source),
|
|
1358
|
+
});
|
|
1359
|
+
} else {
|
|
1360
|
+
urls = [repo.url];
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
} else if (!resolved.ambiguous) {
|
|
1364
|
+
const entry = unknownRepoCounts.get(decl.repositoryName) || {
|
|
1365
|
+
count: 0,
|
|
1366
|
+
source: decl.source,
|
|
1367
|
+
};
|
|
1368
|
+
entry.count += 1;
|
|
1369
|
+
if (!entry.source && decl.source) {
|
|
1370
|
+
entry.source = decl.source;
|
|
1371
|
+
}
|
|
1372
|
+
unknownRepoCounts.set(decl.repositoryName, entry);
|
|
1373
|
+
}
|
|
1374
|
+
} else {
|
|
1375
|
+
graph.diagnostics.push({
|
|
1376
|
+
severity: 'error',
|
|
1377
|
+
message: `Reference "${displayName}" must have url { ... } or in <Repository>`,
|
|
1378
|
+
source: diagnosticSource(decl.source),
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
model.name = decl.name || model.name || displayName;
|
|
1383
|
+
model.kind = decl.referenceKind || undefined;
|
|
1384
|
+
model.title = normalizeTitleValue(decl.title?.raw);
|
|
1385
|
+
model.describe = decl.describe
|
|
1386
|
+
? {
|
|
1387
|
+
raw: decl.describe.raw,
|
|
1388
|
+
normalized: normalizeProseBlock(decl.describe.raw),
|
|
1389
|
+
source: decl.describe.source,
|
|
1390
|
+
}
|
|
1391
|
+
: undefined;
|
|
1392
|
+
model.note = decl.note
|
|
1393
|
+
? {
|
|
1394
|
+
raw: decl.note.raw,
|
|
1395
|
+
normalized: normalizeProseBlock(decl.note.raw),
|
|
1396
|
+
source: decl.note.source,
|
|
1397
|
+
}
|
|
1398
|
+
: undefined;
|
|
1399
|
+
model.urls = urls;
|
|
1400
|
+
model.repositoryRef = repositoryRef;
|
|
1401
|
+
model.paths = decl.paths && decl.paths.length > 0 ? decl.paths : undefined;
|
|
1402
|
+
model.source = decl.source;
|
|
1403
|
+
graph.references[refId] = model;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
for (const [repoName, info] of unknownRepoCounts.entries()) {
|
|
1407
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1408
|
+
graph.diagnostics.push({
|
|
1409
|
+
severity: 'error',
|
|
1410
|
+
message: `Unknown repository "${repoName}" used by references${countText}.`,
|
|
1411
|
+
source: diagnosticSource(info.source),
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* @param {UniverseGraph} graph
|
|
1418
|
+
* @param {Array<{nodeId: string, items: Array<{ name: string, source: SourceSpan }>, universeName: string}>} pending
|
|
1419
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1420
|
+
*/
|
|
1421
|
+
function resolveReferenceAttachments(graph, pending, scopeIndex) {
|
|
1422
|
+
/** @type {Map<string, { count: number, source: SourceSpan | undefined }>} */
|
|
1423
|
+
const unknownReferenceCounts = new Map();
|
|
1424
|
+
|
|
1425
|
+
for (const item of pending) {
|
|
1426
|
+
const node = graph.nodes[item.nodeId];
|
|
1427
|
+
if (!node) {
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (!node.references) {
|
|
1431
|
+
node.references = [];
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
for (const entry of item.items) {
|
|
1435
|
+
const name = entry.name;
|
|
1436
|
+
const resolved = resolveNameInScope(graph, scopeIndex, name, item.nodeId, entry.source);
|
|
1437
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1438
|
+
if (graph.references[resolved.nodeId]) {
|
|
1439
|
+
node.references.push(resolved.nodeId);
|
|
1440
|
+
} else {
|
|
1441
|
+
const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
|
|
1442
|
+
existing.count += 1;
|
|
1443
|
+
if (!existing.source && entry.source) {
|
|
1444
|
+
existing.source = entry.source;
|
|
1445
|
+
}
|
|
1446
|
+
unknownReferenceCounts.set(name, existing);
|
|
1447
|
+
}
|
|
1448
|
+
} else if (!resolved.ambiguous) {
|
|
1449
|
+
const existing = unknownReferenceCounts.get(name) || { count: 0, source: entry.source };
|
|
1450
|
+
existing.count += 1;
|
|
1451
|
+
if (!existing.source && entry.source) {
|
|
1452
|
+
existing.source = entry.source;
|
|
1453
|
+
}
|
|
1454
|
+
unknownReferenceCounts.set(name, existing);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
for (const [name, info] of unknownReferenceCounts.entries()) {
|
|
1460
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1461
|
+
graph.diagnostics.push({
|
|
1462
|
+
severity: 'error',
|
|
1463
|
+
message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
|
|
1464
|
+
source: diagnosticSource(info.source),
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* @param {SourceSpan | undefined} source
|
|
1471
|
+
* @returns {SourceSpan | { file: string, line?: number, col?: number, start?: any, end?: any } | undefined}
|
|
1472
|
+
*/
|
|
1473
|
+
function diagnosticSource(source) {
|
|
1474
|
+
if (!source) return undefined;
|
|
1475
|
+
return {
|
|
1476
|
+
file: source.file,
|
|
1477
|
+
line: source.start?.line,
|
|
1478
|
+
col: source.start?.col,
|
|
1479
|
+
start: source.start,
|
|
1480
|
+
end: source.end,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* @param {string} base
|
|
1486
|
+
* @param {string} path
|
|
1487
|
+
* @returns {string}
|
|
1488
|
+
*/
|
|
1489
|
+
function joinRepositoryUrl(base, path) {
|
|
1490
|
+
if (base.endsWith('/') && path.startsWith('/')) {
|
|
1491
|
+
return base + path.slice(1);
|
|
1492
|
+
}
|
|
1493
|
+
if (!base.endsWith('/') && !path.startsWith('/')) {
|
|
1494
|
+
return `${base}/${path}`;
|
|
1495
|
+
}
|
|
1496
|
+
return base + path;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* @param {UniverseGraph} graph
|
|
1501
|
+
* @param {string} containerNodeId
|
|
1502
|
+
* @param {string} name
|
|
1503
|
+
* @returns {string}
|
|
1504
|
+
*/
|
|
1505
|
+
function makeQualifiedName(graph, containerNodeId, name) {
|
|
1506
|
+
const path = [];
|
|
1507
|
+
let current = containerNodeId;
|
|
1508
|
+
const visited = new Set();
|
|
1509
|
+
while (current && !visited.has(current)) {
|
|
1510
|
+
visited.add(current);
|
|
1511
|
+
const node = graph.nodes[current];
|
|
1512
|
+
if (!node) break;
|
|
1513
|
+
if (node.kind !== 'universe') {
|
|
1514
|
+
path.push(node.name);
|
|
1515
|
+
}
|
|
1516
|
+
current = node.parent;
|
|
1517
|
+
}
|
|
1518
|
+
path.reverse();
|
|
1519
|
+
path.push(name);
|
|
1520
|
+
return path.join('.');
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* @param {string} universeName
|
|
1525
|
+
* @param {string} kind
|
|
1526
|
+
* @param {string} qualifiedName
|
|
1527
|
+
* @returns {string}
|
|
1528
|
+
*/
|
|
1529
|
+
function makeEntityId(universeName, kind, qualifiedName) {
|
|
1530
|
+
return `${universeName}:${kind}:${qualifiedName}`;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* @param {UniverseGraph} graph
|
|
1535
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1536
|
+
* @param {string} currentNodeId
|
|
1537
|
+
* @param {string | undefined} parentName
|
|
1538
|
+
* @param {SourceSpan} source
|
|
1539
|
+
* @returns {string}
|
|
1540
|
+
*/
|
|
1541
|
+
function resolveContainerScope(graph, scopeIndex, currentNodeId, parentName, source) {
|
|
1542
|
+
if (!parentName) {
|
|
1543
|
+
return currentNodeId;
|
|
1544
|
+
}
|
|
1545
|
+
const resolved = resolveNameInScope(graph, scopeIndex, parentName, currentNodeId, source);
|
|
1546
|
+
if (resolved.nodeId && !resolved.ambiguous) {
|
|
1547
|
+
return resolved.nodeId;
|
|
1548
|
+
}
|
|
1549
|
+
return currentNodeId;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex
|
|
1554
|
+
* @param {string} scopeNodeId
|
|
1555
|
+
* @param {string} baseName
|
|
1556
|
+
* @returns {string}
|
|
1557
|
+
*/
|
|
1558
|
+
function pickUniqueName(scopeIndex, scopeNodeId, baseName) {
|
|
1559
|
+
const scopeMap = scopeIndex.get(scopeNodeId);
|
|
1560
|
+
if (!scopeMap || !scopeMap.has(baseName)) {
|
|
1561
|
+
return baseName;
|
|
1562
|
+
}
|
|
1563
|
+
let suffix = 2;
|
|
1564
|
+
let candidate = `${baseName}-${suffix}`;
|
|
1565
|
+
while (scopeMap.has(candidate)) {
|
|
1566
|
+
suffix += 1;
|
|
1567
|
+
candidate = `${baseName}-${suffix}`;
|
|
1568
|
+
}
|
|
1569
|
+
return candidate;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* @param {ReferenceDecl} decl
|
|
1574
|
+
* @returns {string}
|
|
1575
|
+
*/
|
|
1576
|
+
function deriveReferenceName(decl) {
|
|
1577
|
+
const title = normalizeTitleValue(decl.title?.raw);
|
|
1578
|
+
if (title) {
|
|
1579
|
+
return title;
|
|
1580
|
+
}
|
|
1581
|
+
if (decl.paths && decl.paths.length > 0) {
|
|
1582
|
+
const rawPath = decl.paths[0];
|
|
1583
|
+
const trimmed = rawPath.replace(/\/+$/, '');
|
|
1584
|
+
const parts = trimmed.split('/').filter(Boolean);
|
|
1585
|
+
if (parts.length > 0) {
|
|
1586
|
+
const lastPart = parts[parts.length - 1];
|
|
1587
|
+
const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
|
|
1588
|
+
return withoutExt.replace(/\./g, '-');
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
if (decl.url) {
|
|
1592
|
+
try {
|
|
1593
|
+
const parsed = new URL(decl.url);
|
|
1594
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
1595
|
+
if (segments.length > 0) {
|
|
1596
|
+
const lastSegment = segments[segments.length - 1];
|
|
1597
|
+
const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
|
|
1598
|
+
return withoutExt.replace(/\./g, '-');
|
|
1599
|
+
}
|
|
1600
|
+
if (parsed.hostname) {
|
|
1601
|
+
return parsed.hostname.replace(/\./g, '-');
|
|
1602
|
+
}
|
|
1603
|
+
} catch {
|
|
1604
|
+
// Ignore malformed URLs here; validation handles required fields
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return 'reference';
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1085
1610
|
/**
|
|
1086
1611
|
* Creates a node ID
|
|
1087
1612
|
* @param {string} universeName
|