@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.
- package/package.json +1 -1
- package/src/index.js +30 -0
- package/src/universe/graph.js +1619 -0
- package/src/universe/parser.js +1751 -0
- package/src/universe/scanner.js +240 -0
- package/src/universe/scene-manifest.js +856 -0
- package/src/universe/test-graph.js +157 -0
- package/src/universe/test-parser.js +61 -0
- package/src/universe/test-scanner.js +37 -0
- package/src/universe/universe.prose +169 -0
- package/src/universe/validator.js +862 -0
|
@@ -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
|
+
|