@xdbml/parse 0.1.0-poc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,854 @@
1
+ /**
2
+ * Name resolution pass (spec §26.10 / §26.15, parser batch P6).
3
+ *
4
+ * `resolveNames(doc)` walks an xDBML document (the flattened view; clone
5
+ * blocks have been merged) and produces:
6
+ *
7
+ * - A symbol table mapping qualified names to declarations
8
+ * - A list of diagnostics: unresolved references and name conflicts
9
+ *
10
+ * The resolver does NOT mutate the AST. It is a pure side computation
11
+ * that downstream consumers can run for validation, IDE support, code
12
+ * generation, etc. Spans on diagnostics point to the offending construct
13
+ * in the source, so callers can surface them as editor markers.
14
+ *
15
+ * Per the spec, name resolution is a two-pass process:
16
+ *
17
+ * Pass 1: Collect declarations.
18
+ * Walk all top-level + container-body declarations and add them to
19
+ * the symbol table. Duplicates (same qualified name + same kind)
20
+ * produce a `duplicate-declaration` diagnostic and the LATER
21
+ * declaration is silently dropped from the table.
22
+ *
23
+ * Pass 2: Resolve references.
24
+ * Walk all reference sites and look up targets in the symbol table.
25
+ * References that don't resolve produce diagnostics. Built-in scalar
26
+ * and BSON types are recognized via SCALAR_TYPES / BSON_TYPES and
27
+ * never produce unresolved-type diagnostics.
28
+ *
29
+ * Two passes handle forward references (a Type declared at end of file
30
+ * can be referenced from a field declared at the top) and circular
31
+ * imports (cycles are already collapsed by `flatten()` / cycles in P5
32
+ * resolution; the resolver just sees the merged namespace).
33
+ *
34
+ * The resolver flattens its input internally, so callers don't need to
35
+ * `flatten()` first. Callers that want to surface diagnostics tied to
36
+ * the original (provenance-preserving) AST can map positions back via
37
+ * span comparison; in practice the cloned declarations' spans point
38
+ * into the importing file's clone block, which is where the user can
39
+ * edit them, so the natural workflow works correctly.
40
+ */
41
+ import { SCALAR_TYPES, BSON_TYPES } from "./keywords.js";
42
+ import { flatten } from "./module-resolver.js";
43
+ /**
44
+ * Read-only handle on the collected symbol table.
45
+ *
46
+ * Lookup is by qualified name (e.g., `core.dim_customer`). The class
47
+ * also exposes a `lookupBare()` for the common case where the name has
48
+ * no container prefix and the caller wants to find the unique match
49
+ * (returns undefined if ambiguous or missing).
50
+ */
51
+ export class SymbolTable {
52
+ byQualified;
53
+ byBare;
54
+ constructor(entries) {
55
+ this.byQualified = new Map();
56
+ this.byBare = new Map();
57
+ for (const e of entries) {
58
+ this.byQualified.set(e.qualifiedName, e);
59
+ const list = this.byBare.get(e.name) ?? [];
60
+ list.push(e);
61
+ this.byBare.set(e.name, list);
62
+ }
63
+ }
64
+ /** Look up by canonical qualified name. */
65
+ lookup(qualifiedName) {
66
+ return this.byQualified.get(qualifiedName);
67
+ }
68
+ /**
69
+ * Look up by bare name. Returns the unique entry if exactly one match,
70
+ * or undefined when missing or ambiguous (multiple containers contain
71
+ * an entry with this bare name). For ambiguous cases, callers should
72
+ * inspect `lookupAllBare()` if they want to disambiguate.
73
+ */
74
+ lookupBare(name) {
75
+ const list = this.byBare.get(name);
76
+ if (!list || list.length !== 1)
77
+ return undefined;
78
+ return list[0];
79
+ }
80
+ /** Look up by bare name; returns all matches. */
81
+ lookupAllBare(name) {
82
+ return this.byBare.get(name) ?? [];
83
+ }
84
+ /** Iterate all entries in declaration order. */
85
+ entries() {
86
+ return this.byQualified.values();
87
+ }
88
+ /** Total number of entries. */
89
+ get size() {
90
+ return this.byQualified.size;
91
+ }
92
+ }
93
+ /* -------------------------------------------------------------------------
94
+ * Built-in type recognition
95
+ *
96
+ * Field type expressions can name a builtin scalar (`int`, `varchar`),
97
+ * a BSON type (`objectId`), or a user-defined Named Type (`Email`).
98
+ * The parser doesn't distinguish at parse time -- they all land as
99
+ * ScalarType nodes (or NamedTypeReference in some contexts). The
100
+ * resolver uses these sets to decide whether to look up a name in the
101
+ * symbol table.
102
+ *
103
+ * Matching is case-insensitive: `Int`, `int`, `INT` all map to the same
104
+ * builtin per spec §3.8.
105
+ * ----------------------------------------------------------------------- */
106
+ const BUILTIN_TYPES = new Set([
107
+ ...SCALAR_TYPES.map((t) => t.toLowerCase()),
108
+ ...BSON_TYPES.map((t) => t.toLowerCase()),
109
+ ]);
110
+ function isBuiltinType(name) {
111
+ return BUILTIN_TYPES.has(name.toLowerCase());
112
+ }
113
+ /* -------------------------------------------------------------------------
114
+ * Main entry point
115
+ * ----------------------------------------------------------------------- */
116
+ /**
117
+ * Resolve names in an xDBML document. Flattens the AST internally
118
+ * (so callers don't need to call `flatten()` first), then runs the
119
+ * two-pass resolution algorithm. Returns diagnostics and the symbol
120
+ * table.
121
+ *
122
+ * Cost is roughly linear in (declarations + reference sites). For
123
+ * typical schemas (10s-100s of entities) this is fast enough to run
124
+ * on every keystroke in an interactive editor.
125
+ */
126
+ export function resolveNames(doc) {
127
+ // Always work on the flattened view so cloned declarations are visible.
128
+ // The flatten operation is shallow-cheap relative to the resolver pass.
129
+ const flat = flatten(doc);
130
+ const diagnostics = [];
131
+ const entries = [];
132
+ // Pass 1: collect declarations.
133
+ collectDeclarations(flat, entries, diagnostics);
134
+ const symbols = new SymbolTable(entries);
135
+ // Pass 2: resolve references.
136
+ resolveReferences(flat, symbols, diagnostics);
137
+ return { diagnostics, symbols };
138
+ }
139
+ /* -------------------------------------------------------------------------
140
+ * Pass 1: collect declarations
141
+ * ----------------------------------------------------------------------- */
142
+ function collectDeclarations(doc, entries, diagnostics) {
143
+ const seen = new Set(); // qualified-name keys to detect duplicates
144
+ for (const stmt of doc.statements) {
145
+ addTopLevelDeclaration(stmt, entries, diagnostics, seen);
146
+ }
147
+ }
148
+ function addTopLevelDeclaration(stmt, entries, diagnostics, seen) {
149
+ switch (stmt.kind) {
150
+ case 'EntityDeclaration':
151
+ addEntry(stmt.name, undefined, 'entity', stmt, stmt.span, entries, diagnostics, seen);
152
+ return;
153
+ case 'TypeDeclaration':
154
+ addEntry(stmt.name, undefined, 'type', stmt, stmt.span, entries, diagnostics, seen);
155
+ return;
156
+ case 'EnumDeclaration':
157
+ addEntry(stmt.name, undefined, 'enum', stmt, stmt.span, entries, diagnostics, seen);
158
+ return;
159
+ case 'EdgeDeclaration':
160
+ addEntry(stmt.name, undefined, 'edge', stmt, stmt.span, entries, diagnostics, seen);
161
+ return;
162
+ case 'ViewDeclaration':
163
+ addEntry(stmt.name, undefined, 'view', stmt, stmt.span, entries, diagnostics, seen);
164
+ return;
165
+ case 'ContainerDeclaration': {
166
+ addEntry(stmt.name, undefined, 'container', stmt, stmt.span, entries, diagnostics, seen);
167
+ // Walk container body for nested entities, edges, views, enums.
168
+ for (const body of stmt.body) {
169
+ switch (body.kind) {
170
+ case 'EntityDeclaration':
171
+ addEntry(body.name, stmt.name, 'entity', body, body.span, entries, diagnostics, seen);
172
+ break;
173
+ case 'EdgeDeclaration':
174
+ addEntry(body.name, stmt.name, 'edge', body, body.span, entries, diagnostics, seen);
175
+ break;
176
+ case 'ViewDeclaration':
177
+ addEntry(body.name, stmt.name, 'view', body, body.span, entries, diagnostics, seen);
178
+ break;
179
+ case 'EnumDeclaration':
180
+ addEntry(body.name, stmt.name, 'enum', body, body.span, entries, diagnostics, seen);
181
+ break;
182
+ // NoteBlock and ModuleImportDirective contribute no symbols.
183
+ default:
184
+ break;
185
+ }
186
+ }
187
+ return;
188
+ }
189
+ case 'TableGroupDeclaration':
190
+ addEntry(stmt.name, undefined, 'tablegroup', stmt, stmt.span, entries, diagnostics, seen);
191
+ return;
192
+ case 'TablePartialDeclaration':
193
+ addEntry(stmt.name, undefined, 'tablepartial', stmt, stmt.span, entries, diagnostics, seen);
194
+ return;
195
+ case 'NoteDeclaration':
196
+ if (stmt.name) {
197
+ addEntry(stmt.name, undefined, 'note', stmt, stmt.span, entries, diagnostics, seen);
198
+ }
199
+ return;
200
+ // No symbols contributed by Project, Ref, top-level Records, ModuleImportDirective.
201
+ default:
202
+ return;
203
+ }
204
+ }
205
+ function addEntry(name, containerName, kind, declaration, span, entries, diagnostics, seen) {
206
+ const qualifiedName = containerName ? `${containerName}.${name}` : name;
207
+ const key = `${kind}:${qualifiedName}`;
208
+ if (seen.has(key)) {
209
+ diagnostics.push({
210
+ severity: 'error',
211
+ code: 'duplicate-declaration',
212
+ message: `Duplicate ${kind} declaration '${qualifiedName}'. ` +
213
+ `Each ${kind} must have a unique qualified name within its scope.`,
214
+ span,
215
+ });
216
+ return;
217
+ }
218
+ seen.add(key);
219
+ entries.push({
220
+ qualifiedName,
221
+ name,
222
+ containerName,
223
+ kind,
224
+ declaration,
225
+ position: span.start,
226
+ });
227
+ }
228
+ /* -------------------------------------------------------------------------
229
+ * Pass 2: resolve references
230
+ * ----------------------------------------------------------------------- */
231
+ function resolveReferences(doc, symbols, diagnostics) {
232
+ for (const stmt of doc.statements) {
233
+ resolveTopLevel(stmt, symbols, diagnostics);
234
+ }
235
+ }
236
+ function resolveTopLevel(stmt, symbols, diagnostics) {
237
+ switch (stmt.kind) {
238
+ case 'EntityDeclaration':
239
+ resolveEntityBody(stmt, undefined, symbols, diagnostics);
240
+ return;
241
+ case 'ContainerDeclaration':
242
+ for (const body of stmt.body) {
243
+ if (body.kind === 'EntityDeclaration') {
244
+ resolveEntityBody(body, stmt.name, symbols, diagnostics);
245
+ }
246
+ else if (body.kind === 'ViewDeclaration') {
247
+ // Views have field declarations too -- walk them as if they
248
+ // were an entity.
249
+ resolveViewBody(body, stmt.name, symbols, diagnostics);
250
+ }
251
+ // EdgeDeclaration: similar shape but rare; we walk its fields
252
+ // when present. Skip for now to keep scope tight.
253
+ }
254
+ return;
255
+ case 'ViewDeclaration':
256
+ resolveViewBody(stmt, undefined, symbols, diagnostics);
257
+ return;
258
+ case 'RefDeclaration':
259
+ resolveRefSpec(stmt.spec.source, symbols, diagnostics);
260
+ resolveRefSpec(stmt.spec.target, symbols, diagnostics);
261
+ return;
262
+ case 'TableGroupDeclaration':
263
+ for (const member of stmt.members) {
264
+ resolveTableGroupMember(member, stmt.span, symbols, diagnostics);
265
+ }
266
+ return;
267
+ case 'TopLevelRecordsDeclaration': {
268
+ const entity = resolveEntityRef(stmt.entityRef, symbols);
269
+ if (!entity) {
270
+ diagnostics.push({
271
+ severity: 'error',
272
+ code: 'unresolved-records-entity',
273
+ message: `Top-level records declaration refers to unknown entity '${stmt.entityRef}'.`,
274
+ span: stmt.span,
275
+ });
276
+ }
277
+ else {
278
+ // Validate each column is a field of the entity.
279
+ const fieldNames = new Set();
280
+ for (const item of entity.declaration.kind === 'EntityDeclaration' ? entity.declaration.body : []) {
281
+ if (item.kind === 'FieldDeclaration')
282
+ fieldNames.add(item.name);
283
+ }
284
+ for (const col of stmt.columns) {
285
+ if (!fieldNames.has(col)) {
286
+ diagnostics.push({
287
+ severity: 'error',
288
+ code: 'unresolved-records-column',
289
+ message: `Records column '${col}' is not a field of entity '${stmt.entityRef}'.`,
290
+ span: stmt.span,
291
+ });
292
+ }
293
+ }
294
+ }
295
+ return;
296
+ }
297
+ // No reference sites in TypeDeclaration headers (their settings are
298
+ // open-vocabulary). Field-type references inside a Type's object form
299
+ // are handled when we walk that Type as a "pseudo-entity" body --
300
+ // but for P6 we keep the scope tight and don't recurse into Type
301
+ // bodies. The cost is missed unresolved-type diagnostics for fields
302
+ // INSIDE composite Named Types, which is acceptable for v0.2.
303
+ default:
304
+ return;
305
+ }
306
+ }
307
+ function resolveEntityBody(entity, containerName, symbols, diagnostics) {
308
+ for (const item of entity.body) {
309
+ switch (item.kind) {
310
+ case 'FieldDeclaration':
311
+ resolveFieldDeclaration(item, symbols, diagnostics);
312
+ break;
313
+ case 'PartialInjection': {
314
+ const target = symbols.lookup(item.partialName);
315
+ if (!target || target.kind !== 'tablepartial') {
316
+ diagnostics.push({
317
+ severity: 'error',
318
+ code: 'unresolved-partial',
319
+ message: `Partial injection '~${item.partialName}' does not resolve to a TablePartial declaration.`,
320
+ span: item.span,
321
+ });
322
+ }
323
+ break;
324
+ }
325
+ // ChecksBlock, IndexesBlock, RecordsBlock, NoteBlock: contain
326
+ // opaque expressions or content that the resolver doesn't try to
327
+ // type-check. Skip.
328
+ default:
329
+ break;
330
+ }
331
+ }
332
+ void containerName; // currently unused; reserved for future "field in nested scope" diagnostics
333
+ }
334
+ function resolveViewBody(view, containerName, symbols, diagnostics) {
335
+ // Views can contain field declarations; walk them the same way.
336
+ for (const item of view.body) {
337
+ if (item.kind === 'FieldDeclaration') {
338
+ resolveFieldDeclaration(item, symbols, diagnostics);
339
+ }
340
+ }
341
+ void containerName;
342
+ }
343
+ function resolveFieldDeclaration(field, symbols, diagnostics) {
344
+ // Resolve the field's type expression. This walks into nested object
345
+ // types, arrays, unions, etc. recursively -- callers don't need to
346
+ // do their own recursion.
347
+ resolveTypeExpression(field.type, symbols, diagnostics);
348
+ // Resolve any inline `ref:` setting on the field. Other settings
349
+ // (notes, defaults, etc.) carry no name references that the
350
+ // resolver tracks.
351
+ for (const setting of field.settings) {
352
+ if (setting.value && setting.value.kind === 'RefValue') {
353
+ resolveRefSpec(setting.value.target, symbols, diagnostics);
354
+ }
355
+ }
356
+ }
357
+ function resolveTypeExpression(expr, symbols, diagnostics) {
358
+ switch (expr.kind) {
359
+ case 'ScalarType':
360
+ // A ScalarType is either a builtin or a reference to a Named Type
361
+ // (the parser doesn't distinguish at parse time). Look up only
362
+ // when the name isn't a builtin.
363
+ if (!isBuiltinType(expr.name)) {
364
+ const found = symbols.lookup(expr.name) ?? symbols.lookupBare(expr.name);
365
+ if (!found || found.kind !== 'type') {
366
+ diagnostics.push({
367
+ severity: 'error',
368
+ code: 'unresolved-type',
369
+ message: `Type '${expr.name}' is not a built-in type or declared Named Type.`,
370
+ span: expr.span,
371
+ });
372
+ }
373
+ }
374
+ return;
375
+ case 'NamedTypeReference': {
376
+ const found = symbols.lookup(expr.name) ?? symbols.lookupBare(expr.name);
377
+ if (!found || found.kind !== 'type') {
378
+ diagnostics.push({
379
+ severity: 'error',
380
+ code: 'unresolved-type',
381
+ message: `Named type '${expr.name}' is not declared.`,
382
+ span: expr.span,
383
+ });
384
+ }
385
+ return;
386
+ }
387
+ case 'ObjectType':
388
+ for (const field of expr.fields) {
389
+ // ObjectType.fields is `(FieldDeclaration | NoteBlock | PartialInjection)[]`.
390
+ // Only FieldDeclaration carries a type expression to resolve;
391
+ // PartialInjection has a name (resolved as a partial reference),
392
+ // NoteBlock has no name resolution surface.
393
+ if (field.kind === 'FieldDeclaration') {
394
+ resolveTypeExpression(field.type, symbols, diagnostics);
395
+ }
396
+ else if (field.kind === 'PartialInjection') {
397
+ const target = symbols.lookup(field.partialName);
398
+ if (!target || target.kind !== 'tablepartial') {
399
+ diagnostics.push({
400
+ severity: 'error',
401
+ code: 'unresolved-partial',
402
+ message: `Partial injection '~${field.partialName}' does not resolve to a TablePartial declaration.`,
403
+ span: field.span,
404
+ });
405
+ }
406
+ }
407
+ }
408
+ return;
409
+ case 'ArrayType':
410
+ // `elementType` is optional (when the array body uses the `name type`
411
+ // alias form, the element type is reachable via a nested structure
412
+ // that's covered elsewhere; here we only recurse when present).
413
+ if (expr.elementType) {
414
+ resolveTypeExpression(expr.elementType, symbols, diagnostics);
415
+ }
416
+ return;
417
+ case 'MapType':
418
+ resolveTypeExpression(expr.keyType, symbols, diagnostics);
419
+ resolveTypeExpression(expr.valueType, symbols, diagnostics);
420
+ return;
421
+ case 'SetType':
422
+ resolveTypeExpression(expr.elementType, symbols, diagnostics);
423
+ return;
424
+ case 'TupleType':
425
+ for (const elem of expr.elements) {
426
+ resolveTypeExpression(elem.type, symbols, diagnostics);
427
+ }
428
+ return;
429
+ case 'JsonType':
430
+ // No nested type expressions.
431
+ return;
432
+ case 'OneOfType':
433
+ case 'AnyOfType':
434
+ case 'AllOfType':
435
+ for (const alt of expr.alternatives) {
436
+ resolveTypeExpression(alt.type, symbols, diagnostics);
437
+ }
438
+ return;
439
+ case 'UnionType':
440
+ // UnionType uses `members` (not `alternatives`), and each member is
441
+ // a (ScalarType | NamedTypeReference | NullTypeLiteral). The first
442
+ // two have name fields that may need resolution; NullTypeLiteral is
443
+ // a built-in placeholder for the `null` literal and has nothing to
444
+ // resolve, so we skip it.
445
+ for (const member of expr.members) {
446
+ if (member.kind !== 'NullTypeLiteral') {
447
+ resolveTypeExpression(member, symbols, diagnostics);
448
+ }
449
+ }
450
+ return;
451
+ default:
452
+ return;
453
+ }
454
+ }
455
+ function resolveRefSpec(endpoint, symbols, diagnostics) {
456
+ // Foreign-key endpoint resolution proceeds in four phases:
457
+ //
458
+ // PHASE 1 Find the entity. The entity is the longest leading run of
459
+ // PathField segments that resolves to a declared entity.
460
+ //
461
+ // PHASE 2 Compute the post-entity path -- the segments that name a
462
+ // field on the entity and (optionally) navigate into the
463
+ // field's type. Includes any non-PathField segments (array
464
+ // wildcards, indices, map keys) that follow.
465
+ //
466
+ // PHASE 3 Validate the top-level field exists on the entity.
467
+ //
468
+ // PHASE 4 If more segments remain, walk the field's type expression
469
+ // consuming one segment at a time. Composite endpoints
470
+ // (`(f1, f2)`) get validated against whichever type the walk
471
+ // lands on (the entity itself, or a nested object).
472
+ //
473
+ // The walker handles ObjectType field access, Array/Set wildcards and
474
+ // indices, Map key access, Tuple positional access, and Named Type
475
+ // dereferencing (with a depth limit to break cycles).
476
+ // PHASE 1: collect leading PathField run.
477
+ const leadingFields = [];
478
+ for (const seg of endpoint.path) {
479
+ if (seg.kind === 'PathField') {
480
+ leadingFields.push(seg.name);
481
+ }
482
+ else {
483
+ // Stop at the first non-field segment -- nothing past it can be
484
+ // part of the entity name.
485
+ break;
486
+ }
487
+ }
488
+ if (leadingFields.length === 0)
489
+ return;
490
+ const hasComposite = !!(endpoint.compositeFields && endpoint.compositeFields.length > 0);
491
+ const hasNonFieldTail = endpoint.path.length > leadingFields.length;
492
+ // Decide how many leading PathFields could form the entity. If there
493
+ // are non-field segments after the leading run (e.g., `[*]`), the
494
+ // entity must end at least one PathField before, because navigation
495
+ // through array/map/etc. can only begin AFTER a field has been
496
+ // selected. If there are no non-field segments and no composite
497
+ // (the simple `a.b.c` case), the entity is everything except the last
498
+ // segment. With composite and no nested tail, the entity can be the
499
+ // ENTIRE leading run -- composite fields are listed separately.
500
+ let maxEntityLen;
501
+ if (hasNonFieldTail) {
502
+ maxEntityLen = leadingFields.length - 1;
503
+ }
504
+ else if (hasComposite) {
505
+ maxEntityLen = leadingFields.length;
506
+ }
507
+ else {
508
+ maxEntityLen = leadingFields.length - 1;
509
+ }
510
+ if (maxEntityLen < 1)
511
+ return;
512
+ // PHASE 1 (cont'd): longest-prefix entity match.
513
+ let entity;
514
+ let entityPrefixLen = 0;
515
+ for (let len = maxEntityLen; len >= 1; len -= 1) {
516
+ const candidate = leadingFields.slice(0, len).join('.');
517
+ const found = resolveEntityRef(candidate, symbols);
518
+ if (found) {
519
+ entity = found;
520
+ entityPrefixLen = len;
521
+ break;
522
+ }
523
+ }
524
+ if (!entity) {
525
+ const guess = leadingFields.slice(0, maxEntityLen).join('.');
526
+ diagnostics.push({
527
+ severity: 'error',
528
+ code: 'unresolved-entity',
529
+ message: `Foreign-key endpoint references unknown entity '${guess}'.`,
530
+ span: endpoint.span,
531
+ });
532
+ return;
533
+ }
534
+ if (entity.declaration.kind !== 'EntityDeclaration')
535
+ return;
536
+ // PHASE 2: compute the post-entity portion of endpoint.path.
537
+ const remaining = endpoint.path.slice(entityPrefixLen);
538
+ // Special case: path is JUST the entity (no remaining segments). With
539
+ // composite, validate composite fields against the entity body.
540
+ if (remaining.length === 0) {
541
+ if (hasComposite) {
542
+ const fieldNameSet = collectFieldNames(entity.declaration);
543
+ for (const fname of endpoint.compositeFields) {
544
+ if (!fieldNameSet.has(fname)) {
545
+ diagnostics.push({
546
+ severity: 'error',
547
+ code: 'unresolved-field',
548
+ message: `Field '${fname}' is not declared on entity '${entity.qualifiedName}'.`,
549
+ span: endpoint.span,
550
+ });
551
+ }
552
+ }
553
+ }
554
+ return;
555
+ }
556
+ // PHASE 3: the first remaining segment is the top-level field name.
557
+ const topSeg = remaining[0];
558
+ if (topSeg.kind !== 'PathField') {
559
+ // E.g., entity followed immediately by `[*]`. Structurally invalid
560
+ // because navigation can only begin after a field selection.
561
+ diagnostics.push({
562
+ severity: 'error',
563
+ code: 'invalid-nested-path',
564
+ message: `Foreign-key path on entity '${entity.qualifiedName}' starts with a non-field segment; expected a field name first.`,
565
+ span: topSeg.span,
566
+ });
567
+ return;
568
+ }
569
+ const topField = entity.declaration.body.find((item) => item.kind === 'FieldDeclaration' && item.name === topSeg.name);
570
+ if (!topField) {
571
+ diagnostics.push({
572
+ severity: 'error',
573
+ code: 'unresolved-field',
574
+ message: `Field '${topSeg.name}' is not declared on entity '${entity.qualifiedName}'.`,
575
+ span: topSeg.span,
576
+ });
577
+ return;
578
+ }
579
+ // PHASE 4: walk the field's type for any further segments.
580
+ let finalType = topField.type;
581
+ if (remaining.length > 1) {
582
+ const nested = remaining.slice(1);
583
+ finalType = walkTypePath(topField.type, nested, symbols, diagnostics, 0);
584
+ // walkTypePath returns undefined on error (and has already emitted
585
+ // a diagnostic). Continue to composite validation only when the walk
586
+ // succeeded.
587
+ if (!finalType)
588
+ return;
589
+ }
590
+ // Composite endpoint: validate composite fields against the final type.
591
+ if (hasComposite) {
592
+ const compositeFieldNames = extractFieldNamesFromType(finalType, symbols);
593
+ if (!compositeFieldNames) {
594
+ // The final type isn't an object-shaped type, so composite-field
595
+ // validation can't apply. Emit a structural diagnostic.
596
+ diagnostics.push({
597
+ severity: 'error',
598
+ code: 'invalid-nested-path',
599
+ message: `Cannot apply composite fields (${endpoint.compositeFields.join(', ')}) at this point in the path; the navigated type is not an object.`,
600
+ span: endpoint.span,
601
+ });
602
+ return;
603
+ }
604
+ for (const fname of endpoint.compositeFields) {
605
+ if (!compositeFieldNames.has(fname)) {
606
+ diagnostics.push({
607
+ severity: 'error',
608
+ code: 'unresolved-field',
609
+ message: `Field '${fname}' is not declared at the FK endpoint's nested object type.`,
610
+ span: endpoint.span,
611
+ });
612
+ }
613
+ }
614
+ }
615
+ }
616
+ function collectFieldNames(entity) {
617
+ const names = new Set();
618
+ for (const item of entity.body) {
619
+ if (item.kind === 'FieldDeclaration')
620
+ names.add(item.name);
621
+ }
622
+ return names;
623
+ }
624
+ /* -------------------------------------------------------------------------
625
+ * Type-path navigation (nested-field FK validation)
626
+ *
627
+ * Given a starting `TypeExpression` and a sequence of `PathSegment`s,
628
+ * walk the type structure consuming one segment at a time. Each segment
629
+ * shapes how we advance:
630
+ *
631
+ * PathField -> requires an ObjectType; selects the named field
632
+ * PathArrayWildcard [*] -> requires Array/Set; advances to the element type
633
+ * PathArrayIndex [N] -> Array (-> element), Tuple (-> position N's type)
634
+ * PathMapKey [k] -> requires MapType; advances to the value type
635
+ *
636
+ * Named Types are dereferenced on entry (a `ScalarType` whose name isn't
637
+ * a built-in or a `NamedTypeReference` -> look up in the symbol table;
638
+ * if a scalar Named Type, deref to its base; if an object Named Type,
639
+ * synthesize an ObjectType wrapping its body). A depth limit breaks
640
+ * pathological cycles (`Type A B; Type B A`).
641
+ *
642
+ * On structural failure (wrong shape for the segment) or unresolved
643
+ * names, the walker emits a diagnostic and returns undefined. On success
644
+ * it returns the type after consuming all segments.
645
+ * ----------------------------------------------------------------------- */
646
+ const MAX_TYPE_WALK_DEPTH = 16;
647
+ function walkTypePath(startType, segments, symbols, diagnostics, depth) {
648
+ let current = startType;
649
+ for (const seg of segments) {
650
+ const next = stepIntoType(current, seg, symbols, diagnostics, depth);
651
+ if (!next)
652
+ return undefined;
653
+ current = next;
654
+ }
655
+ return current;
656
+ }
657
+ function stepIntoType(current, seg, symbols, diagnostics, depth) {
658
+ // Dereference Named Types up front so the segment-kind switch below
659
+ // operates on the structural form.
660
+ const resolved = dereferenceNamedType(current, symbols, depth);
661
+ if (!resolved)
662
+ return undefined; // depth limit hit (or unresolved Named Type)
663
+ current = resolved;
664
+ switch (seg.kind) {
665
+ case 'PathField': {
666
+ // Field access is only meaningful on ObjectType. Other shapes
667
+ // (array/map/set/tuple) require an explicit element-access segment
668
+ // first ([*], [N], [key]).
669
+ if (current.kind !== 'ObjectType') {
670
+ diagnostics.push({
671
+ severity: 'error',
672
+ code: 'invalid-nested-path',
673
+ message: `Cannot navigate field '${seg.name}' through ${current.kind}; use [*] (array/set), [N] (tuple), or [key] (map) before naming a field.`,
674
+ span: seg.span,
675
+ });
676
+ return undefined;
677
+ }
678
+ const field = current.fields.find((f) => f.kind === 'FieldDeclaration' && f.name === seg.name);
679
+ if (!field) {
680
+ diagnostics.push({
681
+ severity: 'error',
682
+ code: 'unresolved-field',
683
+ message: `Field '${seg.name}' is not declared on the nested object type.`,
684
+ span: seg.span,
685
+ });
686
+ return undefined;
687
+ }
688
+ return field.type;
689
+ }
690
+ case 'PathArrayWildcard': {
691
+ if (current.kind === 'ArrayType') {
692
+ if (!current.elementType) {
693
+ // `array [name type]` form -- the element type lives elsewhere
694
+ // (elementName + elementSettings). For walker purposes, treat
695
+ // the array as having an opaque element and stop walking
696
+ // further by returning undefined silently (no diagnostic; this
697
+ // is a v0.2 shape the spec calls out as alias-only).
698
+ diagnostics.push({
699
+ severity: 'error',
700
+ code: 'invalid-nested-path',
701
+ message: `Array uses the alias 'name type' form which does not expose a navigable element type for further path traversal.`,
702
+ span: seg.span,
703
+ });
704
+ return undefined;
705
+ }
706
+ return current.elementType;
707
+ }
708
+ if (current.kind === 'SetType') {
709
+ return current.elementType;
710
+ }
711
+ diagnostics.push({
712
+ severity: 'error',
713
+ code: 'invalid-nested-path',
714
+ message: `Array wildcard [*] is only valid on array or set types, not ${current.kind}.`,
715
+ span: seg.span,
716
+ });
717
+ return undefined;
718
+ }
719
+ case 'PathArrayIndex': {
720
+ if (current.kind === 'ArrayType') {
721
+ return current.elementType;
722
+ }
723
+ if (current.kind === 'TupleType') {
724
+ const elem = current.elements.find((e) => e.position === seg.index);
725
+ if (!elem) {
726
+ diagnostics.push({
727
+ severity: 'error',
728
+ code: 'invalid-nested-path',
729
+ message: `Tuple has no element at position [${seg.index}].`,
730
+ span: seg.span,
731
+ });
732
+ return undefined;
733
+ }
734
+ return elem.type;
735
+ }
736
+ diagnostics.push({
737
+ severity: 'error',
738
+ code: 'invalid-nested-path',
739
+ message: `Numeric index [${seg.index}] is only valid on array or tuple types, not ${current.kind}.`,
740
+ span: seg.span,
741
+ });
742
+ return undefined;
743
+ }
744
+ case 'PathMapKey': {
745
+ if (current.kind === 'MapType') {
746
+ return current.valueType;
747
+ }
748
+ diagnostics.push({
749
+ severity: 'error',
750
+ code: 'invalid-nested-path',
751
+ message: `Map key [${seg.key}] is only valid on map types, not ${current.kind}.`,
752
+ span: seg.span,
753
+ });
754
+ return undefined;
755
+ }
756
+ default:
757
+ return undefined;
758
+ }
759
+ }
760
+ /**
761
+ * Resolve a TypeExpression to its structural form by chasing through
762
+ * Named Type references. Returns the resolved type, or undefined when
763
+ * the depth limit is hit (cycle) or the Named Type doesn't exist (in
764
+ * which case the field-type resolver has already emitted a diagnostic
765
+ * elsewhere -- we silently fail here to avoid duplicate errors).
766
+ *
767
+ * For object-form Named Types, synthesizes an ObjectType wrapping the
768
+ * Type's body so callers can navigate via PathField uniformly.
769
+ */
770
+ function dereferenceNamedType(type, symbols, depth) {
771
+ if (depth > MAX_TYPE_WALK_DEPTH)
772
+ return undefined;
773
+ // ScalarType might be a Named Type reference (parser ambiguity).
774
+ // NamedTypeReference always is.
775
+ let name;
776
+ if (type.kind === 'ScalarType' && !isBuiltinType(type.name)) {
777
+ name = type.name;
778
+ }
779
+ else if (type.kind === 'NamedTypeReference') {
780
+ name = type.name;
781
+ }
782
+ else {
783
+ // Already a structural form.
784
+ return type;
785
+ }
786
+ const sym = symbols.lookup(name) ?? symbols.lookupBare(name);
787
+ if (!sym || sym.kind !== 'type' || sym.declaration.kind !== 'TypeDeclaration') {
788
+ // Unresolved -- the field-type pass will diagnose this. Return
789
+ // undefined so the walker bails without emitting a duplicate error.
790
+ return undefined;
791
+ }
792
+ const td = sym.declaration;
793
+ if (td.scalarBase) {
794
+ // Scalar Named Type -- recurse through to the base.
795
+ return dereferenceNamedType(td.scalarBase, symbols, depth + 1);
796
+ }
797
+ // Object-form Named Type. Synthesize an ObjectType so the walker can
798
+ // navigate its fields uniformly. The span points at the Type
799
+ // declaration; segment-level positions remain correct because we don't
800
+ // use the synthetic ObjectType's span for diagnostics.
801
+ const synthesized = {
802
+ kind: 'ObjectType',
803
+ keyword: 'object',
804
+ fields: td.body,
805
+ span: td.span,
806
+ };
807
+ return synthesized;
808
+ }
809
+ /**
810
+ * Get the set of declared field names on a type, dereferencing Named
811
+ * Types as needed. Returns undefined when the type isn't an object-shaped
812
+ * type at all (composite-field validation can't apply).
813
+ */
814
+ function extractFieldNamesFromType(type, symbols) {
815
+ const resolved = dereferenceNamedType(type, symbols, 0);
816
+ if (!resolved || resolved.kind !== 'ObjectType')
817
+ return undefined;
818
+ const names = new Set();
819
+ for (const f of resolved.fields) {
820
+ if (f.kind === 'FieldDeclaration')
821
+ names.add(f.name);
822
+ }
823
+ return names;
824
+ }
825
+ function resolveTableGroupMember(member, span, symbols, diagnostics) {
826
+ const found = resolveEntityRef(member, symbols);
827
+ if (!found) {
828
+ diagnostics.push({
829
+ severity: 'error',
830
+ code: 'unresolved-tablegroup-member',
831
+ message: `TableGroup member '${member}' does not resolve to an entity.`,
832
+ span,
833
+ });
834
+ }
835
+ }
836
+ /**
837
+ * Resolve an entity reference by name. Accepts bare (`dim_customer`) or
838
+ * qualified (`core.dim_customer`) form. Bare references are resolved
839
+ * through the SymbolTable's bare-name lookup; ambiguous bare references
840
+ * (matching multiple containers) return undefined.
841
+ */
842
+ function resolveEntityRef(ref, symbols) {
843
+ // Try qualified first.
844
+ const qualified = symbols.lookup(ref);
845
+ if (qualified && qualified.kind === 'entity')
846
+ return qualified;
847
+ // Try bare.
848
+ if (!ref.includes('.')) {
849
+ const bare = symbols.lookupBare(ref);
850
+ if (bare && bare.kind === 'entity')
851
+ return bare;
852
+ }
853
+ return undefined;
854
+ }