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

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,1615 @@
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, declId) {
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 syntactic parent chain to find alias
680
+ let currentDeclId = declId;
681
+ while (currentDeclId) {
682
+ const decl = declMap.get(currentDeclId);
683
+ const aliasTable = decl?.aliases;
684
+ if (aliasTable && aliasTable.has(spelledKind)) {
685
+ return aliasTable.get(spelledKind);
686
+ }
687
+ currentDeclId = decl?.syntacticParentId;
688
+ }
689
+
690
+ return 'unknown';
691
+ }
692
+
693
+ // First pass: resolve kinds
694
+ for (const [declId, info] of declIndex.entries()) {
695
+ const resolvedKind = resolveKind(info.kindSpelled, declId);
696
+ info.kindResolved = resolvedKind;
697
+
698
+ if (resolvedKind === 'unknown' && !['relates', 'relationshipDecl', 'repository', 'referenceDecl'].includes(info.kindSpelled)) {
699
+ diagnostics.push({
700
+ severity: 'error',
701
+ message: `Unknown kind "${info.kindSpelled}" - not a base kind and no alias found`,
702
+ source: info.span,
703
+ });
704
+ }
705
+ }
706
+
707
+ const universeDeclId = Array.from(declIndex.entries()).find(([, info]) => info.kindResolved === 'universe')?.[0];
708
+
709
+ // Second pass: bind parents
710
+ for (const [declId, info] of declIndex.entries()) {
711
+ const decl = declMap.get(declId);
712
+ if (!decl) continue;
713
+
714
+ let parentDeclId = null;
715
+
716
+ // If explicit parentName via 'in', resolve it
717
+ if (decl.parentName) {
718
+ const pathSegments = decl.parentName.split('.');
719
+ if (pathSegments.length === 1) {
720
+ const resolved = resolveUnqualified(pathSegments[0], declId, index, parentMap, info.span);
721
+ if (resolved.declId) {
722
+ parentDeclId = resolved.declId;
723
+ } else if (resolved.ambiguous) {
724
+ diagnostics.push({
725
+ severity: 'error',
726
+ message: `Parent "${decl.parentName}" is ambiguous; use a qualified path like "Anthology.Book".`,
727
+ source: info.span,
728
+ });
729
+ } else {
730
+ diagnostics.push({
731
+ severity: 'error',
732
+ message: `Cannot resolve parent "${decl.parentName}": not found in any scope`,
733
+ source: info.span,
734
+ });
735
+ }
736
+ } else {
737
+ const resolved = resolveQualified(pathSegments, index, info.span);
738
+ if (resolved.declId) {
739
+ parentDeclId = resolved.declId;
740
+ } else {
741
+ diagnostics.push({
742
+ severity: 'error',
743
+ message: `Cannot resolve parent "${decl.parentName}": ${resolved.error || 'not found'}`,
744
+ source: info.span,
745
+ });
746
+ }
747
+ }
748
+ } else if (decl.syntacticParentId) {
749
+ // Use syntactic parent
750
+ parentDeclId = decl.syntacticParentId;
751
+ }
752
+
753
+ // Top-level declarations default to the universe parent when no explicit parent is provided.
754
+ if (!parentDeclId && !decl.parentName && !decl.syntacticParentId && universeDeclId && info.kindResolved !== 'universe') {
755
+ parentDeclId = universeDeclId;
756
+ }
757
+
758
+ info.parentDeclId = parentDeclId;
759
+ if (parentDeclId) {
760
+ parentMap.set(declId, parentDeclId);
761
+ }
762
+
763
+ // Validate containment rules
764
+ const parentInfo = parentDeclId ? declIndex.get(parentDeclId) : null;
765
+
766
+ // RULE A: Chapters must belong to a Book
767
+ if (info.kindResolved === 'chapter') {
768
+ if (!parentInfo || parentInfo.kindResolved !== 'book') {
769
+ const parentKind = parentInfo ? parentInfo.kindResolved : 'none';
770
+ const parentName = parentInfo ? parentInfo.name : 'none';
771
+ diagnostics.push({
772
+ severity: 'error',
773
+ message: `chapter ${info.name} must be defined under a book (found parent ${parentKind} ${parentName})`,
774
+ source: info.span,
775
+ });
776
+ }
777
+ }
778
+
779
+ // RULE B: Concepts are leaf nodes (check if has children)
780
+ if (info.kindResolved === 'concept') {
781
+ const hasChildren = (decl.body || []).some(child =>
782
+ child.kind && ['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'container'].includes(child.kind)
783
+ );
784
+ if (hasChildren) {
785
+ diagnostics.push({
786
+ severity: 'error',
787
+ message: `concept ${info.name} cannot contain declarations`,
788
+ source: info.span,
789
+ });
790
+ }
791
+ }
792
+
793
+ // Additional containment rules (relaxed - only chapters have strict requirements)
794
+ // Books, series, anthologies, and concepts can be under any parent
795
+ // The only strict rule is: chapters must be under books (already checked above)
796
+
797
+ boundDecls.push({
798
+ id: declId,
799
+ info,
800
+ decl,
801
+ children: [],
802
+ });
803
+ }
804
+
805
+ // Build children lists from resolved parent map, preserving source order
806
+ const childrenByParent = new Map();
807
+ const childrenSeenByParent = new Map();
808
+ for (const decl of normalizedDecls) {
809
+ const info = declIndex.get(decl.id);
810
+ if (!info || !info.parentDeclId) continue;
811
+ if (!childrenByParent.has(info.parentDeclId)) {
812
+ childrenByParent.set(info.parentDeclId, []);
813
+ childrenSeenByParent.set(info.parentDeclId, new Set());
814
+ }
815
+ const seen = childrenSeenByParent.get(info.parentDeclId);
816
+ if (!seen.has(decl.id)) {
817
+ childrenByParent.get(info.parentDeclId).push(decl.id);
818
+ seen.add(decl.id);
819
+ }
820
+ }
821
+
822
+ for (const bound of boundDecls) {
823
+ bound.children = childrenByParent.get(bound.id) || [];
824
+ }
825
+
826
+ return { boundDecls, diagnostics, parentMap };
827
+ }
828
+
829
+ /**
830
+ * Pass 3: Compile relationship schemas
831
+ * @param {BoundDecl[]} boundDecls
832
+ * @param {DeclIndex} index
833
+ * @returns {{ schemas: Map<ScopeKey, SchemaRegistry>, diagnostics: Diagnostic[] }}
834
+ */
835
+ function compileSchemas(boundDecls, index) {
836
+ const schemas = new Map();
837
+ const diagnostics = [];
838
+
839
+ // Build scope key -> registry map
840
+ for (const bound of boundDecls) {
841
+ const { decl, info } = bound;
842
+
843
+ // Handle relationship declarations
844
+ if (decl.kind === 'relationshipDecl') {
845
+ const scopeKey = info.scopeKey;
846
+ if (!schemas.has(scopeKey)) {
847
+ schemas.set(scopeKey, { schemas: new Map(), scopeKey });
848
+ }
849
+ const registry = schemas.get(scopeKey);
850
+
851
+ // Determine type: symmetric (1 id) or paired (2 ids)
852
+ // Need to get the original decl to access ids
853
+ // For now, assume we can extract from body or store ids in normalized decl
854
+ // Actually, the original parser AST has ids array - we need to preserve it
855
+ // Let me check the structure... the normalized decl should have the original data
856
+
857
+ // Extract relationship IDs from the normalized decl
858
+ const ids = decl.relationshipIds || decl.name.split(' and ');
859
+
860
+ if (ids.length === 1) {
861
+ // Symmetric relationship
862
+ const schemaId = ids[0];
863
+ if (registry.schemas.has(schemaId)) {
864
+ diagnostics.push({
865
+ severity: 'error',
866
+ message: `Duplicate relationship schema "${schemaId}" in scope "${scopeKey}"`,
867
+ source: info.span,
868
+ });
869
+ } else {
870
+ registry.schemas.set(schemaId, {
871
+ id: schemaId,
872
+ type: 'symmetric',
873
+ decl,
874
+ scopeKey,
875
+ });
876
+ }
877
+ } else if (ids.length === 2) {
878
+ // Paired relationship
879
+ const leftId = ids[0];
880
+ const rightId = ids[1];
881
+ if (registry.schemas.has(leftId) || registry.schemas.has(rightId)) {
882
+ diagnostics.push({
883
+ severity: 'error',
884
+ message: `Duplicate relationship schema "${leftId}" or "${rightId}" in scope "${scopeKey}"`,
885
+ source: info.span,
886
+ });
887
+ } else {
888
+ const schema = {
889
+ id: leftId, // Store under leftId
890
+ type: 'paired',
891
+ leftId,
892
+ rightId,
893
+ decl,
894
+ scopeKey,
895
+ };
896
+ registry.schemas.set(leftId, schema);
897
+ registry.schemas.set(rightId, schema); // Also store under rightId for lookup
898
+ }
899
+ }
900
+ }
901
+
902
+ // Handle relates declarations (these are schemas too, but simpler)
903
+ if (decl.kind === 'relates') {
904
+ // Relates don't need schema registry - they're handled differently
905
+ // But we could store them for validation
906
+ }
907
+ }
908
+
909
+ return { schemas, diagnostics };
910
+ }
911
+
912
+ /**
913
+ * Extract raw text from contentSpan
914
+ * @param {{ startOffset: number, endOffset: number } | undefined} contentSpan
915
+ * @param {string} sourceText
916
+ * @returns {string}
917
+ */
918
+ function getRawText(contentSpan, sourceText) {
919
+ if (!contentSpan) return '';
920
+ return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
921
+ }
922
+
923
+ /**
924
+ * Pass 4: Build nodes
925
+ * @param {BoundDecl[]} boundDecls
926
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
927
+ * @param {Array<{ sourceText: string, filePath: string }>} fileData
928
+ * @returns {{ nodes: Map<DeclId, NodeModel>, repositories: Map<DeclId, RepositoryModel>, references: Map<DeclId, ReferenceModel>, diagnostics: Diagnostic[] }}
929
+ */
930
+ function buildNodes(boundDecls, schemas, fileData) {
931
+ const nodes = new Map();
932
+ const repositories = new Map();
933
+ const references = new Map();
934
+ const diagnostics = [];
935
+
936
+ // Build file data map
937
+ const fileDataMap = new Map();
938
+ for (const file of fileData) {
939
+ fileDataMap.set(file.filePath, file.sourceText);
940
+ }
941
+
942
+ /**
943
+ * Build a text block from describe/title/note block
944
+ * @param {any} block
945
+ * @param {string} sourceText
946
+ * @returns {TextBlock | undefined}
947
+ */
948
+ function buildTextBlock(block, sourceText) {
949
+ if (!block || !block.contentSpan) return undefined;
950
+ const raw = getRawText(block.contentSpan, sourceText);
951
+ return {
952
+ raw,
953
+ normalized: normalizeProseBlock(raw),
954
+ source: block.span,
955
+ };
956
+ }
957
+
958
+ for (const bound of boundDecls) {
959
+ const { id, info, decl } = bound;
960
+ const sourceText = fileDataMap.get(info.span?.file || '') || '';
961
+
962
+ // Build container/concept nodes
963
+ if (['universe', 'anthology', 'series', 'book', 'chapter', 'concept', 'relates'].includes(info.kindResolved)) {
964
+ const node = {
965
+ id,
966
+ kind: info.kindResolved,
967
+ name: info.name,
968
+ parent: info.parentDeclId || undefined,
969
+ children: bound.children,
970
+ source: info.span,
971
+ };
972
+
973
+ if (decl.spelledKind && decl.spelledKind !== info.kindResolved) {
974
+ node.spelledKind = decl.spelledKind;
975
+ }
976
+
977
+ if (decl.aliases && decl.aliases.size > 0) {
978
+ node.aliases = Object.fromEntries(decl.aliases);
979
+ }
980
+
981
+ // Extract describe block
982
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
983
+ if (describeBlock) {
984
+ node.describe = buildTextBlock(describeBlock, sourceText);
985
+ }
986
+
987
+ // Extract title block
988
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
989
+ if (titleBlock) {
990
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
991
+ const titleValue = raw.trim();
992
+ if (titleValue.length > 0) {
993
+ node.title = titleValue;
994
+ }
995
+ }
996
+
997
+ // Extract relationships block (for concepts)
998
+ const relationshipsBlock = (decl.body || []).find(b => b.kind === 'relationships');
999
+ if (relationshipsBlock) {
1000
+ // Handle new entry-based syntax
1001
+ if (relationshipsBlock.entries) {
1002
+ node.relationships = {
1003
+ entries: relationshipsBlock.entries.map(entry => ({
1004
+ relationshipId: entry.relationshipId,
1005
+ targets: entry.targets.map(target => ({
1006
+ id: target.id,
1007
+ metadata: target.metadata ? buildTextBlock(target.metadata, sourceText) : undefined,
1008
+ })),
1009
+ })),
1010
+ source: relationshipsBlock.source,
1011
+ };
1012
+ } else if (relationshipsBlock.values) {
1013
+ // Legacy string values syntax
1014
+ node.relationships = {
1015
+ values: relationshipsBlock.values,
1016
+ source: relationshipsBlock.source,
1017
+ };
1018
+ }
1019
+ }
1020
+
1021
+ // Handle relates nodes specially
1022
+ if (info.kindResolved === 'relates') {
1023
+ // Extract endpoints (will be resolved in Pass 5)
1024
+ const a = decl.name.split(' and ')[0];
1025
+ const b = decl.name.split(' and ')[1] || '';
1026
+ node.unresolvedEndpoints = [a, b];
1027
+ node.endpoints = [];
1028
+
1029
+ // Extract from blocks
1030
+ const fromBlocks = (decl.body || []).filter(b => b.kind === 'from');
1031
+ node.from = {};
1032
+ for (const fromBlock of fromBlocks) {
1033
+ const endpoint = fromBlock.endpoint;
1034
+ const fromView = {
1035
+ source: fromBlock.source,
1036
+ };
1037
+
1038
+ // Extract relationships block from from block
1039
+ const fromRelBlock = (fromBlock.body || []).find(b => b.kind === 'relationships');
1040
+ if (fromRelBlock && fromRelBlock.values) {
1041
+ fromView.relationships = {
1042
+ values: fromRelBlock.values,
1043
+ source: fromRelBlock.source,
1044
+ };
1045
+ }
1046
+
1047
+ // Extract describe block from from block
1048
+ const fromDescribeBlock = (fromBlock.body || []).find(b => b.kind === 'describe');
1049
+ if (fromDescribeBlock) {
1050
+ fromView.describe = buildTextBlock(fromDescribeBlock, sourceText);
1051
+ }
1052
+
1053
+ // Extract unknown blocks
1054
+ const unknownBlocks = (fromBlock.body || []).filter(b =>
1055
+ b.kind && !['relationships', 'describe'].includes(b.kind)
1056
+ );
1057
+ if (unknownBlocks.length > 0) {
1058
+ fromView.unknownBlocks = unknownBlocks.map(ub => ({
1059
+ keyword: ub.kind || 'unknown',
1060
+ raw: getRawText(ub.contentSpan, sourceText),
1061
+ normalized: normalizeProseBlock(getRawText(ub.contentSpan, sourceText)),
1062
+ source: ub.span || ub.source,
1063
+ }));
1064
+ }
1065
+
1066
+ node.from[endpoint] = fromView;
1067
+ }
1068
+
1069
+ // Extract top-level relationships block for relates
1070
+ if (relationshipsBlock && relationshipsBlock.values) {
1071
+ node.relationships = {
1072
+ values: relationshipsBlock.values,
1073
+ source: relationshipsBlock.source,
1074
+ };
1075
+ }
1076
+ }
1077
+
1078
+ // Extract unknown blocks (non-container, non-standard blocks)
1079
+ const unknownBlocks = (decl.body || []).filter(b => {
1080
+ if (!b.kind) return false;
1081
+ const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference'];
1082
+ return !knownKinds.includes(b.kind);
1083
+ });
1084
+ if (unknownBlocks.length > 0) {
1085
+ node.unknownBlocks = unknownBlocks.map(ub => ({
1086
+ keyword: ub.kind,
1087
+ raw: getRawText(ub.contentSpan, sourceText),
1088
+ normalized: normalizeProseBlock(getRawText(ub.contentSpan, sourceText)),
1089
+ source: ub.span || ub.source,
1090
+ }));
1091
+ }
1092
+
1093
+ // Extract references block
1094
+ const referencesBlock = (decl.body || []).find(b => b.kind === 'references');
1095
+ if (referencesBlock && referencesBlock.items) {
1096
+ node.references = referencesBlock.items.map(item => {
1097
+ // Will be resolved in Pass 5
1098
+ return item.ref || item.name || '';
1099
+ });
1100
+ }
1101
+
1102
+ nodes.set(id, node);
1103
+ }
1104
+
1105
+ // Build repository nodes
1106
+ if (info.kindResolved === 'repository') {
1107
+ const repo = {
1108
+ id,
1109
+ name: info.name,
1110
+ url: '',
1111
+ source: info.span,
1112
+ };
1113
+
1114
+ // Extract url block
1115
+ const urlBlock = (decl.body || []).find(b => b.kind === 'url');
1116
+ if (urlBlock && urlBlock.value) {
1117
+ repo.url = urlBlock.value;
1118
+ }
1119
+
1120
+ // Extract title block
1121
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
1122
+ if (titleBlock) {
1123
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
1124
+ const titleValue = raw.trim().replace(/^['"]|['"]$/g, '').trim();
1125
+ if (titleValue.length > 0) {
1126
+ repo.title = titleValue;
1127
+ }
1128
+ }
1129
+
1130
+ // Extract describe block
1131
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
1132
+ if (describeBlock) {
1133
+ repo.describe = buildTextBlock(describeBlock, sourceText);
1134
+ }
1135
+
1136
+ // Extract note block
1137
+ const noteBlock = (decl.body || []).find(b => b.kind === 'note');
1138
+ if (noteBlock) {
1139
+ repo.note = buildTextBlock(noteBlock, sourceText);
1140
+ }
1141
+
1142
+ repositories.set(id, repo);
1143
+ }
1144
+
1145
+ // Build reference nodes
1146
+ if (info.kindResolved === 'reference') {
1147
+ const ref = {
1148
+ id,
1149
+ name: info.name,
1150
+ urls: [],
1151
+ source: info.span,
1152
+ };
1153
+
1154
+ // Extract url block
1155
+ const urlBlock = (decl.body || []).find(b => b.kind === 'url');
1156
+ if (urlBlock && urlBlock.value) {
1157
+ ref.urls = [urlBlock.value];
1158
+ }
1159
+
1160
+ // Extract repository reference
1161
+ const repositoryBlock = (decl.body || []).find(b => b.kind === 'reference' && b.repositoryName);
1162
+ if (repositoryBlock && repositoryBlock.repositoryName) {
1163
+ // Will be resolved later
1164
+ ref.repositoryName = repositoryBlock.repositoryName;
1165
+ } else if (decl.repositoryName) {
1166
+ // Named reference with "in <RepoName>"
1167
+ ref.repositoryName = decl.repositoryName;
1168
+ }
1169
+
1170
+ // Extract paths block
1171
+ const pathsBlock = (decl.body || []).find(b => b.kind === 'paths');
1172
+ if (pathsBlock && pathsBlock.paths) {
1173
+ ref.paths = pathsBlock.paths;
1174
+ }
1175
+
1176
+ // Extract title block
1177
+ const titleBlock = (decl.body || []).find(b => b.kind === 'title');
1178
+ if (titleBlock) {
1179
+ const raw = getRawText(titleBlock.contentSpan, sourceText);
1180
+ const titleValue = raw.trim().replace(/^['"]|['"]$/g, '').trim();
1181
+ if (titleValue.length > 0) {
1182
+ ref.title = titleValue;
1183
+ }
1184
+ }
1185
+
1186
+ // Extract describe block
1187
+ const describeBlock = (decl.body || []).find(b => b.kind === 'describe');
1188
+ if (describeBlock) {
1189
+ ref.describe = buildTextBlock(describeBlock, sourceText);
1190
+ }
1191
+
1192
+ // Extract note block
1193
+ const noteBlock = (decl.body || []).find(b => b.kind === 'note');
1194
+ if (noteBlock) {
1195
+ ref.note = buildTextBlock(noteBlock, sourceText);
1196
+ }
1197
+
1198
+ references.set(id, ref);
1199
+ }
1200
+ }
1201
+
1202
+ return { nodes, repositories, references, diagnostics };
1203
+ }
1204
+
1205
+ /**
1206
+ * Pass 5: Build edges from relationship usage
1207
+ * @param {BoundDecl[]} boundDecls
1208
+ * @param {Map<DeclId, NodeModel>} nodes
1209
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1210
+ * @param {DeclIndex} index
1211
+ * @param {Map<DeclId, DeclId>} parentMap
1212
+ * @param {Array<{ sourceText: string, filePath: string }>} fileData
1213
+ * @returns {{ edges: EdgeAssertedModel[], diagnostics: Diagnostic[] }}
1214
+ */
1215
+ function buildEdges(boundDecls, nodes, schemas, index, parentMap, fileData) {
1216
+ const edges = [];
1217
+ const diagnostics = [];
1218
+
1219
+ const fileDataMap = new Map();
1220
+ for (const file of fileData) {
1221
+ fileDataMap.set(file.filePath, file.sourceText);
1222
+ }
1223
+
1224
+ for (const bound of boundDecls) {
1225
+ const { id, decl, info } = bound;
1226
+ const node = nodes.get(id);
1227
+ if (!node) continue;
1228
+
1229
+ const sourceText = fileDataMap.get(info.span?.file || '') || '';
1230
+
1231
+ // Handle relationships block entries (new syntax)
1232
+ if (node.relationships && node.relationships.entries) {
1233
+ for (const entry of node.relationships.entries) {
1234
+ for (const target of entry.targets) {
1235
+ const pathSegments = target.id.split('.');
1236
+ let targetDeclId = null;
1237
+
1238
+ if (pathSegments.length === 1) {
1239
+ const resolved = resolveUnqualified(pathSegments[0], id, index, parentMap, node.relationships.source);
1240
+ if (resolved.ambiguous) {
1241
+ diagnostics.push({
1242
+ severity: 'error',
1243
+ message: `Ambiguous reference "${pathSegments[0]}" - use qualified path`,
1244
+ source: node.relationships.source,
1245
+ });
1246
+ } else if (resolved.declId) {
1247
+ targetDeclId = resolved.declId;
1248
+ }
1249
+ } else {
1250
+ const resolved = resolveQualified(pathSegments, index, node.relationships.source);
1251
+ if (resolved.declId) {
1252
+ targetDeclId = resolved.declId;
1253
+ }
1254
+ }
1255
+
1256
+ if (targetDeclId) {
1257
+ edges.push({
1258
+ from: id,
1259
+ via: entry.relationshipId,
1260
+ to: targetDeclId,
1261
+ meta: target.metadata,
1262
+ source: node.relationships.source,
1263
+ });
1264
+ }
1265
+ }
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ // Handle relates nodes - create bidirectional edges
1271
+ for (const bound of boundDecls) {
1272
+ const { id, decl, info } = bound;
1273
+ if (info.kindResolved !== 'relates') continue;
1274
+
1275
+ const node = nodes.get(id);
1276
+ if (!node || !node.endpoints || node.endpoints.length !== 2) continue;
1277
+
1278
+ const [endpointA, endpointB] = node.endpoints;
1279
+ const viaLabel = (node.relationships && node.relationships.values && node.relationships.values[0]) || 'related to';
1280
+
1281
+ edges.push({
1282
+ from: endpointA,
1283
+ via: viaLabel,
1284
+ to: endpointB,
1285
+ meta: node.from?.[endpointA]?.describe,
1286
+ source: node.source,
1287
+ bidirectional: true,
1288
+ });
1289
+ }
1290
+
1291
+ return { edges, diagnostics };
1292
+ }
1293
+
1294
+ /**
1295
+ * Pass 6: Normalize edges - add inverse edges for paired/symmetric relationships
1296
+ * @param {EdgeAssertedModel[]} edges
1297
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1298
+ * @param {string} universeName
1299
+ * @returns {NormalizedEdgeModel[]}
1300
+ */
1301
+ function normalizeEdges(edges, schemas, universeName) {
1302
+ const normalized = [];
1303
+ const seenEdges = new Set();
1304
+
1305
+ for (const asserted of edges) {
1306
+ const edgeKey = `${asserted.from}:${asserted.via}:${asserted.to}`;
1307
+
1308
+ if (seenEdges.has(edgeKey)) continue;
1309
+ seenEdges.add(edgeKey);
1310
+
1311
+ // Add asserted edge
1312
+ normalized.push({
1313
+ from: asserted.from,
1314
+ via: asserted.via,
1315
+ to: asserted.to,
1316
+ asserted: true,
1317
+ sourceRefs: [asserted.source],
1318
+ meta: asserted.meta,
1319
+ bidirectional: asserted.bidirectional || false,
1320
+ });
1321
+
1322
+ // Find relationship schema (check all scopes)
1323
+ let relDecl = null;
1324
+ for (const registry of schemas.values()) {
1325
+ if (registry.schemas.has(asserted.via)) {
1326
+ relDecl = registry.schemas.get(asserted.via);
1327
+ break;
1328
+ }
1329
+ }
1330
+
1331
+ // Add inverse edge if applicable (only for declared relationships, not relates)
1332
+ if (relDecl && !asserted.bidirectional) {
1333
+ if (relDecl.type === 'paired') {
1334
+ // Determine inverse side
1335
+ const inverseVia = asserted.via === relDecl.leftId ? relDecl.rightId : relDecl.leftId;
1336
+
1337
+ if (inverseVia) {
1338
+ const inverseKey = `${asserted.to}:${inverseVia}:${asserted.from}`;
1339
+ if (!seenEdges.has(inverseKey)) {
1340
+ normalized.push({
1341
+ from: asserted.to,
1342
+ via: inverseVia,
1343
+ to: asserted.from,
1344
+ asserted: false,
1345
+ sourceRefs: [asserted.source],
1346
+ meta: asserted.meta,
1347
+ });
1348
+ seenEdges.add(inverseKey);
1349
+ }
1350
+ }
1351
+ } else if (relDecl.type === 'symmetric') {
1352
+ // Symmetric: add reverse edge with same relationship ID
1353
+ const reverseKey = `${asserted.to}:${asserted.via}:${asserted.from}`;
1354
+ if (!seenEdges.has(reverseKey)) {
1355
+ normalized.push({
1356
+ from: asserted.to,
1357
+ via: asserted.via,
1358
+ to: asserted.from,
1359
+ asserted: false,
1360
+ sourceRefs: [asserted.source],
1361
+ meta: asserted.meta,
1362
+ });
1363
+ seenEdges.add(reverseKey);
1364
+ }
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ return normalized;
1370
+ }
1371
+
1372
+ /**
1373
+ * Resolve relates node endpoints
1374
+ * @param {Map<DeclId, NodeModel>} nodes
1375
+ * @param {DeclIndex} index
1376
+ * @param {Map<DeclId, DeclId>} parentMap
1377
+ * @param {BoundDecl[]} boundDecls
1378
+ * @returns {Diagnostic[]}
1379
+ */
1380
+ function resolveRelatesEndpoints(nodes, index, parentMap, boundDecls) {
1381
+ const diagnostics = [];
1382
+
1383
+ for (const bound of boundDecls) {
1384
+ const { id, decl, info } = bound;
1385
+ if (info.kindResolved !== 'relates') continue;
1386
+
1387
+ const node = nodes.get(id);
1388
+ if (!node || !node.unresolvedEndpoints) continue;
1389
+
1390
+ const resolvedEndpoints = [];
1391
+ const unresolved = [];
1392
+
1393
+ for (const endpointName of node.unresolvedEndpoints) {
1394
+ const resolved = resolveUnqualified(endpointName, id, index, parentMap, info.span);
1395
+ if (resolved.declId && !resolved.ambiguous) {
1396
+ resolvedEndpoints.push(resolved.declId);
1397
+ } else {
1398
+ unresolved.push(endpointName);
1399
+ if (!resolved.ambiguous) {
1400
+ diagnostics.push({
1401
+ severity: 'warning',
1402
+ message: `Unresolved relates endpoint "${endpointName}"`,
1403
+ source: info.span,
1404
+ });
1405
+ } else {
1406
+ diagnostics.push({
1407
+ severity: 'error',
1408
+ message: `Ambiguous relates endpoint "${endpointName}" - use qualified path`,
1409
+ source: info.span,
1410
+ });
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ node.endpoints = resolvedEndpoints;
1416
+ if (unresolved.length > 0) {
1417
+ node.unresolvedEndpoints = unresolved;
1418
+ } else {
1419
+ delete node.unresolvedEndpoints;
1420
+ }
1421
+
1422
+ // Resolve from blocks: convert endpoint names to node IDs
1423
+ if (node.from) {
1424
+ const resolvedFrom = {};
1425
+ for (const endpointName in node.from) {
1426
+ const resolved = resolveUnqualified(endpointName, id, index, parentMap, info.span);
1427
+ if (resolved.declId && !resolved.ambiguous) {
1428
+ resolvedFrom[resolved.declId] = node.from[endpointName];
1429
+ } else {
1430
+ // Keep unresolved from blocks keyed by name
1431
+ resolvedFrom[endpointName] = node.from[endpointName];
1432
+ }
1433
+ }
1434
+ node.from = resolvedFrom;
1435
+ }
1436
+ }
1437
+
1438
+ return diagnostics;
1439
+ }
1440
+
1441
+ /**
1442
+ * Main function: Build UniverseGraph from parsed AST files
1443
+ * @param {Array<{ kind: string, decls: any[], source?: SourceSpan, sourceText?: string }> | { kind: string, decls: any[], source?: SourceSpan, sourceText?: string }} fileAstOrFileAsts
1444
+ * @param {Object} [options]
1445
+ * @returns {UniverseGraph}
1446
+ */
1447
+ export function buildGraph(fileAstOrFileAsts, options) {
1448
+ // Normalize input to array
1449
+ const fileASTs = Array.isArray(fileAstOrFileAsts) ? fileAstOrFileAsts : [fileAstOrFileAsts];
1450
+
1451
+ // Pass 0: Normalize AST
1452
+ const normalized = normalizeAst(fileASTs);
1453
+
1454
+ // Pass 1: Index declarations
1455
+ const { index, normalizedDecls } = indexDecls(normalized);
1456
+
1457
+ // Extract universe name
1458
+ const universeNames = Array.from(new Set(normalizedDecls.filter(d => d.kind === 'universe').map(d => d.name)));
1459
+ const universeName = universeNames[0] || 'Unknown';
1460
+
1461
+ // Pass 2: Bind parents and resolve kinds
1462
+ const { boundDecls, diagnostics: bindDiags, parentMap } = bindParentsAndKinds(index, normalizedDecls);
1463
+
1464
+ // Pass 3: Compile schemas
1465
+ const { schemas, diagnostics: schemaDiags } = compileSchemas(boundDecls, index);
1466
+
1467
+ // Pass 4: Build nodes
1468
+ const fileData = normalized.map(n => ({ sourceText: n.sourceText, filePath: n.filePath }));
1469
+ const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(boundDecls, schemas, fileData);
1470
+
1471
+ // Resolve relates endpoints
1472
+ const relatesDiags = resolveRelatesEndpoints(nodes, index, parentMap, boundDecls);
1473
+
1474
+ // Pass 5: Build edges
1475
+ const { edges: assertedEdges, diagnostics: edgeDiags } = buildEdges(boundDecls, nodes, schemas, index, parentMap, fileData);
1476
+
1477
+ // Pass 6: Normalize edges
1478
+ const normalizedEdges = normalizeEdges(assertedEdges, schemas, universeName);
1479
+
1480
+ // Collect all diagnostics
1481
+ const allDiagnostics = [
1482
+ ...index.diagnostics,
1483
+ ...bindDiags,
1484
+ ...schemaDiags,
1485
+ ...nodeDiags,
1486
+ ...relatesDiags,
1487
+ ...edgeDiags,
1488
+ ];
1489
+
1490
+ // Build relationshipDecls object
1491
+ const relationshipDecls = {};
1492
+ relationshipDecls[universeName] = {};
1493
+ for (const registry of schemas.values()) {
1494
+ for (const [schemaId, schema] of registry.schemas.entries()) {
1495
+ if (schema.type === 'symmetric') {
1496
+ relationshipDecls[universeName][schemaId] = {
1497
+ type: 'symmetric',
1498
+ id: schemaId,
1499
+ describe: undefined, // TODO: extract from schema.decl.body
1500
+ label: undefined, // TODO: extract from schema.decl.body
1501
+ source: schema.decl.source,
1502
+ };
1503
+ } else if (schema.type === 'paired') {
1504
+ relationshipDecls[universeName][schema.leftId] = {
1505
+ type: 'paired',
1506
+ leftId: schema.leftId,
1507
+ rightId: schema.rightId,
1508
+ describe: undefined, // TODO: extract from schema.decl.body
1509
+ from: {}, // TODO: extract from schema.decl.body
1510
+ source: schema.decl.source,
1511
+ };
1512
+ relationshipDecls[universeName][schema.rightId] = relationshipDecls[universeName][schema.leftId];
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ // Build nodes object (convert Map to Record)
1518
+ const nodesObj = {};
1519
+ for (const [id, node] of nodes.entries()) {
1520
+ nodesObj[id] = node;
1521
+ }
1522
+
1523
+ // Build repositories object
1524
+ const reposObj = {};
1525
+ for (const [id, repo] of repositories.entries()) {
1526
+ reposObj[id] = repo;
1527
+ }
1528
+
1529
+ // Build references object
1530
+ const refsObj = {};
1531
+ for (const [id, ref] of references.entries()) {
1532
+ refsObj[id] = ref;
1533
+ }
1534
+
1535
+ // Build universes object
1536
+ const universes = {};
1537
+ const universeNode = Array.from(nodes.values()).find(n => n.kind === 'universe');
1538
+ if (universeNode) {
1539
+ universes[universeName] = {
1540
+ name: universeName,
1541
+ root: universeNode.id,
1542
+ };
1543
+ }
1544
+
1545
+ return {
1546
+ version: 1,
1547
+ universes,
1548
+ nodes: nodesObj,
1549
+ edges: normalizedEdges,
1550
+ edgesAsserted: assertedEdges,
1551
+ diagnostics: allDiagnostics,
1552
+ repositories: reposObj,
1553
+ references: refsObj,
1554
+ documentsByName: {}, // TODO: implement
1555
+ relationshipDecls,
1556
+ };
1557
+ }
1558
+
1559
+ // Debug helpers (not exported, for testing)
1560
+ /**
1561
+ * @param {DeclIndex} index
1562
+ * @returns {string}
1563
+ */
1564
+ function debugIndex(index) {
1565
+ const lines = [];
1566
+ lines.push('=== DeclIndex ===');
1567
+ lines.push(`Declarations: ${index.declIndex.size}`);
1568
+ lines.push(`Scopes: ${index.scopeIndex.size}`);
1569
+ lines.push(`Series: ${index.seriesIndex.size}`);
1570
+ lines.push(`Alias tables: ${index.aliasTablesByScope.size}`);
1571
+ return lines.join('\n');
1572
+ }
1573
+
1574
+ /**
1575
+ * @param {BoundDecl[]} boundDecls
1576
+ * @returns {string}
1577
+ */
1578
+ function debugBound(boundDecls) {
1579
+ const lines = [];
1580
+ lines.push('=== BoundDecls ===');
1581
+ for (const bound of boundDecls) {
1582
+ lines.push(`${bound.id}: ${bound.info.kindResolved} ${bound.info.name} (parent: ${bound.info.parentDeclId || 'none'})`);
1583
+ }
1584
+ return lines.join('\n');
1585
+ }
1586
+
1587
+ /**
1588
+ * @param {Map<ScopeKey, SchemaRegistry>} schemas
1589
+ * @returns {string}
1590
+ */
1591
+ function debugSchemas(schemas) {
1592
+ const lines = [];
1593
+ lines.push('=== Schemas ===');
1594
+ for (const [scopeKey, registry] of schemas.entries()) {
1595
+ lines.push(`Scope: ${scopeKey}`);
1596
+ for (const [schemaId, schema] of registry.schemas.entries()) {
1597
+ lines.push(` ${schemaId}: ${schema.type}`);
1598
+ }
1599
+ }
1600
+ return lines.join('\n');
1601
+ }
1602
+
1603
+ /**
1604
+ * @param {EdgeAssertedModel[]} edges
1605
+ * @returns {string}
1606
+ */
1607
+ function debugEdges(edges) {
1608
+ const lines = [];
1609
+ lines.push('=== Edges ===');
1610
+ for (const edge of edges) {
1611
+ lines.push(`${edge.from} --[${edge.via}]--> ${edge.to}`);
1612
+ }
1613
+ return lines.join('\n');
1614
+ }
1615
+