@sprig-and-prose/sprig-universe 0.3.4 → 0.4.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 +51 -4
- package/src/graph.js +533 -111
- package/src/ir.js +40 -2
- package/src/parser.js +666 -57
- package/src/scanner.js +1 -0
- package/test/aliases.test.js +91 -0
- package/test/fixtures/aliases-basic.prose +7 -0
- package/test/fixtures/aliases-conflict.prose +6 -0
- package/test/fixtures/aliases-no-leak.prose +5 -0
- package/test/fixtures/aliases-shadowing.prose +11 -0
- package/test/fixtures/aliases-single-line.prose +7 -0
- package/test/fixtures/relationship-errors.prose +20 -0
- package/test/fixtures/relationship-paired.prose +28 -0
- package/test/fixtures/relationship-scoped.prose +23 -0
- package/test/fixtures/relationship-symmetric.prose +18 -0
- package/test/fixtures/relationship-usage.prose +31 -0
package/src/graph.js
CHANGED
|
@@ -22,6 +22,7 @@ import { normalizeProseBlock } from './util/text.js';
|
|
|
22
22
|
* @typedef {import('./ast.js').ChapterDecl} ChapterDecl
|
|
23
23
|
* @typedef {import('./ast.js').ConceptDecl} ConceptDecl
|
|
24
24
|
* @typedef {import('./ast.js').RelatesDecl} RelatesDecl
|
|
25
|
+
* @typedef {import('./ast.js').RelationshipDecl} RelationshipDecl
|
|
25
26
|
* @typedef {import('./ast.js').DescribeBlock} DescribeBlock
|
|
26
27
|
* @typedef {import('./ast.js').TitleBlock} TitleBlock
|
|
27
28
|
* @typedef {import('./ast.js').FromBlock} FromBlock
|
|
@@ -49,7 +50,7 @@ export function buildGraph(fileASTs) {
|
|
|
49
50
|
version: 1,
|
|
50
51
|
universes: {},
|
|
51
52
|
nodes: {},
|
|
52
|
-
edges:
|
|
53
|
+
edges: [],
|
|
53
54
|
diagnostics: [],
|
|
54
55
|
documentsByName: {},
|
|
55
56
|
repositories: {},
|
|
@@ -66,6 +67,15 @@ export function buildGraph(fileASTs) {
|
|
|
66
67
|
// Track repositories and references per universe for scoped resolution
|
|
67
68
|
const repositoriesByUniverse = new Map(); // universeName -> Map<id, RepositoryDecl>
|
|
68
69
|
const referencesByUniverse = new Map(); // universeName -> Map<id, ReferenceDecl>
|
|
70
|
+
|
|
71
|
+
// Track relationship declarations per scope (containerNodeId -> Map<relationshipId, RelationshipDecl>)
|
|
72
|
+
const relationshipsByScope = new Map(); // containerNodeId -> Map<relationshipId, RelationshipDecl>
|
|
73
|
+
|
|
74
|
+
// Store relationship declarations by universe for UI access
|
|
75
|
+
const relationshipDeclsByUniverse = new Map(); // universeName -> Map<relationshipId, RelationshipDecl>
|
|
76
|
+
|
|
77
|
+
// Initialize relationshipDecls in graph
|
|
78
|
+
graph.relationshipDecls = {};
|
|
69
79
|
|
|
70
80
|
// Track entity kinds for duplicate detection (references/repositories)
|
|
71
81
|
const entityKinds = new Map(); // id -> kind
|
|
@@ -190,6 +200,8 @@ export function buildGraph(fileASTs) {
|
|
|
190
200
|
repositoriesByUniverse.get(universeName),
|
|
191
201
|
referencesByUniverse.get(universeName),
|
|
192
202
|
entityKinds,
|
|
203
|
+
relationshipsByScope,
|
|
204
|
+
relationshipDeclsByUniverse,
|
|
193
205
|
pendingReferenceDecls,
|
|
194
206
|
pendingReferenceAttachments,
|
|
195
207
|
pendingContainerResolutions,
|
|
@@ -220,6 +232,8 @@ export function buildGraph(fileASTs) {
|
|
|
220
232
|
repositoriesByUniverse.get(universeName),
|
|
221
233
|
referencesByUniverse.get(universeName),
|
|
222
234
|
entityKinds,
|
|
235
|
+
relationshipsByScope,
|
|
236
|
+
relationshipDeclsByUniverse,
|
|
223
237
|
pendingReferenceDecls,
|
|
224
238
|
pendingReferenceAttachments,
|
|
225
239
|
pendingContainerResolutions,
|
|
@@ -256,6 +270,7 @@ export function buildGraph(fileASTs) {
|
|
|
256
270
|
continue;
|
|
257
271
|
}
|
|
258
272
|
}
|
|
273
|
+
// Process the declaration itself first
|
|
259
274
|
processBody(
|
|
260
275
|
graph,
|
|
261
276
|
universeName,
|
|
@@ -268,6 +283,8 @@ export function buildGraph(fileASTs) {
|
|
|
268
283
|
repositoriesByUniverse.get(universeName),
|
|
269
284
|
referencesByUniverse.get(universeName),
|
|
270
285
|
entityKinds,
|
|
286
|
+
relationshipsByScope,
|
|
287
|
+
relationshipDeclsByUniverse,
|
|
271
288
|
pendingReferenceDecls,
|
|
272
289
|
pendingReferenceAttachments,
|
|
273
290
|
pendingContainerResolutions,
|
|
@@ -298,6 +315,31 @@ export function buildGraph(fileASTs) {
|
|
|
298
315
|
// Validate and apply ordering blocks after all nodes and relationships are established
|
|
299
316
|
validateOrderingBlocks(graph);
|
|
300
317
|
|
|
318
|
+
// Extract asserted edges from both relationships blocks and relates nodes
|
|
319
|
+
graph.edgesAsserted = extractAssertedEdges(graph, scopeIndex);
|
|
320
|
+
|
|
321
|
+
// Normalize edges (add inverse edges for paired/symmetric relationships)
|
|
322
|
+
const universeNames = Array.from(universeNameToFiles.keys());
|
|
323
|
+
if (universeNames.length > 0) {
|
|
324
|
+
// For now, assume single universe (as per buildGraph contract)
|
|
325
|
+
const universeName = universeNames[0];
|
|
326
|
+
graph.edges = normalizeEdges(
|
|
327
|
+
graph.edgesAsserted,
|
|
328
|
+
relationshipDeclsByUniverse,
|
|
329
|
+
universeName,
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
graph.edges = [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Convert relationshipDeclsByUniverse Maps to plain objects for JSON serialization
|
|
336
|
+
// (already done above, but ensure all universes are initialized)
|
|
337
|
+
for (const [universeName, declsMap] of relationshipDeclsByUniverse.entries()) {
|
|
338
|
+
if (!graph.relationshipDecls[universeName]) {
|
|
339
|
+
graph.relationshipDecls[universeName] = {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
301
343
|
return graph;
|
|
302
344
|
}
|
|
303
345
|
|
|
@@ -337,6 +379,46 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
|
|
|
337
379
|
}
|
|
338
380
|
}
|
|
339
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Finds the series containing a given node by walking up the parent chain
|
|
384
|
+
* @param {UniverseGraph} graph - The graph
|
|
385
|
+
* @param {string} nodeId - Node ID to find series for
|
|
386
|
+
* @returns {string | null} - Series node ID, or null if not found
|
|
387
|
+
*/
|
|
388
|
+
function findSeriesForNode(graph, nodeId) {
|
|
389
|
+
const node = graph.nodes[nodeId];
|
|
390
|
+
if (!node) return null;
|
|
391
|
+
|
|
392
|
+
let checkNode = node;
|
|
393
|
+
while (checkNode && checkNode.parent) {
|
|
394
|
+
checkNode = graph.nodes[checkNode.parent];
|
|
395
|
+
if (checkNode && checkNode.kind === 'series') {
|
|
396
|
+
return checkNode.id;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Prefers a candidate from the same series as the starting node when multiple candidates exist
|
|
404
|
+
* @param {UniverseGraph} graph - The graph
|
|
405
|
+
* @param {string} startScopeNodeId - Starting node ID
|
|
406
|
+
* @param {string[]} candidates - Array of candidate node IDs
|
|
407
|
+
* @returns {string | null} - Preferred candidate node ID, or null if none found
|
|
408
|
+
*/
|
|
409
|
+
function preferSameSeriesCandidate(graph, startScopeNodeId, candidates) {
|
|
410
|
+
const seriesNodeId = findSeriesForNode(graph, startScopeNodeId);
|
|
411
|
+
if (!seriesNodeId) return null;
|
|
412
|
+
|
|
413
|
+
for (const candidateId of candidates) {
|
|
414
|
+
const candidateSeriesNodeId = findSeriesForNode(graph, candidateId);
|
|
415
|
+
if (candidateSeriesNodeId === seriesNodeId) {
|
|
416
|
+
return candidateId;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
340
422
|
/**
|
|
341
423
|
* Resolves a name by walking the scope chain (current container -> parent -> ... -> universe)
|
|
342
424
|
* Returns the first matching node ID found, or null if not found
|
|
@@ -350,7 +432,7 @@ function addNameToScope(graph, scopeIndex, scopeNodeId, name, nodeId) {
|
|
|
350
432
|
*/
|
|
351
433
|
function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
352
434
|
// Handle qualified names (dot notation)
|
|
353
|
-
if (name.includes('.')) {
|
|
435
|
+
if (name && name.includes('.')) {
|
|
354
436
|
const parts = name.split('.');
|
|
355
437
|
// Start from universe and resolve each part
|
|
356
438
|
const universeName = startScopeNodeId.split(':')[0];
|
|
@@ -400,7 +482,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
|
400
482
|
} else if (candidates.length === 1) {
|
|
401
483
|
return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
|
|
402
484
|
} else {
|
|
403
|
-
// Ambiguity at this scope level
|
|
485
|
+
// Ambiguity at this scope level - try to disambiguate by preferring same series
|
|
486
|
+
const preferredCandidate = preferSameSeriesCandidate(graph, startScopeNodeId, candidates);
|
|
487
|
+
|
|
488
|
+
if (preferredCandidate) {
|
|
489
|
+
return { nodeId: preferredCandidate, ambiguous: false, ambiguousNodes: [] };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// No preferred candidate found - report ambiguity
|
|
404
493
|
if (source) {
|
|
405
494
|
const scopeNode = graph.nodes[currentScope];
|
|
406
495
|
const scopeName = scopeNode ? (scopeNode.name || currentScope) : currentScope;
|
|
@@ -430,6 +519,14 @@ function resolveNameInScope(graph, scopeIndex, name, startScopeNodeId, source) {
|
|
|
430
519
|
if (candidates.length === 1) {
|
|
431
520
|
return { nodeId: candidates[0], ambiguous: false, ambiguousNodes: [] };
|
|
432
521
|
} else if (candidates.length > 1) {
|
|
522
|
+
// Multiple matches at universe scope - try to disambiguate by preferring same series
|
|
523
|
+
const preferredCandidate = preferSameSeriesCandidate(graph, startScopeNodeId, candidates);
|
|
524
|
+
|
|
525
|
+
if (preferredCandidate) {
|
|
526
|
+
return { nodeId: preferredCandidate, ambiguous: false, ambiguousNodes: [] };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// No preferred candidate found - report ambiguity
|
|
433
530
|
if (source) {
|
|
434
531
|
graph.diagnostics.push({
|
|
435
532
|
severity: 'error',
|
|
@@ -506,6 +603,8 @@ function resolveRelatesEndpoint(graph, scopeIndex, name, startScopeNodeId, sourc
|
|
|
506
603
|
* @param {Map<string, RepositoryDecl>} [reposMap] - Map for tracking repository declarations (universe scope only)
|
|
507
604
|
* @param {Map<string, ReferenceDecl>} [refsMap] - Map for tracking reference declarations (universe scope only)
|
|
508
605
|
* @param {Map<string, string>} [entityKinds] - Map for tracking non-node entity kinds by ID
|
|
606
|
+
* @param {Map<string, Map<string, RelationshipDecl>>} relationshipsByScope - Map for tracking relationship declarations per scope (required)
|
|
607
|
+
* @param {Map<string, Map<string, RelationshipDecl>>} relationshipDeclsByUniverse - Map for storing relationship declarations by universe for UI access
|
|
509
608
|
* @param {Array} [pendingReferenceDecls] - Reference declarations to resolve after indexing
|
|
510
609
|
* @param {Array} [pendingReferenceAttachments] - Reference attachments to resolve after indexing
|
|
511
610
|
* @param {Array} [pendingContainerResolutions] - Container resolutions to resolve after all nodes are created
|
|
@@ -522,6 +621,8 @@ function processBody(
|
|
|
522
621
|
reposMap,
|
|
523
622
|
refsMap,
|
|
524
623
|
entityKinds,
|
|
624
|
+
relationshipsByScope,
|
|
625
|
+
relationshipDeclsByUniverse,
|
|
525
626
|
pendingReferenceDecls,
|
|
526
627
|
pendingReferenceAttachments,
|
|
527
628
|
pendingContainerResolutions,
|
|
@@ -529,8 +630,88 @@ function processBody(
|
|
|
529
630
|
// Collect ordering blocks to process after all children are added
|
|
530
631
|
const orderingBlocks = [];
|
|
531
632
|
|
|
633
|
+
// Ensure relationshipsByScope is initialized as a Map
|
|
634
|
+
// This should always be provided, but check to prevent runtime errors
|
|
635
|
+
if (!relationshipsByScope) {
|
|
636
|
+
throw new Error(`relationshipsByScope is required but was ${relationshipsByScope}`);
|
|
637
|
+
}
|
|
638
|
+
if (typeof relationshipsByScope.has !== 'function' || typeof relationshipsByScope.set !== 'function' || typeof relationshipsByScope.get !== 'function') {
|
|
639
|
+
throw new Error(`relationshipsByScope must be a Map, got ${typeof relationshipsByScope} with has: ${typeof relationshipsByScope.has}, set: ${typeof relationshipsByScope.set}, get: ${typeof relationshipsByScope.get}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Initialize relationship scope for current container if not exists
|
|
643
|
+
if (!relationshipsByScope.has(currentNodeId)) {
|
|
644
|
+
relationshipsByScope.set(currentNodeId, new Map());
|
|
645
|
+
}
|
|
646
|
+
// Inherit parent relationships
|
|
647
|
+
if (parentNodeId && relationshipsByScope.has(parentNodeId)) {
|
|
648
|
+
const parentRelationships = relationshipsByScope.get(parentNodeId);
|
|
649
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
650
|
+
if (parentRelationships && currentRelationships) {
|
|
651
|
+
for (const [id, rel] of parentRelationships) {
|
|
652
|
+
if (!currentRelationships.has(id)) {
|
|
653
|
+
currentRelationships.set(id, rel);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
532
659
|
for (const decl of body) {
|
|
533
|
-
if (decl.kind === '
|
|
660
|
+
if (decl.kind === 'relationship' && relationshipsByScope) {
|
|
661
|
+
// Track relationship declaration in current scope
|
|
662
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
663
|
+
if (currentRelationships) {
|
|
664
|
+
if (decl.type === 'symmetric' && decl.id) {
|
|
665
|
+
currentRelationships.set(decl.id, decl);
|
|
666
|
+
// Also store in universe-level map for UI access
|
|
667
|
+
if (!relationshipDeclsByUniverse.has(universeName)) {
|
|
668
|
+
relationshipDeclsByUniverse.set(universeName, new Map());
|
|
669
|
+
graph.relationshipDecls[universeName] = {};
|
|
670
|
+
}
|
|
671
|
+
const universeDecls = relationshipDeclsByUniverse.get(universeName);
|
|
672
|
+
if (universeDecls) {
|
|
673
|
+
universeDecls.set(decl.id, {
|
|
674
|
+
type: decl.type,
|
|
675
|
+
id: decl.id,
|
|
676
|
+
describe: decl.describe,
|
|
677
|
+
label: decl.label,
|
|
678
|
+
source: decl.source,
|
|
679
|
+
});
|
|
680
|
+
graph.relationshipDecls[universeName][decl.id] = {
|
|
681
|
+
type: decl.type,
|
|
682
|
+
id: decl.id,
|
|
683
|
+
describe: decl.describe,
|
|
684
|
+
label: decl.label,
|
|
685
|
+
source: decl.source,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
} else if (decl.type === 'paired') {
|
|
689
|
+
if (decl.leftId) currentRelationships.set(decl.leftId, decl);
|
|
690
|
+
if (decl.rightId) currentRelationships.set(decl.rightId, decl);
|
|
691
|
+
// Also store in universe-level map for UI access (store once per pair)
|
|
692
|
+
if (!relationshipDeclsByUniverse.has(universeName)) {
|
|
693
|
+
relationshipDeclsByUniverse.set(universeName, new Map());
|
|
694
|
+
graph.relationshipDecls[universeName] = {};
|
|
695
|
+
}
|
|
696
|
+
const universeDecls = relationshipDeclsByUniverse.get(universeName);
|
|
697
|
+
if (universeDecls && decl.leftId && decl.rightId) {
|
|
698
|
+
// Store under both IDs for easy lookup
|
|
699
|
+
const declModel = {
|
|
700
|
+
type: decl.type,
|
|
701
|
+
leftId: decl.leftId,
|
|
702
|
+
rightId: decl.rightId,
|
|
703
|
+
describe: decl.describe,
|
|
704
|
+
from: decl.from,
|
|
705
|
+
source: decl.source,
|
|
706
|
+
};
|
|
707
|
+
universeDecls.set(decl.leftId, declModel);
|
|
708
|
+
universeDecls.set(decl.rightId, declModel);
|
|
709
|
+
graph.relationshipDecls[universeName][decl.leftId] = declModel;
|
|
710
|
+
graph.relationshipDecls[universeName][decl.rightId] = declModel;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
} else if (decl.kind === 'anthology') {
|
|
534
715
|
const nodeId = makeNodeId(universeName, 'anthology', decl.name);
|
|
535
716
|
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'anthology', decl.source);
|
|
536
717
|
let actualParentNodeId = parentNodeId;
|
|
@@ -556,6 +737,8 @@ function processBody(
|
|
|
556
737
|
reposMap,
|
|
557
738
|
refsMap,
|
|
558
739
|
entityKinds,
|
|
740
|
+
relationshipsByScope,
|
|
741
|
+
relationshipDeclsByUniverse,
|
|
559
742
|
pendingReferenceDecls,
|
|
560
743
|
pendingReferenceAttachments,
|
|
561
744
|
pendingContainerResolutions,
|
|
@@ -590,6 +773,8 @@ function processBody(
|
|
|
590
773
|
reposMap,
|
|
591
774
|
refsMap,
|
|
592
775
|
entityKinds,
|
|
776
|
+
relationshipsByScope,
|
|
777
|
+
relationshipDeclsByUniverse,
|
|
593
778
|
pendingReferenceDecls,
|
|
594
779
|
pendingReferenceAttachments,
|
|
595
780
|
pendingContainerResolutions,
|
|
@@ -633,41 +818,89 @@ function processBody(
|
|
|
633
818
|
reposMap,
|
|
634
819
|
refsMap,
|
|
635
820
|
entityKinds,
|
|
821
|
+
relationshipsByScope,
|
|
822
|
+
relationshipDeclsByUniverse,
|
|
636
823
|
pendingReferenceDecls,
|
|
637
824
|
pendingReferenceAttachments,
|
|
638
825
|
pendingContainerResolutions,
|
|
639
826
|
);
|
|
640
827
|
} else if (decl.kind === 'chapter') {
|
|
641
|
-
|
|
642
|
-
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
|
|
643
|
-
|
|
644
|
-
// Validate: chapter must have an "in" block
|
|
645
|
-
if (!decl.parentName) {
|
|
646
|
-
graph.diagnostics.push({
|
|
647
|
-
severity: 'error',
|
|
648
|
-
message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
|
|
649
|
-
source: decl.source,
|
|
650
|
-
});
|
|
651
|
-
// Continue processing but mark as invalid
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
655
|
-
const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
828
|
+
let containerNodeId = undefined;
|
|
656
829
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
830
|
+
if (decl.parentName) {
|
|
831
|
+
// Explicit "in" clause - resolve the parent name
|
|
832
|
+
const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
|
|
833
|
+
containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : undefined;
|
|
834
|
+
|
|
835
|
+
// Validate: chapter container must be a book (if found)
|
|
836
|
+
// Note: We defer "not found" validation until after all nodes are created
|
|
837
|
+
// to allow forward references (book defined after chapter)
|
|
838
|
+
if (containerNodeId) {
|
|
839
|
+
const containerNode = graph.nodes[containerNodeId];
|
|
840
|
+
if (containerNode && containerNode.kind !== 'book') {
|
|
841
|
+
graph.diagnostics.push({
|
|
842
|
+
severity: 'error',
|
|
843
|
+
message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
|
|
844
|
+
source: decl.source,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
} else {
|
|
849
|
+
// Nested chapter - parentName is undefined, check if parentNodeId is a book
|
|
850
|
+
if (parentNodeId) {
|
|
851
|
+
const parentNode = graph.nodes[parentNodeId];
|
|
852
|
+
if (parentNode && parentNode.kind !== 'book') {
|
|
853
|
+
graph.diagnostics.push({
|
|
854
|
+
severity: 'error',
|
|
855
|
+
message: `Chapter "${decl.name}" must belong to a book, but it's nested inside a ${parentNode.kind}`,
|
|
856
|
+
source: decl.source,
|
|
857
|
+
});
|
|
858
|
+
} else if (parentNode && parentNode.kind === 'book') {
|
|
859
|
+
// Valid nested chapter - use parentNodeId as container
|
|
860
|
+
containerNodeId = parentNodeId;
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
// No parent at all - error
|
|
663
864
|
graph.diagnostics.push({
|
|
664
865
|
severity: 'error',
|
|
665
|
-
message: `Chapter "${decl.name}" must belong to a book
|
|
866
|
+
message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
|
|
666
867
|
source: decl.source,
|
|
667
868
|
});
|
|
668
869
|
}
|
|
669
|
-
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Build unique node ID including container path
|
|
873
|
+
let containerPath = null;
|
|
874
|
+
if (containerNodeId) {
|
|
875
|
+
// Container is resolved - build path from container node
|
|
876
|
+
containerPath = buildContainerPath(graph, containerNodeId);
|
|
877
|
+
} else if (parentNodeId) {
|
|
878
|
+
// Nested chapter - use parent (book) to build path
|
|
879
|
+
containerPath = buildContainerPath(graph, parentNodeId);
|
|
880
|
+
} else if (decl.parentName) {
|
|
881
|
+
// Forward reference - build path from current scope to include series
|
|
882
|
+
// Walk up from currentNodeId to find series, then use book name
|
|
883
|
+
// This ensures uniqueness even when the container isn't resolved yet
|
|
884
|
+
const pathParts = [];
|
|
885
|
+
const seriesNodeId = findSeriesForNode(graph, currentNodeId);
|
|
886
|
+
if (seriesNodeId) {
|
|
887
|
+
const seriesNode = graph.nodes[seriesNodeId];
|
|
888
|
+
if (seriesNode) {
|
|
889
|
+
pathParts.push('series', seriesNode.name);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// Always include book name - if series wasn't found, we'll still have book name
|
|
893
|
+
// which should be unique within the current processing context
|
|
894
|
+
pathParts.push('book', decl.parentName);
|
|
895
|
+
containerPath = pathParts.length > 0 ? pathParts.join(':') : null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const nodeId = makeNodeId(universeName, 'chapter', decl.name, containerPath);
|
|
899
|
+
checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
|
|
900
|
+
|
|
901
|
+
if (decl.parentName && !containerNodeId && pendingContainerResolutions) {
|
|
670
902
|
// Container not found yet - may be a forward reference, track for later resolution
|
|
903
|
+
// Note: nodeId already includes the parent name, so it's unique even if container isn't resolved yet
|
|
671
904
|
pendingContainerResolutions.push({
|
|
672
905
|
nodeId,
|
|
673
906
|
parentName: decl.parentName,
|
|
@@ -705,6 +938,8 @@ function processBody(
|
|
|
705
938
|
reposMap,
|
|
706
939
|
refsMap,
|
|
707
940
|
entityKinds,
|
|
941
|
+
relationshipsByScope,
|
|
942
|
+
relationshipDeclsByUniverse,
|
|
708
943
|
pendingReferenceDecls,
|
|
709
944
|
pendingReferenceAttachments,
|
|
710
945
|
pendingContainerResolutions,
|
|
@@ -727,6 +962,27 @@ function processBody(
|
|
|
727
962
|
graph.nodes[nodeId] = node;
|
|
728
963
|
graph.nodes[actualParentNodeId].children.push(nodeId);
|
|
729
964
|
addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
|
|
965
|
+
|
|
966
|
+
// Validate relationships blocks in concept body
|
|
967
|
+
const relationshipsBlocks = decl.body.filter((b) => b.kind === 'relationships');
|
|
968
|
+
for (const relBlock of relationshipsBlocks) {
|
|
969
|
+
if (relBlock.entries && relationshipsByScope) {
|
|
970
|
+
// New syntax: validate relationship IDs
|
|
971
|
+
const currentRelationships = relationshipsByScope.get(currentNodeId);
|
|
972
|
+
if (currentRelationships) {
|
|
973
|
+
for (const entry of relBlock.entries) {
|
|
974
|
+
if (!currentRelationships.has(entry.relationshipId)) {
|
|
975
|
+
graph.diagnostics.push({
|
|
976
|
+
severity: 'error',
|
|
977
|
+
message: `Undeclared relationship identifier "${entry.relationshipId}" in relationships block`,
|
|
978
|
+
source: relBlock.source,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
730
986
|
processBody(
|
|
731
987
|
graph,
|
|
732
988
|
universeName,
|
|
@@ -739,8 +995,10 @@ function processBody(
|
|
|
739
995
|
reposMap,
|
|
740
996
|
refsMap,
|
|
741
997
|
entityKinds,
|
|
998
|
+
relationshipsByScope,
|
|
742
999
|
pendingReferenceDecls,
|
|
743
1000
|
pendingReferenceAttachments,
|
|
1001
|
+
pendingContainerResolutions,
|
|
744
1002
|
);
|
|
745
1003
|
} else if (decl.kind === 'relates') {
|
|
746
1004
|
// Check for duplicate relates in reverse order
|
|
@@ -825,6 +1083,8 @@ function processBody(
|
|
|
825
1083
|
source: decl.source,
|
|
826
1084
|
endpoints: [], // Will be populated during resolution
|
|
827
1085
|
unresolvedEndpoints: [decl.a, decl.b], // Will be cleared during resolution
|
|
1086
|
+
spelledKind: decl.spelledKind,
|
|
1087
|
+
aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
|
|
828
1088
|
};
|
|
829
1089
|
|
|
830
1090
|
// Add top-level describe if present
|
|
@@ -925,32 +1185,15 @@ function processBody(
|
|
|
925
1185
|
|
|
926
1186
|
graph.nodes[finalRelatesNodeId] = relatesNode;
|
|
927
1187
|
graph.nodes[parentNodeId].children.push(finalRelatesNodeId);
|
|
928
|
-
|
|
929
|
-
// Also create edge for backward compatibility
|
|
930
|
-
const edgeId = makeEdgeId(universeName, decl.a, decl.b, index);
|
|
931
|
-
const edge = {
|
|
932
|
-
id: edgeId,
|
|
933
|
-
kind: 'relates',
|
|
934
|
-
a: { text: decl.a },
|
|
935
|
-
b: { text: decl.b },
|
|
936
|
-
source: decl.source,
|
|
937
|
-
};
|
|
938
|
-
|
|
939
|
-
if (describeBlocks.length > 0) {
|
|
940
|
-
edge.describe = {
|
|
941
|
-
raw: describeBlocks[0].raw,
|
|
942
|
-
normalized: normalizeProseBlock(describeBlocks[0].raw),
|
|
943
|
-
source: describeBlocks[0].source,
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
graph.edges[edgeId] = edge;
|
|
948
1188
|
} else if (decl.kind === 'describe') {
|
|
949
1189
|
// Describe blocks are attached to their parent node
|
|
950
1190
|
// This is handled in createNode
|
|
951
1191
|
} else if (decl.kind === 'title') {
|
|
952
1192
|
// Title blocks are attached to their parent node
|
|
953
1193
|
// This is handled in createNode
|
|
1194
|
+
} else if (decl.kind === 'relationships') {
|
|
1195
|
+
// Relationships blocks are attached to their parent node
|
|
1196
|
+
// This is handled in createNode
|
|
954
1197
|
} else if (decl.kind === 'references') {
|
|
955
1198
|
if (pendingReferenceAttachments) {
|
|
956
1199
|
pendingReferenceAttachments.push({
|
|
@@ -1112,18 +1355,27 @@ function processBody(
|
|
|
1112
1355
|
graph.documentsByName[universeName][docName] = docModel;
|
|
1113
1356
|
}
|
|
1114
1357
|
}
|
|
1358
|
+
} else if (decl.kind === 'relationship') {
|
|
1359
|
+
// Relationship declarations are tracked in relationshipsByScope but don't create nodes
|
|
1360
|
+
// They're just declarations that can be referenced in relationships blocks
|
|
1115
1361
|
} else {
|
|
1116
1362
|
// UnknownBlock - attach to current node (the node whose body contains this block)
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1363
|
+
// Only process if it's actually an UnknownBlock (has keyword property, not kind)
|
|
1364
|
+
// Blocks with 'kind' property are known block types that should be handled above
|
|
1365
|
+
if (decl.keyword && !decl.kind) {
|
|
1366
|
+
const currentNode = graph.nodes[currentNodeId];
|
|
1367
|
+
if (!currentNode.unknownBlocks) {
|
|
1368
|
+
currentNode.unknownBlocks = [];
|
|
1369
|
+
}
|
|
1370
|
+
currentNode.unknownBlocks.push({
|
|
1371
|
+
keyword: decl.keyword,
|
|
1372
|
+
raw: decl.raw,
|
|
1373
|
+
normalized: normalizeProseBlock(decl.raw),
|
|
1374
|
+
source: decl.source,
|
|
1375
|
+
});
|
|
1120
1376
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
raw: decl.raw,
|
|
1124
|
-
normalized: normalizeProseBlock(decl.raw),
|
|
1125
|
-
source: decl.source,
|
|
1126
|
-
});
|
|
1377
|
+
// If it has a 'kind' property, it's a known block type that should have been handled above
|
|
1378
|
+
// Skip it silently (it may have been handled elsewhere, e.g., in createNode)
|
|
1127
1379
|
}
|
|
1128
1380
|
}
|
|
1129
1381
|
|
|
@@ -1170,6 +1422,8 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
1170
1422
|
parent: parentNodeId,
|
|
1171
1423
|
children: [],
|
|
1172
1424
|
source: decl.source,
|
|
1425
|
+
spelledKind: decl.spelledKind,
|
|
1426
|
+
aliases: decl.aliases && Object.keys(decl.aliases).length > 0 ? decl.aliases : undefined,
|
|
1173
1427
|
};
|
|
1174
1428
|
|
|
1175
1429
|
// Always set container for book/chapter nodes (may be undefined if not resolved)
|
|
@@ -1200,6 +1454,34 @@ function createNode(nodeId, kind, name, parentNodeId, decl, containerNodeId) {
|
|
|
1200
1454
|
}
|
|
1201
1455
|
}
|
|
1202
1456
|
|
|
1457
|
+
// Extract relationships block if present
|
|
1458
|
+
const relationshipsBlock = decl.body?.find((b) => b.kind === 'relationships');
|
|
1459
|
+
if (relationshipsBlock) {
|
|
1460
|
+
if (relationshipsBlock.entries) {
|
|
1461
|
+
// New syntax: relationship ID + targets
|
|
1462
|
+
node.relationships = {
|
|
1463
|
+
entries: relationshipsBlock.entries.map((entry) => ({
|
|
1464
|
+
relationshipId: entry.relationshipId,
|
|
1465
|
+
targets: entry.targets.map((target) => ({
|
|
1466
|
+
id: target.id,
|
|
1467
|
+
metadata: target.metadata ? {
|
|
1468
|
+
raw: target.metadata.raw,
|
|
1469
|
+
normalized: normalizeProseBlock(target.metadata.raw),
|
|
1470
|
+
source: target.metadata.source,
|
|
1471
|
+
} : undefined,
|
|
1472
|
+
})),
|
|
1473
|
+
})),
|
|
1474
|
+
source: relationshipsBlock.source,
|
|
1475
|
+
};
|
|
1476
|
+
} else if (relationshipsBlock.values) {
|
|
1477
|
+
// String literals syntax (for relates blocks)
|
|
1478
|
+
node.relationships = {
|
|
1479
|
+
values: relationshipsBlock.values,
|
|
1480
|
+
source: relationshipsBlock.source,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1203
1485
|
// Note: UnknownBlocks are handled in processBody and attached to the parent node
|
|
1204
1486
|
// They are not extracted here to avoid duplication
|
|
1205
1487
|
|
|
@@ -1267,60 +1549,7 @@ function checkDuplicateRelates(graph, universeName, a, b, source) {
|
|
|
1267
1549
|
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
1268
1550
|
*/
|
|
1269
1551
|
function resolveEdges(graph, scopeIndex) {
|
|
1270
|
-
//
|
|
1271
|
-
// (edges are created for backward compatibility alongside relates nodes)
|
|
1272
|
-
const relatesNodeNames = new Set();
|
|
1273
|
-
for (const nodeId in graph.nodes) {
|
|
1274
|
-
const node = graph.nodes[nodeId];
|
|
1275
|
-
if (node.kind === 'relates') {
|
|
1276
|
-
relatesNodeNames.add(node.name);
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
// Resolve edges (for backward compatibility)
|
|
1281
|
-
for (const edgeId in graph.edges) {
|
|
1282
|
-
const edge = graph.edges[edgeId];
|
|
1283
|
-
const universeName = edgeId.split(':')[0];
|
|
1284
|
-
const universeNodeId = `${universeName}:universe:${universeName}`;
|
|
1285
|
-
|
|
1286
|
-
// Check if this edge corresponds to a relates node
|
|
1287
|
-
// If so, skip warnings here (they'll be generated during relates node resolution)
|
|
1288
|
-
const edgeName = `${edge.a.text} and ${edge.b.text}`;
|
|
1289
|
-
const reverseEdgeName = `${edge.b.text} and ${edge.a.text}`;
|
|
1290
|
-
const hasRelatesNode = relatesNodeNames.has(edgeName) || relatesNodeNames.has(reverseEdgeName);
|
|
1291
|
-
|
|
1292
|
-
// Resolve endpoint A - start from universe scope
|
|
1293
|
-
const resolvedA = resolveRelatesEndpoint(graph, scopeIndex, edge.a.text, universeNodeId, edge.source);
|
|
1294
|
-
if (resolvedA.nodeId && !resolvedA.ambiguous) {
|
|
1295
|
-
edge.a.target = resolvedA.nodeId;
|
|
1296
|
-
} else if (!hasRelatesNode) {
|
|
1297
|
-
// Only warn if there's no corresponding relates node (legacy edge-only format)
|
|
1298
|
-
if (!resolvedA.ambiguous) {
|
|
1299
|
-
graph.diagnostics.push({
|
|
1300
|
-
severity: 'warning',
|
|
1301
|
-
message: `Unresolved relates endpoint "${edge.a.text}" in universe "${universeName}"`,
|
|
1302
|
-
source: edge.source,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
// Resolve endpoint B - start from universe scope
|
|
1308
|
-
const resolvedB = resolveRelatesEndpoint(graph, scopeIndex, edge.b.text, universeNodeId, edge.source);
|
|
1309
|
-
if (resolvedB.nodeId && !resolvedB.ambiguous) {
|
|
1310
|
-
edge.b.target = resolvedB.nodeId;
|
|
1311
|
-
} else if (!hasRelatesNode) {
|
|
1312
|
-
// Only warn if there's no corresponding relates node (legacy edge-only format)
|
|
1313
|
-
if (!resolvedB.ambiguous) {
|
|
1314
|
-
graph.diagnostics.push({
|
|
1315
|
-
severity: 'warning',
|
|
1316
|
-
message: `Unresolved relates endpoint "${edge.b.text}" in universe "${universeName}"`,
|
|
1317
|
-
source: edge.source,
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// Resolve relates nodes
|
|
1552
|
+
// Resolve relates node endpoints
|
|
1324
1553
|
for (const nodeId in graph.nodes) {
|
|
1325
1554
|
const node = graph.nodes[nodeId];
|
|
1326
1555
|
if (node.kind === 'relates' && node.unresolvedEndpoints) {
|
|
@@ -1433,6 +1662,162 @@ function resolveContainers(graph, scopeIndex, pending) {
|
|
|
1433
1662
|
}
|
|
1434
1663
|
}
|
|
1435
1664
|
|
|
1665
|
+
/**
|
|
1666
|
+
* Extracts asserted edges from both relationships {} blocks and relates nodes
|
|
1667
|
+
* @param {UniverseGraph} graph - The graph
|
|
1668
|
+
* @param {Map<string, Map<string, string[]>>} scopeIndex - Scope index
|
|
1669
|
+
* @returns {import('./ir.js').EdgeAssertedModel[]}
|
|
1670
|
+
*/
|
|
1671
|
+
function extractAssertedEdges(graph, scopeIndex) {
|
|
1672
|
+
/** @type {import('./ir.js').EdgeAssertedModel[]} */
|
|
1673
|
+
const assertedEdges = [];
|
|
1674
|
+
|
|
1675
|
+
// Extract from relationships {} blocks (new-style adjacency lists)
|
|
1676
|
+
for (const nodeId in graph.nodes) {
|
|
1677
|
+
const node = graph.nodes[nodeId];
|
|
1678
|
+
if (!node.relationships || !node.relationships.entries) continue;
|
|
1679
|
+
|
|
1680
|
+
// Resolve each target and create asserted edge
|
|
1681
|
+
for (const entry of node.relationships.entries) {
|
|
1682
|
+
for (const target of entry.targets) {
|
|
1683
|
+
// Resolve target ID using scope resolution
|
|
1684
|
+
const resolved = resolveNameInScope(
|
|
1685
|
+
graph,
|
|
1686
|
+
scopeIndex,
|
|
1687
|
+
target.id,
|
|
1688
|
+
nodeId,
|
|
1689
|
+
node.relationships.source,
|
|
1690
|
+
);
|
|
1691
|
+
const targetNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
|
|
1692
|
+
if (!targetNodeId) continue;
|
|
1693
|
+
|
|
1694
|
+
assertedEdges.push({
|
|
1695
|
+
from: nodeId,
|
|
1696
|
+
via: entry.relationshipId,
|
|
1697
|
+
to: targetNodeId,
|
|
1698
|
+
meta: target.metadata,
|
|
1699
|
+
source: node.relationships.source,
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Extract from relates nodes (bidirectional relationships)
|
|
1706
|
+
// Note: Relates are bidirectional, but we create only ONE edge entry
|
|
1707
|
+
// The UI should traverse edges bidirectionally to show the relationship from both perspectives
|
|
1708
|
+
//
|
|
1709
|
+
// Metadata behavior: edge.meta contains metadata from endpointA's perspective (from block at endpointA).
|
|
1710
|
+
// When viewing endpointB, the UI should look up endpointB-specific metadata from the relates node's
|
|
1711
|
+
// from[endpointB] block. This allows different descriptions for the same relationship from each endpoint's view.
|
|
1712
|
+
for (const nodeId in graph.nodes) {
|
|
1713
|
+
const node = graph.nodes[nodeId];
|
|
1714
|
+
if (node.kind !== 'relates' || !node.endpoints || node.endpoints.length !== 2) continue;
|
|
1715
|
+
|
|
1716
|
+
const [endpointA, endpointB] = node.endpoints;
|
|
1717
|
+
|
|
1718
|
+
// Use the relationship label from the relates node if available
|
|
1719
|
+
const viaLabel = node.relationships?.values?.[0] || 'related to';
|
|
1720
|
+
|
|
1721
|
+
// Create a single bidirectional edge (A -> B)
|
|
1722
|
+
// The UI should show this relationship when viewing either endpoint
|
|
1723
|
+
// Metadata is from endpointA's perspective; UI will look up endpointB-specific metadata when needed
|
|
1724
|
+
assertedEdges.push({
|
|
1725
|
+
from: endpointA,
|
|
1726
|
+
via: viaLabel, // Note: relates don't use relationship declarations, they use string labels
|
|
1727
|
+
to: endpointB,
|
|
1728
|
+
meta: node.from?.[endpointA]?.describe, // Metadata from endpointA's perspective
|
|
1729
|
+
source: node.source,
|
|
1730
|
+
bidirectional: true, // Mark as bidirectional so UI can traverse both directions
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
return assertedEdges;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/**
|
|
1738
|
+
* Normalizes edges by adding inverse edges for paired/symmetric relationships
|
|
1739
|
+
* @param {import('./ir.js').EdgeAssertedModel[]} assertedEdges - Asserted edges
|
|
1740
|
+
* @param {Map<string, Map<string, import('./ast.js').RelationshipDecl>>} relationshipDecls - Relationship declarations by universe
|
|
1741
|
+
* @param {string} universeName - Universe name
|
|
1742
|
+
* @returns {import('./ir.js').NormalizedEdgeModel[]}
|
|
1743
|
+
*/
|
|
1744
|
+
function normalizeEdges(assertedEdges, relationshipDecls, universeName) {
|
|
1745
|
+
/** @type {import('./ir.js').NormalizedEdgeModel[]} */
|
|
1746
|
+
const normalizedEdges = [];
|
|
1747
|
+
const relDeclsMap = relationshipDecls.get(universeName);
|
|
1748
|
+
const relDecls = relDeclsMap ? Object.fromEntries(relDeclsMap) : {};
|
|
1749
|
+
const seenEdges = new Set(); // Track edges to avoid duplicates
|
|
1750
|
+
|
|
1751
|
+
for (const asserted of assertedEdges) {
|
|
1752
|
+
const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
|
|
1753
|
+
|
|
1754
|
+
// Skip if we've already added this exact edge (can happen with relates bidirectional edges)
|
|
1755
|
+
if (seenEdges.has(edgeKey)) continue;
|
|
1756
|
+
seenEdges.add(edgeKey);
|
|
1757
|
+
|
|
1758
|
+
// Get relationship declaration if this is a declared relationship
|
|
1759
|
+
// Note: relates edges use string labels, not relationship IDs, so they won't have a relDecl
|
|
1760
|
+
const relDecl = relDecls[asserted.via];
|
|
1761
|
+
|
|
1762
|
+
// Add asserted edge
|
|
1763
|
+
normalizedEdges.push({
|
|
1764
|
+
from: asserted.from,
|
|
1765
|
+
via: asserted.via,
|
|
1766
|
+
to: asserted.to,
|
|
1767
|
+
asserted: true,
|
|
1768
|
+
sourceRefs: [asserted.source],
|
|
1769
|
+
meta: asserted.meta,
|
|
1770
|
+
bidirectional: asserted.bidirectional || false, // Preserve bidirectional flag for relates edges
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
// Add inverse edge if applicable (only for declared relationships, not relates)
|
|
1774
|
+
// Relates are already bidirectional, so both directions are in assertedEdges
|
|
1775
|
+
if (relDecl) {
|
|
1776
|
+
if (relDecl.type === 'paired') {
|
|
1777
|
+
// Determine inverse side
|
|
1778
|
+
const inverseVia =
|
|
1779
|
+
asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
|
|
1780
|
+
|
|
1781
|
+
if (inverseVia) {
|
|
1782
|
+
const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
|
|
1783
|
+
// Only add inverse if not already present as an asserted edge
|
|
1784
|
+
if (!seenEdges.has(inverseKey)) {
|
|
1785
|
+
normalizedEdges.push({
|
|
1786
|
+
from: asserted.to,
|
|
1787
|
+
via: inverseVia,
|
|
1788
|
+
to: asserted.from,
|
|
1789
|
+
asserted: false,
|
|
1790
|
+
sourceRefs: [asserted.source], // Points back to asserted edge
|
|
1791
|
+
meta: asserted.meta, // Copy metadata
|
|
1792
|
+
});
|
|
1793
|
+
seenEdges.add(inverseKey);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
} else if (relDecl.type === 'symmetric') {
|
|
1797
|
+
// Symmetric: add reverse edge with same relationship ID
|
|
1798
|
+
const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
|
|
1799
|
+
// Only add reverse if not already present as an asserted edge
|
|
1800
|
+
if (!seenEdges.has(reverseKey)) {
|
|
1801
|
+
normalizedEdges.push({
|
|
1802
|
+
from: asserted.to,
|
|
1803
|
+
via: asserted.via,
|
|
1804
|
+
to: asserted.from,
|
|
1805
|
+
asserted: false,
|
|
1806
|
+
sourceRefs: [asserted.source],
|
|
1807
|
+
meta: asserted.meta,
|
|
1808
|
+
});
|
|
1809
|
+
seenEdges.add(reverseKey);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
// Note: If no relDecl found, it's likely a relates edge (string label, not declared relationship)
|
|
1814
|
+
// Relates edges are bidirectional - the UI should traverse them bidirectionally
|
|
1815
|
+
// by checking both from/to fields, so we only create one edge entry
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
return normalizedEdges;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1436
1821
|
/**
|
|
1437
1822
|
* @param {string | undefined} raw
|
|
1438
1823
|
* @returns {string | undefined}
|
|
@@ -1761,10 +2146,47 @@ function deriveReferenceName(decl) {
|
|
|
1761
2146
|
* @param {string} name
|
|
1762
2147
|
* @returns {string}
|
|
1763
2148
|
*/
|
|
1764
|
-
function makeNodeId(universeName, kind, name) {
|
|
2149
|
+
function makeNodeId(universeName, kind, name, containerPath = null) {
|
|
2150
|
+
if (containerPath) {
|
|
2151
|
+
return `${universeName}:${kind}:${containerPath}:${name}`;
|
|
2152
|
+
}
|
|
1765
2153
|
return `${universeName}:${kind}:${name}`;
|
|
1766
2154
|
}
|
|
1767
2155
|
|
|
2156
|
+
/**
|
|
2157
|
+
* Builds a container path string from a node's parent chain
|
|
2158
|
+
* @param {UniverseGraph} graph
|
|
2159
|
+
* @param {string} containerNodeId - The container node ID
|
|
2160
|
+
* @returns {string | null} - Container path like "series:Alchemy:book:Materials" or null if no container
|
|
2161
|
+
*/
|
|
2162
|
+
function buildContainerPath(graph, containerNodeId) {
|
|
2163
|
+
if (!containerNodeId) return null;
|
|
2164
|
+
|
|
2165
|
+
const containerNode = graph.nodes[containerNodeId];
|
|
2166
|
+
// If container node doesn't exist yet (e.g., forward reference), return null
|
|
2167
|
+
// The caller should handle this case appropriately
|
|
2168
|
+
if (!containerNode) return null;
|
|
2169
|
+
|
|
2170
|
+
const pathParts = [];
|
|
2171
|
+
let current = containerNode;
|
|
2172
|
+
|
|
2173
|
+
// Walk up the parent chain, collecting series and book names
|
|
2174
|
+
while (current) {
|
|
2175
|
+
if (current.kind === 'series' || current.kind === 'book') {
|
|
2176
|
+
pathParts.unshift(current.kind, current.name);
|
|
2177
|
+
}
|
|
2178
|
+
if (current.parent) {
|
|
2179
|
+
current = graph.nodes[current.parent];
|
|
2180
|
+
// Safety check: if parent node doesn't exist, stop walking
|
|
2181
|
+
if (!current) break;
|
|
2182
|
+
} else {
|
|
2183
|
+
break;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
return pathParts.length > 0 ? pathParts.join(':') : null;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
1768
2190
|
/**
|
|
1769
2191
|
* Creates a relates node ID (deterministic, endpoints in source order)
|
|
1770
2192
|
* @param {string} universeName
|