@sprig-and-prose/sprig-universe 0.4.1 → 0.4.2

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.
@@ -0,0 +1,1619 @@
1
+ /**
2
+ * @fileoverview Multi-pass resolver for building UniverseGraph from parsed AST
3
+ * Consumes NEW parser AST format (FileAST with kind:'file', decls:[...])
4
+ */
5
+
6
+ import { normalizeProseBlock } from '../util/text.js';
7
+
8
+ /**
9
+ * @typedef {import('../ir.js').UniverseGraph} UniverseGraph
10
+ * @typedef {import('../ir.js').NodeModel} NodeModel
11
+ * @typedef {import('../ir.js').EdgeModel} EdgeModel
12
+ * @typedef {import('../ir.js').UniverseModel} UniverseModel
13
+ * @typedef {import('../ir.js').TextBlock} TextBlock
14
+ * @typedef {import('../ir.js').UnknownBlock} UnknownBlock
15
+ * @typedef {import('../ir.js').Diagnostic} Diagnostic
16
+ * @typedef {import('../ir.js').FromView} FromView
17
+ * @typedef {import('../ir.js').EdgeAssertedModel} EdgeAssertedModel
18
+ * @typedef {import('../ir.js').NormalizedEdgeModel} NormalizedEdgeModel
19
+ * @typedef {import('../ir.js').RepositoryModel} RepositoryModel
20
+ * @typedef {import('../ir.js').ReferenceModel} ReferenceModel
21
+ * @typedef {import('../ir.js').NamedDocumentModel} NamedDocumentModel
22
+ * @typedef {import('../ir.js').RelationshipDeclModel} RelationshipDeclModel
23
+ * @typedef {import('../ir.js').SourceSpan} SourceSpan
24
+ */
25
+
26
+ /**
27
+ * @typedef {string} DeclId - Declaration identifier
28
+ * @typedef {string} ScopeKey - Scope identifier
29
+ * @typedef {string} SeriesDeclId - Series declaration identifier
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} NormalizedDecl
34
+ * @property {DeclId} id - Declaration ID
35
+ * @property {string} kind - Normalized kind ('universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container', 'repository', 'referenceDecl', 'relates', 'relationshipDecl')
36
+ * @property {string} spelledKind - Original spelled kind (may be alias)
37
+ * @property {string} name - Declaration name
38
+ * @property {string} [parentName] - Explicit parent name from 'in' clause
39
+ * @property {any[]} body - Body declarations/blocks
40
+ * @property {SourceSpan} source - Source span
41
+ * @property {DeclId} [syntacticParentId] - Syntactic parent from AST tree
42
+ * @property {Map<string, string>} [aliases] - Alias table for this scope
43
+ */
44
+
45
+ /**
46
+ * @typedef {Object} DeclInfo
47
+ * @property {string} name - Declaration name
48
+ * @property {string} kindSpelled - Spelled kind
49
+ * @property {string} [kindResolved] - Resolved base kind
50
+ * @property {string} [parentRef] - Parent reference name
51
+ * @property {DeclId} [parentDeclId] - Resolved parent declaration ID
52
+ * @property {ScopeKey} scopeKey - Scope key
53
+ * @property {SourceSpan} span - Source span
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} DeclIndex
58
+ * @property {Map<DeclId, DeclInfo>} declIndex - Declaration index
59
+ * @property {Map<ScopeKey, Map<string, DeclId>>} scopeIndex - Scope index (scope -> name -> declId)
60
+ * @property {Map<SeriesDeclId, Map<string, DeclId>>} seriesIndex - Series index (seriesId -> name -> declId)
61
+ * @property {Map<ScopeKey, Map<string, string>>} aliasTablesByScope - Alias tables (scope -> aliasName -> targetBaseKind)
62
+ * @property {Diagnostic[]} diagnostics - Diagnostics from indexing
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} BoundDecl
67
+ * @property {DeclId} id - Declaration ID
68
+ * @property {DeclInfo} info - Declaration info with resolved kind and parent
69
+ * @property {NormalizedDecl} decl - Original normalized declaration
70
+ * @property {DeclId[]} children - Child declaration IDs
71
+ */
72
+
73
+ /**
74
+ * @typedef {Object} RelationshipSchema
75
+ * @property {string} id - Schema identifier
76
+ * @property {'symmetric' | 'paired'} type - Schema type
77
+ * @property {string} [leftId] - Left ID for paired
78
+ * @property {string} [rightId] - Right ID for paired
79
+ * @property {any} decl - Original declaration
80
+ * @property {ScopeKey} scopeKey - Scope where schema is defined
81
+ */
82
+
83
+ /**
84
+ * @typedef {Object} SchemaRegistry
85
+ * @property {Map<string, RelationshipSchema>} schemas - Schemas by ID
86
+ * @property {ScopeKey} scopeKey - Scope key
87
+ */
88
+
89
+ /**
90
+ * Extract raw text from contentSpan using sourceText
91
+ * @param {{ startOffset: number, endOffset: number }} contentSpan
92
+ * @param {string} sourceText
93
+ * @returns {string}
94
+ */
95
+ function extractRawText(contentSpan, sourceText) {
96
+ return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
97
+ }
98
+
99
+ /**
100
+ * Pass 0: Normalize AST - normalize alias/aliases blocks and container declarations
101
+ * @param {Array<{ kind: string, decls: any[], source?: SourceSpan, sourceText?: string }>} fileASTs
102
+ * @returns {Array<{ normalized: NormalizedDecl[], sourceText: string, filePath: string }>}
103
+ */
104
+ function normalizeAst(fileASTs) {
105
+ const normalized = [];
106
+
107
+ for (const fileAST of fileASTs) {
108
+ if (fileAST.kind !== 'file') {
109
+ continue;
110
+ }
111
+
112
+ const sourceText = fileAST.sourceText || '';
113
+ const filePath = fileAST.source?.file || '';
114
+ const normalizedDecls = [];
115
+
116
+ // Track aliases per scope (will be attached to container decls)
117
+ const aliasStack = [new Map()]; // Root scope
118
+
119
+ /**
120
+ * Normalize a declaration recursively
121
+ * @param {any} decl
122
+ * @param {Map<string, string>} currentAliases
123
+ * @param {DeclId} [syntacticParentId]
124
+ * @returns {NormalizedDecl | null}
125
+ */
126
+ function normalizeDecl(decl, currentAliases, syntacticParentId) {
127
+ if (!decl || !decl.kind) {
128
+ return null;
129
+ }
130
+
131
+ // Handle alias declarations
132
+ if (decl.kind === 'alias') {
133
+ currentAliases.set(decl.name, decl.targetKind);
134
+ return null; // Don't create a decl for alias itself
135
+ }
136
+
137
+ // Handle aliases block
138
+ if (decl.kind === 'aliases') {
139
+ for (const alias of decl.aliases || []) {
140
+ currentAliases.set(alias.name, alias.targetKind);
141
+ }
142
+ return null; // Don't create a decl for aliases block itself
143
+ }
144
+
145
+ // Handle container declarations (including aliased ones)
146
+ const containerKinds = ['universe', 'anthology', 'series', 'book', 'chapter', 'concept'];
147
+ const passthroughBlockKinds = new Set([
148
+ 'describe',
149
+ 'title',
150
+ 'note',
151
+ 'relationships',
152
+ 'from',
153
+ 'references',
154
+ 'reference',
155
+ 'label',
156
+ 'ordering',
157
+ 'documentation',
158
+ 'document',
159
+ 'namedDocument',
160
+ 'unknown',
161
+ ]);
162
+ if (containerKinds.includes(decl.kind) || decl.kind === 'container') {
163
+ const normalizedKind = decl.kind === 'container' ? 'container' : decl.kind;
164
+ const spelledKind = decl.spelledKind || decl.kind;
165
+
166
+ // Create new alias map for this scope (inherits parent)
167
+ const scopeAliases = new Map(currentAliases);
168
+
169
+ // Normalize body - collect aliases and normalize children
170
+ const normalizedBody = [];
171
+ for (const child of decl.body || []) {
172
+ if (child.kind === 'alias') {
173
+ scopeAliases.set(child.name, child.targetKind);
174
+ continue;
175
+ }
176
+ if (child.kind === 'aliases') {
177
+ for (const alias of (child.aliases || [])) {
178
+ scopeAliases.set(alias.name, alias.targetKind);
179
+ }
180
+ continue;
181
+ }
182
+ if (child.kind && passthroughBlockKinds.has(child.kind)) {
183
+ normalizedBody.push(child);
184
+ continue;
185
+ }
186
+ const normalizedChild = normalizeDecl(child, scopeAliases, undefined); // Will set parent later
187
+ if (normalizedChild) {
188
+ normalizedBody.push(normalizedChild);
189
+ }
190
+ }
191
+
192
+ // Generate ID (temporary, will be regenerated in Pass 1)
193
+ const tempId = `${decl.kind}:${decl.name}`;
194
+
195
+ return {
196
+ id: tempId,
197
+ kind: normalizedKind,
198
+ spelledKind,
199
+ name: decl.name,
200
+ parentName: decl.parentName,
201
+ body: normalizedBody,
202
+ source: decl.source || decl.span,
203
+ syntacticParentId,
204
+ aliases: scopeAliases.size > 0 ? scopeAliases : undefined,
205
+ };
206
+ }
207
+
208
+ // Handle relates declarations
209
+ if (decl.kind === 'relates') {
210
+ const tempId = `relates:${decl.a}:${decl.b}`;
211
+ return {
212
+ id: tempId,
213
+ kind: 'relates',
214
+ spelledKind: decl.spelledKind || 'relates',
215
+ name: `${decl.a} and ${decl.b}`,
216
+ parentName: undefined,
217
+ body: decl.body || [],
218
+ source: decl.source || decl.span,
219
+ syntacticParentId,
220
+ };
221
+ }
222
+
223
+ // Handle relationship declarations
224
+ if (decl.kind === 'relationshipDecl') {
225
+ const tempId = `relationship:${decl.ids.join(':')}`;
226
+ return {
227
+ id: tempId,
228
+ kind: 'relationshipDecl',
229
+ spelledKind: 'relationship',
230
+ name: decl.ids.join(' and '),
231
+ parentName: undefined,
232
+ body: decl.body || [],
233
+ source: decl.span,
234
+ syntacticParentId,
235
+ relationshipIds: decl.ids, // Preserve original IDs
236
+ };
237
+ }
238
+
239
+ // Handle repository declarations
240
+ if (decl.kind === 'repository') {
241
+ const tempId = `repository:${decl.name}`;
242
+ return {
243
+ id: tempId,
244
+ kind: 'repository',
245
+ spelledKind: decl.spelledKind || 'repository',
246
+ name: decl.name,
247
+ parentName: decl.parentName,
248
+ body: decl.children || [],
249
+ source: decl.source || decl.span,
250
+ syntacticParentId,
251
+ };
252
+ }
253
+
254
+ // Handle reference declarations
255
+ if (decl.kind === 'referenceDecl') {
256
+ const tempId = `reference:${decl.name}`;
257
+ return {
258
+ id: tempId,
259
+ kind: 'referenceDecl',
260
+ spelledKind: 'reference',
261
+ name: decl.name,
262
+ repositoryName: decl.repositoryName,
263
+ parentName: undefined,
264
+ body: decl.children || [],
265
+ source: decl.source || decl.span,
266
+ syntacticParentId,
267
+ };
268
+ }
269
+
270
+ // Unknown/other - skip for now
271
+ return null;
272
+ }
273
+
274
+ // Normalize all top-level declarations
275
+ for (const decl of fileAST.decls || []) {
276
+ const normalizedDecl = normalizeDecl(decl, aliasStack[0], undefined);
277
+ if (normalizedDecl) {
278
+ // Set syntactic parents for children
279
+ setSyntacticParents(normalizedDecl);
280
+ normalizedDecls.push(normalizedDecl);
281
+ }
282
+ }
283
+
284
+ normalized.push({
285
+ normalized: normalizedDecls,
286
+ sourceText,
287
+ filePath,
288
+ });
289
+ }
290
+
291
+ return normalized;
292
+ }
293
+
294
+ /**
295
+ * Set syntactic parent IDs recursively
296
+ * @param {NormalizedDecl} decl
297
+ */
298
+ function setSyntacticParents(decl) {
299
+ const declId = decl.id;
300
+ for (const child of decl.body || []) {
301
+ if (child.kind && ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container', 'relates', 'relationshipDecl', 'repository', 'referenceDecl'].includes(child.kind)) {
302
+ child.syntacticParentId = declId;
303
+ setSyntacticParents(child);
304
+ }
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Generate a deterministic DeclId
310
+ * @param {string} universeName
311
+ * @param {string} kind
312
+ * @param {string} name
313
+ * @param {string} [containerPath]
314
+ * @returns {DeclId}
315
+ */
316
+ function makeDeclId(universeName, kind, name, containerPath) {
317
+ if (containerPath) {
318
+ return `${universeName}:${kind}:${containerPath}:${name}`;
319
+ }
320
+ return `${universeName}:${kind}:${name}`;
321
+ }
322
+
323
+ /**
324
+ * Pass 1: Index declarations - create DeclIds and build indexes
325
+ * @param {Array<{ normalized: NormalizedDecl[], sourceText: string, filePath: string }>} normalized
326
+ * @returns {{ index: DeclIndex, normalizedDecls: NormalizedDecl[] }}
327
+ */
328
+ function indexDecls(normalized) {
329
+ const declIndex = new Map();
330
+ const scopeIndex = new Map();
331
+ const seriesIndex = new Map();
332
+ const aliasTablesByScope = new Map();
333
+ const diagnostics = [];
334
+ const idMap = new Map(); // old temp ID -> new proper ID
335
+
336
+ // First pass: collect all universe names
337
+ const universeNames = new Set();
338
+ for (const file of normalized) {
339
+ for (const decl of file.normalized) {
340
+ if (decl.kind === 'universe') {
341
+ universeNames.add(decl.name);
342
+ }
343
+ }
344
+ }
345
+
346
+ // Collect all normalized decls
347
+ const allNormalizedDecls = [];
348
+
349
+ // Validate: exactly one universe
350
+ if (universeNames.size === 0) {
351
+ diagnostics.push({
352
+ severity: 'error',
353
+ message: 'No universe declaration found',
354
+ source: undefined,
355
+ });
356
+ return { index: { declIndex, scopeIndex, seriesIndex, aliasTablesByScope, diagnostics }, normalizedDecls: [] };
357
+ }
358
+
359
+ if (universeNames.size > 1) {
360
+ const names = Array.from(universeNames).join(', ');
361
+ diagnostics.push({
362
+ severity: 'error',
363
+ message: `Multiple distinct universes found: ${names}. All files must declare the same universe name.`,
364
+ source: undefined,
365
+ });
366
+ }
367
+
368
+ const universeName = Array.from(universeNames)[0];
369
+
370
+ /**
371
+ * Index a declaration recursively
372
+ * @param {NormalizedDecl} decl
373
+ * @param {ScopeKey} currentScopeKey
374
+ * @param {SeriesDeclId | null} currentSeriesId
375
+ */
376
+ function indexDecl(decl, currentScopeKey, currentSeriesId) {
377
+ allNormalizedDecls.push(decl);
378
+ const oldId = decl.id; // Store old temp ID before we change it
379
+ // Generate proper DeclId
380
+ let declId;
381
+ if (decl.kind === 'universe') {
382
+ declId = makeDeclId(decl.name, 'universe', decl.name);
383
+ } else if (decl.kind === 'relates') {
384
+ declId = makeDeclId(universeName, 'relates', `${decl.name}`);
385
+ } else if (decl.kind === 'relationshipDecl') {
386
+ declId = makeDeclId(universeName, 'relationship', decl.name);
387
+ } else if (decl.kind === 'repository') {
388
+ declId = makeDeclId(universeName, 'repository', decl.name);
389
+ } else if (decl.kind === 'referenceDecl') {
390
+ declId = makeDeclId(universeName, 'reference', decl.name);
391
+ } else {
392
+ declId = makeDeclId(universeName, decl.kind, decl.name);
393
+ }
394
+
395
+ // Update decl with proper ID and map old -> new
396
+ idMap.set(oldId, declId);
397
+ decl.id = declId;
398
+
399
+ // Determine scope key
400
+ let scopeKey = currentScopeKey;
401
+ if (decl.kind === 'universe') {
402
+ scopeKey = `universe:${decl.name}`;
403
+ } else if (decl.kind === 'anthology' || decl.kind === 'series') {
404
+ scopeKey = `${decl.kind}:${decl.name}`;
405
+ }
406
+
407
+ // Update series ID if this is a series
408
+ let seriesId = currentSeriesId;
409
+ if (decl.kind === 'series') {
410
+ seriesId = declId;
411
+ }
412
+
413
+ // Store alias table for this scope
414
+ if (decl.aliases && decl.aliases.size > 0) {
415
+ aliasTablesByScope.set(scopeKey, decl.aliases);
416
+ }
417
+
418
+ // Create DeclInfo
419
+ const info = {
420
+ name: decl.name,
421
+ kindSpelled: decl.spelledKind,
422
+ parentRef: decl.parentName,
423
+ scopeKey,
424
+ span: decl.source,
425
+ };
426
+
427
+ // Check for duplicate names in scope
428
+ if (!scopeIndex.has(scopeKey)) {
429
+ scopeIndex.set(scopeKey, new Map());
430
+ }
431
+ const scopeMap = scopeIndex.get(scopeKey);
432
+ if (scopeMap.has(decl.name)) {
433
+ const existingId = scopeMap.get(decl.name);
434
+ const existingInfo = declIndex.get(existingId);
435
+ diagnostics.push({
436
+ severity: 'error',
437
+ message: `Duplicate name "${decl.name}" in scope "${scopeKey}": already defined as ${existingInfo?.kindSpelled || 'unknown'}, now also defined as ${decl.spelledKind}`,
438
+ source: decl.source,
439
+ });
440
+ diagnostics.push({
441
+ severity: 'error',
442
+ message: `First declaration of "${decl.name}" was here.`,
443
+ source: existingInfo?.span,
444
+ });
445
+ } else {
446
+ scopeMap.set(decl.name, declId);
447
+ }
448
+
449
+ // Add to series index if inside a series
450
+ if (seriesId) {
451
+ if (!seriesIndex.has(seriesId)) {
452
+ seriesIndex.set(seriesId, new Map());
453
+ }
454
+ seriesIndex.get(seriesId).set(decl.name, declId);
455
+ }
456
+
457
+ // Store in declIndex
458
+ declIndex.set(declId, info);
459
+
460
+ // Index children
461
+ for (const child of decl.body || []) {
462
+ if (child.kind && ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container', 'relates', 'relationshipDecl', 'repository', 'referenceDecl'].includes(child.kind)) {
463
+ indexDecl(child, scopeKey, seriesId);
464
+ }
465
+ }
466
+ }
467
+
468
+ // Index all declarations
469
+ // First pass: index universe and its children
470
+ for (const file of normalized) {
471
+ for (const decl of file.normalized) {
472
+ if (decl.kind === 'universe') {
473
+ indexDecl(decl, `universe:${decl.name}`, null);
474
+ }
475
+ }
476
+ }
477
+
478
+ // Second pass: index top-level declarations that reference the universe
479
+ // These are declarations with 'in' clauses that eventually reference universe entities
480
+ const universeScopeKey = `universe:${universeName}`;
481
+ for (const file of normalized) {
482
+ for (const decl of file.normalized) {
483
+ // Skip universe (already indexed) and declarations that are children of universe
484
+ if (decl.kind === 'universe') continue;
485
+ if (decl.syntacticParentId) continue; // Already indexed as child
486
+
487
+ // Index top-level declarations (they're in universe scope by default)
488
+ indexDecl(decl, universeScopeKey, null);
489
+ }
490
+ }
491
+
492
+ // Update all syntacticParentId references to use new IDs
493
+ function updateSyntacticParents(decl) {
494
+ if (decl.syntacticParentId) {
495
+ const newParentId = idMap.get(decl.syntacticParentId);
496
+ if (newParentId) {
497
+ decl.syntacticParentId = newParentId;
498
+ } else {
499
+ // Parent not found - might be an error, but don't crash
500
+ decl.syntacticParentId = undefined;
501
+ }
502
+ }
503
+ // Recursively update children
504
+ for (const child of decl.body || []) {
505
+ if (child.kind && ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container', 'relates', 'relationshipDecl', 'repository', 'referenceDecl'].includes(child.kind)) {
506
+ updateSyntacticParents(child);
507
+ }
508
+ }
509
+ }
510
+
511
+ for (const file of normalized) {
512
+ for (const decl of file.normalized) {
513
+ updateSyntacticParents(decl);
514
+ }
515
+ }
516
+
517
+ return { index: { declIndex, scopeIndex, seriesIndex, aliasTablesByScope, diagnostics }, normalizedDecls: allNormalizedDecls };
518
+ }
519
+
520
+ /**
521
+ * Find nearest enclosing series for a declaration
522
+ * @param {DeclId} declId
523
+ * @param {Map<DeclId, DeclInfo>} declIndex
524
+ * @param {Map<DeclId, DeclId>} parentMap
525
+ * @returns {SeriesDeclId | null}
526
+ */
527
+ function findNearestSeries(declId, declIndex, parentMap) {
528
+ let current = declId;
529
+ const visited = new Set();
530
+
531
+ while (current && !visited.has(current)) {
532
+ visited.add(current);
533
+ const info = declIndex.get(current);
534
+ if (info && info.kindResolved === 'series') {
535
+ return current;
536
+ }
537
+ current = parentMap.get(current);
538
+ }
539
+
540
+ return null;
541
+ }
542
+
543
+ /**
544
+ * Resolve unqualified name using series-scoped → global disambiguation
545
+ * @param {string} name
546
+ * @param {DeclId} currentDeclId
547
+ * @param {DeclIndex} index
548
+ * @param {Map<DeclId, DeclId>} parentMap
549
+ * @param {SourceSpan} [source]
550
+ * @returns {{ declId: DeclId | null, ambiguous: boolean }}
551
+ */
552
+ function resolveUnqualified(name, currentDeclId, index, parentMap, source) {
553
+ const { declIndex, scopeIndex, seriesIndex } = index;
554
+
555
+ // Find nearest enclosing series
556
+ const seriesId = findNearestSeries(currentDeclId, declIndex, parentMap);
557
+
558
+ // If inside a series, check series index first
559
+ if (seriesId) {
560
+ const seriesMap = seriesIndex.get(seriesId);
561
+ if (seriesMap && seriesMap.has(name)) {
562
+ return { declId: seriesMap.get(name), ambiguous: false };
563
+ }
564
+ }
565
+
566
+ // Search all scopes for the name
567
+ const matches = [];
568
+ for (const [scopeKey, scopeMap] of scopeIndex.entries()) {
569
+ if (scopeMap.has(name)) {
570
+ const declId = scopeMap.get(name);
571
+ matches.push({ scopeKey, declId });
572
+ }
573
+ }
574
+
575
+ if (matches.length === 0) {
576
+ return { declId: null, ambiguous: false };
577
+ }
578
+
579
+ if (matches.length === 1) {
580
+ // Single unique match - return it
581
+ return { declId: matches[0].declId, ambiguous: false };
582
+ }
583
+
584
+ // Multiple matches - check if they're all the same declId (can happen if name appears in multiple scopes)
585
+ const uniqueDeclIds = new Set(matches.map(m => m.declId));
586
+ if (uniqueDeclIds.size === 1) {
587
+ // All matches point to the same declaration - return it
588
+ return { declId: matches[0].declId, ambiguous: false };
589
+ }
590
+
591
+ // Multiple distinct matches - require qualified path
592
+ if (source) {
593
+ // Note: diagnostics will be added by caller
594
+ }
595
+ return { declId: null, ambiguous: true };
596
+ }
597
+
598
+ /**
599
+ * Resolve qualified path (e.g., "Series.Book.Chapter")
600
+ * @param {string[]} pathSegments
601
+ * @param {DeclIndex} index
602
+ * @param {SourceSpan} [source]
603
+ * @returns {{ declId: DeclId | null, error?: string }}
604
+ */
605
+ function resolveQualified(pathSegments, index, source) {
606
+ const { scopeIndex, declIndex } = index;
607
+
608
+ if (pathSegments.length === 0) {
609
+ return { declId: null, error: 'Empty qualified path' };
610
+ }
611
+
612
+ // Start from universe root
613
+ const universeScopeKey = Array.from(scopeIndex.keys()).find(k => k.startsWith('universe:'));
614
+ if (!universeScopeKey) {
615
+ return { declId: null, error: 'No universe scope found' };
616
+ }
617
+
618
+ let currentScopeKey = universeScopeKey;
619
+ let currentDeclId = null;
620
+
621
+ for (let i = 0; i < pathSegments.length; i++) {
622
+ const segment = pathSegments[i];
623
+ const scopeMap = scopeIndex.get(currentScopeKey);
624
+
625
+ if (!scopeMap || !scopeMap.has(segment)) {
626
+ return { declId: null, error: `Segment "${segment}" not found in scope "${currentScopeKey}"` };
627
+ }
628
+
629
+ currentDeclId = scopeMap.get(segment);
630
+ const info = declIndex.get(currentDeclId);
631
+
632
+ if (!info) {
633
+ return { declId: null, error: `Declaration "${segment}" not found in index` };
634
+ }
635
+
636
+ // Update scope for next segment
637
+ if (info.kindResolved === 'universe' || info.kindResolved === 'anthology' || info.kindResolved === 'series') {
638
+ currentScopeKey = info.scopeKey;
639
+ } else {
640
+ // For non-scope containers, use their parent's scope
641
+ // This handles cases like book.chapter where book doesn't introduce a scope
642
+ currentScopeKey = info.scopeKey;
643
+ }
644
+ }
645
+
646
+ return { declId: currentDeclId };
647
+ }
648
+
649
+ /**
650
+ * Pass 2: Bind parents and resolve kinds
651
+ * @param {DeclIndex} index
652
+ * @param {NormalizedDecl[]} normalizedDecls
653
+ * @returns {{ boundDecls: BoundDecl[], diagnostics: Diagnostic[] }}
654
+ */
655
+ function bindParentsAndKinds(index, normalizedDecls) {
656
+ const { declIndex, scopeIndex, seriesIndex, aliasTablesByScope } = index;
657
+ const boundDecls = [];
658
+ const diagnostics = [...index.diagnostics];
659
+ const parentMap = new Map(); // declId -> parentDeclId
660
+ const declMap = new Map(); // declId -> NormalizedDecl
661
+
662
+ // Build decl map
663
+ for (const decl of normalizedDecls) {
664
+ declMap.set(decl.id, decl);
665
+ }
666
+
667
+ /**
668
+ * Resolve kind via alias tables
669
+ * @param {string} spelledKind
670
+ * @param {ScopeKey} scopeKey
671
+ * @returns {string}
672
+ */
673
+ function resolveKind(spelledKind, scopeKey) {
674
+ const baseKinds = ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'relationship', 'repository', 'reference'];
675
+ if (baseKinds.includes(spelledKind)) {
676
+ return spelledKind;
677
+ }
678
+
679
+ // Walk up scope chain to find alias
680
+ let currentScope = scopeKey;
681
+ while (currentScope) {
682
+ const aliasTable = aliasTablesByScope.get(currentScope);
683
+ if (aliasTable && aliasTable.has(spelledKind)) {
684
+ return aliasTable.get(spelledKind);
685
+ }
686
+
687
+ // Move to parent scope (extract parent from scope key)
688
+ if (currentScope.startsWith('series:')) {
689
+ // Parent would be anthology or universe - need to find it
690
+ break; // For now, just check current scope
691
+ } else if (currentScope.startsWith('anthology:')) {
692
+ // Parent is universe
693
+ const universeName = currentScope.split(':')[1];
694
+ currentScope = `universe:${universeName}`;
695
+ } else {
696
+ break;
697
+ }
698
+ }
699
+
700
+ return 'unknown';
701
+ }
702
+
703
+ // First pass: resolve kinds
704
+ for (const [declId, info] of declIndex.entries()) {
705
+ const resolvedKind = resolveKind(info.kindSpelled, info.scopeKey);
706
+ info.kindResolved = resolvedKind;
707
+
708
+ if (resolvedKind === 'unknown' && !['relates', 'relationshipDecl', 'repository', 'referenceDecl'].includes(info.kindSpelled)) {
709
+ diagnostics.push({
710
+ severity: 'error',
711
+ message: `Unknown kind "${info.kindSpelled}" - not a base kind and no alias found`,
712
+ source: info.span,
713
+ });
714
+ }
715
+ }
716
+
717
+ const universeDeclId = Array.from(declIndex.entries()).find(([, info]) => info.kindResolved === 'universe')?.[0];
718
+
719
+ // Second pass: bind parents
720
+ for (const [declId, info] of declIndex.entries()) {
721
+ const decl = declMap.get(declId);
722
+ if (!decl) continue;
723
+
724
+ let parentDeclId = null;
725
+
726
+ // If explicit parentName via 'in', resolve it
727
+ if (decl.parentName) {
728
+ const pathSegments = decl.parentName.split('.');
729
+ if (pathSegments.length === 1) {
730
+ const resolved = resolveUnqualified(pathSegments[0], declId, index, parentMap, info.span);
731
+ if (resolved.declId) {
732
+ parentDeclId = resolved.declId;
733
+ } else if (resolved.ambiguous) {
734
+ diagnostics.push({
735
+ severity: 'error',
736
+ message: `Parent "${decl.parentName}" is ambiguous; use a qualified path like "Anthology.Book".`,
737
+ source: info.span,
738
+ });
739
+ } else {
740
+ diagnostics.push({
741
+ severity: 'error',
742
+ message: `Cannot resolve parent "${decl.parentName}": not found in any scope`,
743
+ source: info.span,
744
+ });
745
+ }
746
+ } else {
747
+ const resolved = resolveQualified(pathSegments, index, info.span);
748
+ if (resolved.declId) {
749
+ parentDeclId = resolved.declId;
750
+ } else {
751
+ diagnostics.push({
752
+ severity: 'error',
753
+ message: `Cannot resolve parent "${decl.parentName}": ${resolved.error || 'not found'}`,
754
+ source: info.span,
755
+ });
756
+ }
757
+ }
758
+ } else if (decl.syntacticParentId) {
759
+ // Use syntactic parent
760
+ parentDeclId = decl.syntacticParentId;
761
+ }
762
+
763
+ // Top-level declarations default to the universe parent when no explicit parent is provided.
764
+ if (!parentDeclId && !decl.parentName && !decl.syntacticParentId && universeDeclId && info.kindResolved !== 'universe') {
765
+ parentDeclId = universeDeclId;
766
+ }
767
+
768
+ info.parentDeclId = parentDeclId;
769
+ if (parentDeclId) {
770
+ parentMap.set(declId, parentDeclId);
771
+ }
772
+
773
+ // Validate containment rules
774
+ const parentInfo = parentDeclId ? declIndex.get(parentDeclId) : null;
775
+
776
+ // RULE A: Chapters must belong to a Book
777
+ if (info.kindResolved === 'chapter') {
778
+ if (!parentInfo || parentInfo.kindResolved !== 'book') {
779
+ const parentKind = parentInfo ? parentInfo.kindResolved : 'none';
780
+ const parentName = parentInfo ? parentInfo.name : 'none';
781
+ diagnostics.push({
782
+ severity: 'error',
783
+ message: `chapter ${info.name} must be defined under a book (found parent ${parentKind} ${parentName})`,
784
+ source: info.span,
785
+ });
786
+ }
787
+ }
788
+
789
+ // RULE B: Concepts are leaf nodes (check if has children)
790
+ if (info.kindResolved === 'concept') {
791
+ const hasChildren = (decl.body || []).some(child =>
792
+ child.kind && ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container'].includes(child.kind)
793
+ );
794
+ if (hasChildren) {
795
+ diagnostics.push({
796
+ severity: 'error',
797
+ message: `concept ${info.name} cannot contain declarations`,
798
+ source: info.span,
799
+ });
800
+ }
801
+ }
802
+
803
+ // Additional containment rules (relaxed - only chapters have strict requirements)
804
+ // Books, series, anthologies, and concepts can be under any parent
805
+ // The only strict rule is: chapters must be under books (already checked above)
806
+
807
+ boundDecls.push({
808
+ id: declId,
809
+ info,
810
+ decl,
811
+ children: [],
812
+ });
813
+ }
814
+
815
+ // Build children lists from resolved parent map, preserving source order
816
+ const childrenByParent = new Map();
817
+ for (const decl of normalizedDecls) {
818
+ const info = declIndex.get(decl.id);
819
+ if (!info || !info.parentDeclId) continue;
820
+ if (!childrenByParent.has(info.parentDeclId)) {
821
+ childrenByParent.set(info.parentDeclId, []);
822
+ }
823
+ childrenByParent.get(info.parentDeclId).push(decl.id);
824
+ }
825
+
826
+ for (const bound of boundDecls) {
827
+ bound.children = childrenByParent.get(bound.id) || [];
828
+ }
829
+
830
+ return { boundDecls, diagnostics, parentMap };
831
+ }
832
+
833
+ /**
834
+ * Pass 3: Compile relationship schemas
835
+ * @param {BoundDecl[]} boundDecls
836
+ * @param {DeclIndex} index
837
+ * @returns {{ schemas: Map<ScopeKey, SchemaRegistry>, diagnostics: Diagnostic[] }}
838
+ */
839
+ function compileSchemas(boundDecls, index) {
840
+ const schemas = new Map();
841
+ const diagnostics = [];
842
+
843
+ // Build scope key -> registry map
844
+ for (const bound of boundDecls) {
845
+ const { decl, info } = bound;
846
+
847
+ // Handle relationship declarations
848
+ if (decl.kind === 'relationshipDecl') {
849
+ const scopeKey = info.scopeKey;
850
+ if (!schemas.has(scopeKey)) {
851
+ schemas.set(scopeKey, { schemas: new Map(), scopeKey });
852
+ }
853
+ const registry = schemas.get(scopeKey);
854
+
855
+ // Determine type: symmetric (1 id) or paired (2 ids)
856
+ // Need to get the original decl to access ids
857
+ // For now, assume we can extract from body or store ids in normalized decl
858
+ // Actually, the original parser AST has ids array - we need to preserve it
859
+ // Let me check the structure... the normalized decl should have the original data
860
+
861
+ // Extract relationship IDs from the normalized decl
862
+ const ids = decl.relationshipIds || decl.name.split(' and ');
863
+
864
+ if (ids.length === 1) {
865
+ // Symmetric relationship
866
+ const schemaId = ids[0];
867
+ if (registry.schemas.has(schemaId)) {
868
+ diagnostics.push({
869
+ severity: 'error',
870
+ message: `Duplicate relationship schema "${schemaId}" in scope "${scopeKey}"`,
871
+ source: info.span,
872
+ });
873
+ } else {
874
+ registry.schemas.set(schemaId, {
875
+ id: schemaId,
876
+ type: 'symmetric',
877
+ decl,
878
+ scopeKey,
879
+ });
880
+ }
881
+ } else if (ids.length === 2) {
882
+ // Paired relationship
883
+ const leftId = ids[0];
884
+ const rightId = ids[1];
885
+ if (registry.schemas.has(leftId) || registry.schemas.has(rightId)) {
886
+ diagnostics.push({
887
+ severity: 'error',
888
+ message: `Duplicate relationship schema "${leftId}" or "${rightId}" in scope "${scopeKey}"`,
889
+ source: info.span,
890
+ });
891
+ } else {
892
+ const schema = {
893
+ id: leftId, // Store under leftId
894
+ type: 'paired',
895
+ leftId,
896
+ rightId,
897
+ decl,
898
+ scopeKey,
899
+ };
900
+ registry.schemas.set(leftId, schema);
901
+ registry.schemas.set(rightId, schema); // Also store under rightId for lookup
902
+ }
903
+ }
904
+ }
905
+
906
+ // Handle relates declarations (these are schemas too, but simpler)
907
+ if (decl.kind === 'relates') {
908
+ // Relates don't need schema registry - they're handled differently
909
+ // But we could store them for validation
910
+ }
911
+ }
912
+
913
+ return { schemas, diagnostics };
914
+ }
915
+
916
+ /**
917
+ * Extract raw text from contentSpan
918
+ * @param {{ startOffset: number, endOffset: number } | undefined} contentSpan
919
+ * @param {string} sourceText
920
+ * @returns {string}
921
+ */
922
+ function getRawText(contentSpan, sourceText) {
923
+ if (!contentSpan) return '';
924
+ return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
925
+ }
926
+
927
+ /**
928
+ * Pass 4: Build nodes
929
+ * @param {BoundDecl[]} boundDecls
930
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
931
+ * @param {Array<{ sourceText: string, filePath: string }>} fileData
932
+ * @returns {{ nodes: Map<DeclId, NodeModel>, repositories: Map<DeclId, RepositoryModel>, references: Map<DeclId, ReferenceModel>, diagnostics: Diagnostic[] }}
933
+ */
934
+ function buildNodes(boundDecls, schemas, fileData) {
935
+ const nodes = new Map();
936
+ const repositories = new Map();
937
+ const references = new Map();
938
+ const diagnostics = [];
939
+
940
+ // Build file data map
941
+ const fileDataMap = new Map();
942
+ for (const file of fileData) {
943
+ fileDataMap.set(file.filePath, file.sourceText);
944
+ }
945
+
946
+ /**
947
+ * Build a text block from describe/title/note block
948
+ * @param {any} block
949
+ * @param {string} sourceText
950
+ * @returns {TextBlock | undefined}
951
+ */
952
+ function buildTextBlock(block, sourceText) {
953
+ if (!block || !block.contentSpan) return undefined;
954
+ const raw = getRawText(block.contentSpan, sourceText);
955
+ return {
956
+ raw,
957
+ normalized: normalizeProseBlock(raw),
958
+ source: block.span,
959
+ };
960
+ }
961
+
962
+ for (const bound of boundDecls) {
963
+ const { id, info, decl } = bound;
964
+ const sourceText = fileDataMap.get(info.span?.file || '') || '';
965
+
966
+ // Build container/concept nodes
967
+ if (['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'relates'].includes(info.kindResolved)) {
968
+ const node = {
969
+ id,
970
+ kind: info.kindResolved,
971
+ name: info.name,
972
+ parent: info.parentDeclId || undefined,
973
+ children: bound.children,
974
+ source: info.span,
975
+ };
976
+
977
+ if (decl.spelledKind && decl.spelledKind !== info.kindResolved) {
978
+ node.spelledKind = decl.spelledKind;
979
+ }
980
+
981
+ if (decl.aliases && decl.aliases.size > 0) {
982
+ node.aliases = Object.fromEntries(decl.aliases);
983
+ }
984
+
985
+ // Extract describe block
986
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
987
+ if (describeBlock) {
988
+ node.describe = buildTextBlock(describeBlock, sourceText);
989
+ }
990
+
991
+ // Extract title block
992
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
993
+ if (titleBlock) {
994
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
995
+ const titleValue = raw.trim();
996
+ if (titleValue.length > 0) {
997
+ node.title = titleValue;
998
+ }
999
+ }
1000
+
1001
+ // Extract relationships block (for concepts)
1002
+ const relationshipsBlock = (decl.body || []).find(b => b.kind === 'relationships');
1003
+ if (relationshipsBlock) {
1004
+ // Handle new entry-based syntax
1005
+ if (relationshipsBlock.entries) {
1006
+ node.relationships = {
1007
+ entries: relationshipsBlock.entries.map(entry => ({
1008
+ relationshipId: entry.relationshipId,
1009
+ targets: entry.targets.map(target => ({
1010
+ id: target.id,
1011
+ metadata: target.metadata ? buildTextBlock(target.metadata, sourceText) : undefined,
1012
+ })),
1013
+ })),
1014
+ source: relationshipsBlock.source,
1015
+ };
1016
+ } else if (relationshipsBlock.values) {
1017
+ // Legacy string values syntax
1018
+ node.relationships = {
1019
+ values: relationshipsBlock.values,
1020
+ source: relationshipsBlock.source,
1021
+ };
1022
+ }
1023
+ }
1024
+
1025
+ // Handle relates nodes specially
1026
+ if (info.kindResolved === 'relates') {
1027
+ // Extract endpoints (will be resolved in Pass 5)
1028
+ const a = decl.name.split(' and ')[0];
1029
+ const b = decl.name.split(' and ')[1] || '';
1030
+ node.unresolvedEndpoints = [a, b];
1031
+ node.endpoints = [];
1032
+
1033
+ // Extract from blocks
1034
+ const fromBlocks = (decl.body || []).filter(b => b.kind === 'from');
1035
+ node.from = {};
1036
+ for (const fromBlock of fromBlocks) {
1037
+ const endpoint = fromBlock.endpoint;
1038
+ const fromView = {
1039
+ source: fromBlock.source,
1040
+ };
1041
+
1042
+ // Extract relationships block from from block
1043
+ const fromRelBlock = (fromBlock.body || []).find(b => b.kind === 'relationships');
1044
+ if (fromRelBlock && fromRelBlock.values) {
1045
+ fromView.relationships = {
1046
+ values: fromRelBlock.values,
1047
+ source: fromRelBlock.source,
1048
+ };
1049
+ }
1050
+
1051
+ // Extract describe block from from block
1052
+ const fromDescribeBlock = (fromBlock.body || []).find(b => b.kind === 'describe');
1053
+ if (fromDescribeBlock) {
1054
+ fromView.describe = buildTextBlock(fromDescribeBlock, sourceText);
1055
+ }
1056
+
1057
+ // Extract unknown blocks
1058
+ const unknownBlocks = (fromBlock.body || []).filter(b =>
1059
+ b.kind && !['relationships', 'describe'].includes(b.kind)
1060
+ );
1061
+ if (unknownBlocks.length > 0) {
1062
+ fromView.unknownBlocks = unknownBlocks.map(ub => ({
1063
+ keyword: ub.kind || 'unknown',
1064
+ raw: getRawText(ub.contentSpan, sourceText),
1065
+ normalized: normalizeProseBlock(getRawText(ub.contentSpan, sourceText)),
1066
+ source: ub.span || ub.source,
1067
+ }));
1068
+ }
1069
+
1070
+ node.from[endpoint] = fromView;
1071
+ }
1072
+
1073
+ // Extract top-level relationships block for relates
1074
+ if (relationshipsBlock && relationshipsBlock.values) {
1075
+ node.relationships = {
1076
+ values: relationshipsBlock.values,
1077
+ source: relationshipsBlock.source,
1078
+ };
1079
+ }
1080
+ }
1081
+
1082
+ // Extract unknown blocks (non-container, non-standard blocks)
1083
+ const unknownBlocks = (decl.body || []).filter(b => {
1084
+ if (!b.kind) return false;
1085
+ const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference'];
1086
+ return !knownKinds.includes(b.kind);
1087
+ });
1088
+ if (unknownBlocks.length > 0) {
1089
+ node.unknownBlocks = unknownBlocks.map(ub => ({
1090
+ keyword: ub.kind,
1091
+ raw: getRawText(ub.contentSpan, sourceText),
1092
+ normalized: normalizeProseBlock(getRawText(ub.contentSpan, sourceText)),
1093
+ source: ub.span || ub.source,
1094
+ }));
1095
+ }
1096
+
1097
+ // Extract references block
1098
+ const referencesBlock = (decl.body || []).find(b => b.kind === 'references');
1099
+ if (referencesBlock && referencesBlock.items) {
1100
+ node.references = referencesBlock.items.map(item => {
1101
+ // Will be resolved in Pass 5
1102
+ return item.ref || item.name || '';
1103
+ });
1104
+ }
1105
+
1106
+ nodes.set(id, node);
1107
+ }
1108
+
1109
+ // Build repository nodes
1110
+ if (info.kindResolved === 'repository') {
1111
+ const repo = {
1112
+ id,
1113
+ name: info.name,
1114
+ url: '',
1115
+ source: info.span,
1116
+ };
1117
+
1118
+ // Extract url block
1119
+ const urlBlock = (decl.body || []).find(b => b.kind === 'url');
1120
+ if (urlBlock && urlBlock.value) {
1121
+ repo.url = urlBlock.value;
1122
+ }
1123
+
1124
+ // Extract title block
1125
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
1126
+ if (titleBlock) {
1127
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
1128
+ const titleValue = raw.trim().replace(/^['"]|['"]$/g, '').trim();
1129
+ if (titleValue.length > 0) {
1130
+ repo.title = titleValue;
1131
+ }
1132
+ }
1133
+
1134
+ // Extract describe block
1135
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
1136
+ if (describeBlock) {
1137
+ repo.describe = buildTextBlock(describeBlock, sourceText);
1138
+ }
1139
+
1140
+ // Extract note block
1141
+ const noteBlock = (decl.body || []).find(b => b.kind === 'note');
1142
+ if (noteBlock) {
1143
+ repo.note = buildTextBlock(noteBlock, sourceText);
1144
+ }
1145
+
1146
+ repositories.set(id, repo);
1147
+ }
1148
+
1149
+ // Build reference nodes
1150
+ if (info.kindResolved === 'reference') {
1151
+ const ref = {
1152
+ id,
1153
+ name: info.name,
1154
+ urls: [],
1155
+ source: info.span,
1156
+ };
1157
+
1158
+ // Extract url block
1159
+ const urlBlock = (decl.body || []).find(b => b.kind === 'url');
1160
+ if (urlBlock && urlBlock.value) {
1161
+ ref.urls = [urlBlock.value];
1162
+ }
1163
+
1164
+ // Extract repository reference
1165
+ const repositoryBlock = (decl.body || []).find(b => b.kind === 'reference' && b.repositoryName);
1166
+ if (repositoryBlock && repositoryBlock.repositoryName) {
1167
+ // Will be resolved later
1168
+ ref.repositoryName = repositoryBlock.repositoryName;
1169
+ } else if (decl.repositoryName) {
1170
+ // Named reference with "in <RepoName>"
1171
+ ref.repositoryName = decl.repositoryName;
1172
+ }
1173
+
1174
+ // Extract paths block
1175
+ const pathsBlock = (decl.body || []).find(b => b.kind === 'paths');
1176
+ if (pathsBlock && pathsBlock.paths) {
1177
+ ref.paths = pathsBlock.paths;
1178
+ }
1179
+
1180
+ // Extract title block
1181
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
1182
+ if (titleBlock) {
1183
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
1184
+ const titleValue = raw.trim().replace(/^['"]|['"]$/g, '').trim();
1185
+ if (titleValue.length > 0) {
1186
+ ref.title = titleValue;
1187
+ }
1188
+ }
1189
+
1190
+ // Extract describe block
1191
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
1192
+ if (describeBlock) {
1193
+ ref.describe = buildTextBlock(describeBlock, sourceText);
1194
+ }
1195
+
1196
+ // Extract note block
1197
+ const noteBlock = (decl.body || []).find(b => b.kind === 'note');
1198
+ if (noteBlock) {
1199
+ ref.note = buildTextBlock(noteBlock, sourceText);
1200
+ }
1201
+
1202
+ references.set(id, ref);
1203
+ }
1204
+ }
1205
+
1206
+ return { nodes, repositories, references, diagnostics };
1207
+ }
1208
+
1209
+ /**
1210
+ * Pass 5: Build edges from relationship usage
1211
+ * @param {BoundDecl[]} boundDecls
1212
+ * @param {Map<DeclId, NodeModel>} nodes
1213
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1214
+ * @param {DeclIndex} index
1215
+ * @param {Map<DeclId, DeclId>} parentMap
1216
+ * @param {Array<{ sourceText: string, filePath: string }>} fileData
1217
+ * @returns {{ edges: EdgeAssertedModel[], diagnostics: Diagnostic[] }}
1218
+ */
1219
+ function buildEdges(boundDecls, nodes, schemas, index, parentMap, fileData) {
1220
+ const edges = [];
1221
+ const diagnostics = [];
1222
+
1223
+ const fileDataMap = new Map();
1224
+ for (const file of fileData) {
1225
+ fileDataMap.set(file.filePath, file.sourceText);
1226
+ }
1227
+
1228
+ for (const bound of boundDecls) {
1229
+ const { id, decl, info } = bound;
1230
+ const node = nodes.get(id);
1231
+ if (!node) continue;
1232
+
1233
+ const sourceText = fileDataMap.get(info.span?.file || '') || '';
1234
+
1235
+ // Handle relationships block entries (new syntax)
1236
+ if (node.relationships && node.relationships.entries) {
1237
+ for (const entry of node.relationships.entries) {
1238
+ for (const target of entry.targets) {
1239
+ const pathSegments = target.id.split('.');
1240
+ let targetDeclId = null;
1241
+
1242
+ if (pathSegments.length === 1) {
1243
+ const resolved = resolveUnqualified(pathSegments[0], id, index, parentMap, node.relationships.source);
1244
+ if (resolved.ambiguous) {
1245
+ diagnostics.push({
1246
+ severity: 'error',
1247
+ message: `Ambiguous reference "${pathSegments[0]}" - use qualified path`,
1248
+ source: node.relationships.source,
1249
+ });
1250
+ } else if (resolved.declId) {
1251
+ targetDeclId = resolved.declId;
1252
+ }
1253
+ } else {
1254
+ const resolved = resolveQualified(pathSegments, index, node.relationships.source);
1255
+ if (resolved.declId) {
1256
+ targetDeclId = resolved.declId;
1257
+ }
1258
+ }
1259
+
1260
+ if (targetDeclId) {
1261
+ edges.push({
1262
+ from: id,
1263
+ via: entry.relationshipId,
1264
+ to: targetDeclId,
1265
+ meta: target.metadata,
1266
+ source: node.relationships.source,
1267
+ });
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ // Handle relates nodes - create bidirectional edges
1275
+ for (const bound of boundDecls) {
1276
+ const { id, decl, info } = bound;
1277
+ if (info.kindResolved !== 'relates') continue;
1278
+
1279
+ const node = nodes.get(id);
1280
+ if (!node || !node.endpoints || node.endpoints.length !== 2) continue;
1281
+
1282
+ const [endpointA, endpointB] = node.endpoints;
1283
+ const viaLabel = (node.relationships && node.relationships.values && node.relationships.values[0]) || 'related to';
1284
+
1285
+ edges.push({
1286
+ from: endpointA,
1287
+ via: viaLabel,
1288
+ to: endpointB,
1289
+ meta: node.from?.[endpointA]?.describe,
1290
+ source: node.source,
1291
+ bidirectional: true,
1292
+ });
1293
+ }
1294
+
1295
+ return { edges, diagnostics };
1296
+ }
1297
+
1298
+ /**
1299
+ * Pass 6: Normalize edges - add inverse edges for paired/symmetric relationships
1300
+ * @param {EdgeAssertedModel[]} edges
1301
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1302
+ * @param {string} universeName
1303
+ * @returns {NormalizedEdgeModel[]}
1304
+ */
1305
+ function normalizeEdges(edges, schemas, universeName) {
1306
+ const normalized = [];
1307
+ const seenEdges = new Set();
1308
+
1309
+ for (const asserted of edges) {
1310
+ const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
1311
+
1312
+ if (seenEdges.has(edgeKey)) continue;
1313
+ seenEdges.add(edgeKey);
1314
+
1315
+ // Add asserted edge
1316
+ normalized.push({
1317
+ from: asserted.from,
1318
+ via: asserted.via,
1319
+ to: asserted.to,
1320
+ asserted: true,
1321
+ sourceRefs: [asserted.source],
1322
+ meta: asserted.meta,
1323
+ bidirectional: asserted.bidirectional || false,
1324
+ });
1325
+
1326
+ // Find relationship schema (check all scopes)
1327
+ let relDecl = null;
1328
+ for (const registry of schemas.values()) {
1329
+ if (registry.schemas.has(asserted.via)) {
1330
+ relDecl = registry.schemas.get(asserted.via);
1331
+ break;
1332
+ }
1333
+ }
1334
+
1335
+ // Add inverse edge if applicable (only for declared relationships, not relates)
1336
+ if (relDecl && !asserted.bidirectional) {
1337
+ if (relDecl.type === 'paired') {
1338
+ // Determine inverse side
1339
+ const inverseVia = asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
1340
+
1341
+ if (inverseVia) {
1342
+ const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
1343
+ if (!seenEdges.has(inverseKey)) {
1344
+ normalized.push({
1345
+ from: asserted.to,
1346
+ via: inverseVia,
1347
+ to: asserted.from,
1348
+ asserted: false,
1349
+ sourceRefs: [asserted.source],
1350
+ meta: asserted.meta,
1351
+ });
1352
+ seenEdges.add(inverseKey);
1353
+ }
1354
+ }
1355
+ } else if (relDecl.type === 'symmetric') {
1356
+ // Symmetric: add reverse edge with same relationship ID
1357
+ const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
1358
+ if (!seenEdges.has(reverseKey)) {
1359
+ normalized.push({
1360
+ from: asserted.to,
1361
+ via: asserted.via,
1362
+ to: asserted.from,
1363
+ asserted: false,
1364
+ sourceRefs: [asserted.source],
1365
+ meta: asserted.meta,
1366
+ });
1367
+ seenEdges.add(reverseKey);
1368
+ }
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ return normalized;
1374
+ }
1375
+
1376
+ /**
1377
+ * Resolve relates node endpoints
1378
+ * @param {Map<DeclId, NodeModel>} nodes
1379
+ * @param {DeclIndex} index
1380
+ * @param {Map<DeclId, DeclId>} parentMap
1381
+ * @param {BoundDecl[]} boundDecls
1382
+ * @returns {Diagnostic[]}
1383
+ */
1384
+ function resolveRelatesEndpoints(nodes, index, parentMap, boundDecls) {
1385
+ const diagnostics = [];
1386
+
1387
+ for (const bound of boundDecls) {
1388
+ const { id, decl, info } = bound;
1389
+ if (info.kindResolved !== 'relates') continue;
1390
+
1391
+ const node = nodes.get(id);
1392
+ if (!node || !node.unresolvedEndpoints) continue;
1393
+
1394
+ const resolvedEndpoints = [];
1395
+ const unresolved = [];
1396
+
1397
+ for (const endpointName of node.unresolvedEndpoints) {
1398
+ const resolved = resolveUnqualified(endpointName, id, index, parentMap, info.span);
1399
+ if (resolved.declId && !resolved.ambiguous) {
1400
+ resolvedEndpoints.push(resolved.declId);
1401
+ } else {
1402
+ unresolved.push(endpointName);
1403
+ if (!resolved.ambiguous) {
1404
+ diagnostics.push({
1405
+ severity: 'warning',
1406
+ message: `Unresolved relates endpoint "${endpointName}"`,
1407
+ source: info.span,
1408
+ });
1409
+ } else {
1410
+ diagnostics.push({
1411
+ severity: 'error',
1412
+ message: `Ambiguous relates endpoint "${endpointName}" - use qualified path`,
1413
+ source: info.span,
1414
+ });
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ node.endpoints = resolvedEndpoints;
1420
+ if (unresolved.length > 0) {
1421
+ node.unresolvedEndpoints = unresolved;
1422
+ } else {
1423
+ delete node.unresolvedEndpoints;
1424
+ }
1425
+
1426
+ // Resolve from blocks: convert endpoint names to node IDs
1427
+ if (node.from) {
1428
+ const resolvedFrom = {};
1429
+ for (const endpointName in node.from) {
1430
+ const resolved = resolveUnqualified(endpointName, id, index, parentMap, info.span);
1431
+ if (resolved.declId && !resolved.ambiguous) {
1432
+ resolvedFrom[resolved.declId] = node.from[endpointName];
1433
+ } else {
1434
+ // Keep unresolved from blocks keyed by name
1435
+ resolvedFrom[endpointName] = node.from[endpointName];
1436
+ }
1437
+ }
1438
+ node.from = resolvedFrom;
1439
+ }
1440
+ }
1441
+
1442
+ return diagnostics;
1443
+ }
1444
+
1445
+ /**
1446
+ * Main function: Build UniverseGraph from parsed AST files
1447
+ * @param {Array<{ kind: string, decls: any[], source?: SourceSpan, sourceText?: string }> | { kind: string, decls: any[], source?: SourceSpan, sourceText?: string }} fileAstOrFileAsts
1448
+ * @param {Object} [options]
1449
+ * @returns {UniverseGraph}
1450
+ */
1451
+ export function buildGraph(fileAstOrFileAsts, options) {
1452
+ // Normalize input to array
1453
+ const fileASTs = Array.isArray(fileAstOrFileAsts) ? fileAstOrFileAsts : [fileAstOrFileAsts];
1454
+
1455
+ // Pass 0: Normalize AST
1456
+ const normalized = normalizeAst(fileASTs);
1457
+
1458
+ // Pass 1: Index declarations
1459
+ const { index, normalizedDecls } = indexDecls(normalized);
1460
+
1461
+ // Extract universe name
1462
+ const universeNames = Array.from(new Set(normalizedDecls.filter(d => d.kind === 'universe').map(d => d.name)));
1463
+ const universeName = universeNames[0] || 'Unknown';
1464
+
1465
+ // Pass 2: Bind parents and resolve kinds
1466
+ const { boundDecls, diagnostics: bindDiags, parentMap } = bindParentsAndKinds(index, normalizedDecls);
1467
+
1468
+ // Pass 3: Compile schemas
1469
+ const { schemas, diagnostics: schemaDiags } = compileSchemas(boundDecls, index);
1470
+
1471
+ // Pass 4: Build nodes
1472
+ const fileData = normalized.map(n => ({ sourceText: n.sourceText, filePath: n.filePath }));
1473
+ const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(boundDecls, schemas, fileData);
1474
+
1475
+ // Resolve relates endpoints
1476
+ const relatesDiags = resolveRelatesEndpoints(nodes, index, parentMap, boundDecls);
1477
+
1478
+ // Pass 5: Build edges
1479
+ const { edges: assertedEdges, diagnostics: edgeDiags } = buildEdges(boundDecls, nodes, schemas, index, parentMap, fileData);
1480
+
1481
+ // Pass 6: Normalize edges
1482
+ const normalizedEdges = normalizeEdges(assertedEdges, schemas, universeName);
1483
+
1484
+ // Collect all diagnostics
1485
+ const allDiagnostics = [
1486
+ ...index.diagnostics,
1487
+ ...bindDiags,
1488
+ ...schemaDiags,
1489
+ ...nodeDiags,
1490
+ ...relatesDiags,
1491
+ ...edgeDiags,
1492
+ ];
1493
+
1494
+ // Build relationshipDecls object
1495
+ const relationshipDecls = {};
1496
+ relationshipDecls[universeName] = {};
1497
+ for (const registry of schemas.values()) {
1498
+ for (const [schemaId, schema] of registry.schemas.entries()) {
1499
+ if (schema.type === 'symmetric') {
1500
+ relationshipDecls[universeName][schemaId] = {
1501
+ type: 'symmetric',
1502
+ id: schemaId,
1503
+ describe: undefined, // TODO: extract from schema.decl.body
1504
+ label: undefined, // TODO: extract from schema.decl.body
1505
+ source: schema.decl.source,
1506
+ };
1507
+ } else if (schema.type === 'paired') {
1508
+ relationshipDecls[universeName][schema.leftId] = {
1509
+ type: 'paired',
1510
+ leftId: schema.leftId,
1511
+ rightId: schema.rightId,
1512
+ describe: undefined, // TODO: extract from schema.decl.body
1513
+ from: {}, // TODO: extract from schema.decl.body
1514
+ source: schema.decl.source,
1515
+ };
1516
+ relationshipDecls[universeName][schema.rightId] = relationshipDecls[universeName][schema.leftId];
1517
+ }
1518
+ }
1519
+ }
1520
+
1521
+ // Build nodes object (convert Map to Record)
1522
+ const nodesObj = {};
1523
+ for (const [id, node] of nodes.entries()) {
1524
+ nodesObj[id] = node;
1525
+ }
1526
+
1527
+ // Build repositories object
1528
+ const reposObj = {};
1529
+ for (const [id, repo] of repositories.entries()) {
1530
+ reposObj[id] = repo;
1531
+ }
1532
+
1533
+ // Build references object
1534
+ const refsObj = {};
1535
+ for (const [id, ref] of references.entries()) {
1536
+ refsObj[id] = ref;
1537
+ }
1538
+
1539
+ // Build universes object
1540
+ const universes = {};
1541
+ const universeNode = Array.from(nodes.values()).find(n => n.kind === 'universe');
1542
+ if (universeNode) {
1543
+ universes[universeName] = {
1544
+ name: universeName,
1545
+ root: universeNode.id,
1546
+ };
1547
+ }
1548
+
1549
+ return {
1550
+ version: 1,
1551
+ universes,
1552
+ nodes: nodesObj,
1553
+ edges: normalizedEdges,
1554
+ edgesAsserted: assertedEdges,
1555
+ diagnostics: allDiagnostics,
1556
+ repositories: reposObj,
1557
+ references: refsObj,
1558
+ documentsByName: {}, // TODO: implement
1559
+ relationshipDecls,
1560
+ };
1561
+ }
1562
+
1563
+ // Debug helpers (not exported, for testing)
1564
+ /**
1565
+ * @param {DeclIndex} index
1566
+ * @returns {string}
1567
+ */
1568
+ function debugIndex(index) {
1569
+ const lines = [];
1570
+ lines.push('=== DeclIndex ===');
1571
+ lines.push(`Declarations: ${index.declIndex.size}`);
1572
+ lines.push(`Scopes: ${index.scopeIndex.size}`);
1573
+ lines.push(`Series: ${index.seriesIndex.size}`);
1574
+ lines.push(`Alias tables: ${index.aliasTablesByScope.size}`);
1575
+ return lines.join('\n');
1576
+ }
1577
+
1578
+ /**
1579
+ * @param {BoundDecl[]} boundDecls
1580
+ * @returns {string}
1581
+ */
1582
+ function debugBound(boundDecls) {
1583
+ const lines = [];
1584
+ lines.push('=== BoundDecls ===');
1585
+ for (const bound of boundDecls) {
1586
+ lines.push(`${bound.id}: ${bound.info.kindResolved} ${bound.info.name} (parent: ${bound.info.parentDeclId || 'none'})`);
1587
+ }
1588
+ return lines.join('\n');
1589
+ }
1590
+
1591
+ /**
1592
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1593
+ * @returns {string}
1594
+ */
1595
+ function debugSchemas(schemas) {
1596
+ const lines = [];
1597
+ lines.push('=== Schemas ===');
1598
+ for (const [scopeKey, registry] of schemas.entries()) {
1599
+ lines.push(`Scope: ${scopeKey}`);
1600
+ for (const [schemaId, schema] of registry.schemas.entries()) {
1601
+ lines.push(` ${schemaId}: ${schema.type}`);
1602
+ }
1603
+ }
1604
+ return lines.join('\n');
1605
+ }
1606
+
1607
+ /**
1608
+ * @param {EdgeAssertedModel[]} edges
1609
+ * @returns {string}
1610
+ */
1611
+ function debugEdges(edges) {
1612
+ const lines = [];
1613
+ lines.push('=== Edges ===');
1614
+ for (const edge of edges) {
1615
+ lines.push(`${edge.from} --[${edge.via}]--> ${edge.to}`);
1616
+ }
1617
+ return lines.join('\n');
1618
+ }
1619
+