@metaobjectsdev/metadata 1.0.2 → 1.0.4-rc.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.
Files changed (93) hide show
  1. package/dist/core/parser-yaml.d.ts.map +1 -1
  2. package/dist/core/parser-yaml.js +4 -0
  3. package/dist/core/parser-yaml.js.map +1 -1
  4. package/dist/core/relationship/derive-m2m-fields.d.ts +3 -1
  5. package/dist/core/relationship/derive-m2m-fields.d.ts.map +1 -1
  6. package/dist/core/relationship/derive-m2m-fields.js +110 -15
  7. package/dist/core/relationship/derive-m2m-fields.js.map +1 -1
  8. package/dist/core/relationship/find-reference.d.ts +21 -5
  9. package/dist/core/relationship/find-reference.d.ts.map +1 -1
  10. package/dist/core/relationship/find-reference.js +29 -10
  11. package/dist/core/relationship/find-reference.js.map +1 -1
  12. package/dist/core/relationship/relationship-definition.embedded.js +8 -8
  13. package/dist/core/relationship/relationship-definition.embedded.js.map +1 -1
  14. package/dist/core/relationship/resolve-relationship-reference.d.ts +27 -0
  15. package/dist/core/relationship/resolve-relationship-reference.d.ts.map +1 -0
  16. package/dist/core/relationship/resolve-relationship-reference.js +84 -0
  17. package/dist/core/relationship/resolve-relationship-reference.js.map +1 -0
  18. package/dist/errors.d.ts +1 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +31 -0
  21. package/dist/errors.js.map +1 -1
  22. package/dist/index.d.ts +8 -5
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -4
  25. package/dist/index.js.map +1 -1
  26. package/dist/json-path.d.ts +14 -0
  27. package/dist/json-path.d.ts.map +1 -1
  28. package/dist/json-path.js +13 -0
  29. package/dist/json-path.js.map +1 -1
  30. package/dist/library/embedded-library.generated.d.ts +2 -0
  31. package/dist/library/embedded-library.generated.d.ts.map +1 -1
  32. package/dist/library/embedded-library.generated.js +11 -1
  33. package/dist/library/embedded-library.generated.js.map +1 -1
  34. package/dist/library/index.d.ts +2 -0
  35. package/dist/library/index.d.ts.map +1 -0
  36. package/dist/library/index.js +24 -0
  37. package/dist/library/index.js.map +1 -0
  38. package/dist/library/library-sources.d.ts +99 -6
  39. package/dist/library/library-sources.d.ts.map +1 -1
  40. package/dist/library/library-sources.js +165 -43
  41. package/dist/library/library-sources.js.map +1 -1
  42. package/dist/loader/meta-data-loader.d.ts +44 -21
  43. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  44. package/dist/loader/meta-data-loader.js +118 -88
  45. package/dist/loader/meta-data-loader.js.map +1 -1
  46. package/dist/loader/sources/file-source.d.ts +7 -1
  47. package/dist/loader/sources/file-source.d.ts.map +1 -1
  48. package/dist/loader/sources/file-source.js +3 -2
  49. package/dist/loader/sources/file-source.js.map +1 -1
  50. package/dist/loader/validation-passes.d.ts +2 -0
  51. package/dist/loader/validation-passes.d.ts.map +1 -1
  52. package/dist/loader/validation-passes.js +162 -22
  53. package/dist/loader/validation-passes.js.map +1 -1
  54. package/dist/naming.d.ts +7 -0
  55. package/dist/naming.d.ts.map +1 -1
  56. package/dist/naming.js +10 -0
  57. package/dist/naming.js.map +1 -1
  58. package/dist/parser-core.d.ts +103 -0
  59. package/dist/parser-core.d.ts.map +1 -1
  60. package/dist/parser-core.js +202 -18
  61. package/dist/parser-core.js.map +1 -1
  62. package/dist/scope.d.ts +16 -0
  63. package/dist/scope.d.ts.map +1 -0
  64. package/dist/scope.js +78 -0
  65. package/dist/scope.js.map +1 -0
  66. package/dist/serializer-json.d.ts +1 -0
  67. package/dist/serializer-json.d.ts.map +1 -1
  68. package/dist/serializer-json.js +43 -1
  69. package/dist/serializer-json.js.map +1 -1
  70. package/dist/vocabulary-rewrite-yaml.d.ts +2 -0
  71. package/dist/vocabulary-rewrite-yaml.d.ts.map +1 -0
  72. package/dist/vocabulary-rewrite-yaml.js +22 -0
  73. package/dist/vocabulary-rewrite-yaml.js.map +1 -0
  74. package/package.json +7 -7
  75. package/src/core/parser-yaml.ts +4 -0
  76. package/src/core/relationship/derive-m2m-fields.ts +115 -15
  77. package/src/core/relationship/find-reference.ts +34 -11
  78. package/src/core/relationship/relationship-definition.embedded.ts +8 -8
  79. package/src/core/relationship/resolve-relationship-reference.ts +99 -0
  80. package/src/errors.ts +31 -0
  81. package/src/index.ts +18 -5
  82. package/src/json-path.ts +16 -1
  83. package/src/library/embedded-library.generated.ts +12 -1
  84. package/src/library/index.ts +23 -0
  85. package/src/library/library-sources.ts +197 -47
  86. package/src/loader/meta-data-loader.ts +154 -102
  87. package/src/loader/sources/file-source.ts +10 -2
  88. package/src/loader/validation-passes.ts +181 -23
  89. package/src/naming.ts +11 -0
  90. package/src/parser-core.ts +319 -22
  91. package/src/scope.ts +97 -0
  92. package/src/serializer-json.ts +46 -1
  93. package/src/vocabulary-rewrite-yaml.ts +21 -0
@@ -13,12 +13,19 @@ import { TypeId, TypeRegistry } from "../registry.js";
13
13
  import { coreProviders } from "../core-types.js";
14
14
  import { composeRegistry } from "../provider.js";
15
15
  import { TYPE_METADATA, SUBTYPE_ROOT } from "../shared/base-types.js";
16
- import { RESERVED_KEY_CHILDREN, RESERVED_KEY_OVERLAY } from "../shared/structural.js";
16
+ import {
17
+ PACKAGE_SEPARATOR,
18
+ RESERVED_KEY_CHILDREN,
19
+ RESERVED_KEY_NAME,
20
+ RESERVED_KEY_OVERLAY,
21
+ RESERVED_KEY_PACKAGE,
22
+ TYPE_SUBTYPE_SEPARATOR,
23
+ } from "../shared/structural.js";
17
24
  import { ParseError } from "../errors.js";
18
25
  import type { LoaderWarning } from "../source.js";
19
26
  import { codeSource, resolvedSource } from "../source.js";
20
27
  import { parseJson } from "../parser-json.js";
21
- import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
28
+ import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
22
29
  import { runRegisteredValidation } from "./validation-registry.js";
23
30
  import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
24
31
  import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
@@ -34,7 +41,8 @@ import { validateIdentityPassthrough } from "../core/identity/validate-identity-
34
41
  import { validateAttrSchema } from "../attr-schema-validate.js";
35
42
  import type { MetaDataFormat, MetaDataSource } from "./meta-data-source.js";
36
43
  import { InMemoryStringSource } from "./meta-data-source.js";
37
- import type { ParseOptions, ParseResult } from "../parser-core.js";
44
+ import type { ParseOptions, ParseResult, PendingOverlay } from "../parser-core.js";
45
+ import { expandPackageForPath, applyPendingOverlays } from "../parser-core.js";
38
46
 
39
47
  // Local mirror of DirectorySource's options shape. Deliberately inlined here
40
48
  // (instead of `import type`'d from ./sources/directory-source.js) so the
@@ -97,6 +105,113 @@ function makeSyntheticRoot(): MetaRoot {
97
105
  return new MetaRoot(new TypeId(TYPE_METADATA, SUBTYPE_ROOT), "");
98
106
  }
99
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // declaredTopLevelKeys — structural pre-parse walk (#160, generalized)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** One root-level declaration as `declaredTopLevelKeys` structurally scans it —
113
+ * before registry-driven parsing, super resolution, or merge. */
114
+ export interface DeclaredTopLevelKey {
115
+ /** The wrapper key's TYPE segment (e.g. "object" for "object.entity"). A bare
116
+ * wrapper key with no "." is its own type — this walk never consults the
117
+ * registry for a default subType, because it never needs the subType. */
118
+ type: string;
119
+ /** The resolution key the declaration would carry once parsed: the child's
120
+ * own `package` if set (expanded against the root's `package` via
121
+ * `expandPackageForPath` when it's a relative, `::`-prefixed path — e.g.
122
+ * root `acme` + own `::garage` → `acme::garage`), else the root's
123
+ * `package` verbatim, then `::`, then its `name`. Exactly
124
+ * `rootChildResolutionKey` (parser-core.ts) — including the relative-path
125
+ * expansion, reusing `expandPackageForPath` rather than reimplementing it,
126
+ * because a second copy is exactly how this walk and the real parser
127
+ * silently disagreed on a relative package (task 17 fix-round-1: this
128
+ * function used to return `::garage::Garage` for a fixture the real
129
+ * loader resolves to `acme::garage::Garage`). */
130
+ key: string;
131
+ /** Whether the declaration's body carries `overlay: true`. */
132
+ overlay: boolean;
133
+ }
134
+
135
+ /**
136
+ * Structurally scan a source's raw content (JSON via `JSON.parse`; sigil-free
137
+ * authoring YAML via the raw YAML walker — `overlay: true` is a bare key
138
+ * before desugar) and report every top-level declaration under
139
+ * `metadata.root.children`: its type, the resolution key it would carry once
140
+ * parsed, and whether it carries `overlay: true`.
141
+ *
142
+ * A declaration with no (string, non-empty) `name` is skipped — a resolution
143
+ * key can't be computed for it, and the real parser will report it as a
144
+ * loader error rather than silently reusing or creating a node. Malformed
145
+ * root shapes (root missing, not an object, `children` absent or not an
146
+ * array) return `[]` rather than throwing — the real parse loop is where a
147
+ * genuine structural error surfaces; this walk must never crash a caller
148
+ * that is only trying to answer "what does this file declare".
149
+ *
150
+ * Written for the #160 overlay-only source partition, which ADR-0055 retired —
151
+ * overlays are now applied in a post-parse pass, so no ordering decision needs
152
+ * this walk. Its remaining caller is the `meta verify` overlay authoring lint,
153
+ * which needs per-file declaration provenance precisely because the MERGED tree
154
+ * has already lost which file contributed which declaration.
155
+ */
156
+ export async function declaredTopLevelKeys(
157
+ content: string,
158
+ format: MetaDataFormat,
159
+ ): Promise<ReadonlyArray<DeclaredTopLevelKey>> {
160
+ // Strip UTF-8 BOM if present (mirrors parseJson / parseYaml).
161
+ const normalized = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
162
+ let parsed: unknown;
163
+ if (format === "json") {
164
+ parsed = JSON.parse(normalized);
165
+ } else if (format === "yaml") {
166
+ const { parseYamlWithPositions } = await import("../core/yaml-positions-walker.js");
167
+ parsed = parseYamlWithPositions(normalized).value;
168
+ } else {
169
+ return [];
170
+ }
171
+ return declaredTopLevelKeysFromParsedRoot(parsed);
172
+ }
173
+
174
+ /** The structural walk `declaredTopLevelKeys` performs once content is
175
+ * structurally parsed — split out so it takes no format/BOM concerns. */
176
+ function declaredTopLevelKeysFromParsedRoot(parsed: unknown): ReadonlyArray<DeclaredTopLevelKey> {
177
+ if (typeof parsed !== "object" || parsed === null) return [];
178
+ const parsedRecord = parsed as Record<string, unknown>;
179
+ // Canonical JSON always fuses the subType onto the root wrapper key
180
+ // ("metadata.root"); sigil-free YAML authoring writes the bare type
181
+ // ("metadata") and leaves the default subType to the registry — this
182
+ // structural walk runs BEFORE desugar/registry resolution, so it must
183
+ // accept both spellings rather than only ever matching JSON's.
184
+ const explicitRootKey = `${TYPE_METADATA}${TYPE_SUBTYPE_SEPARATOR}${SUBTYPE_ROOT}`; // "metadata.root"
185
+ const rootBody = parsedRecord[explicitRootKey] ?? parsedRecord[TYPE_METADATA];
186
+ if (typeof rootBody !== "object" || rootBody === null) return [];
187
+ const rawRootPkg = (rootBody as Record<string, unknown>)[RESERVED_KEY_PACKAGE];
188
+ const rootPkg = typeof rawRootPkg === "string" ? rawRootPkg : "";
189
+ const children = (rootBody as Record<string, unknown>)[RESERVED_KEY_CHILDREN];
190
+ if (!Array.isArray(children)) return [];
191
+
192
+ const declared: DeclaredTopLevelKey[] = [];
193
+ for (const child of children) {
194
+ if (typeof child !== "object" || child === null) continue;
195
+ // Each child is a single-key wrapper: { "object.entity": { ... } }.
196
+ for (const [wrapperKey, body] of Object.entries(child as Record<string, unknown>)) {
197
+ if (typeof body !== "object" || body === null) continue;
198
+ const bodyRecord = body as Record<string, unknown>;
199
+ const name = bodyRecord[RESERVED_KEY_NAME];
200
+ if (typeof name !== "string" || name === "") continue;
201
+ const dotIdx = wrapperKey.indexOf(TYPE_SUBTYPE_SEPARATOR);
202
+ const type = dotIdx < 0 ? wrapperKey : wrapperKey.slice(0, dotIdx);
203
+ const rawOwnPkg = bodyRecord[RESERVED_KEY_PACKAGE];
204
+ const pkg =
205
+ typeof rawOwnPkg === "string" && rawOwnPkg !== ""
206
+ ? expandPackageForPath(rootPkg, rawOwnPkg)
207
+ : rootPkg;
208
+ const key = pkg !== "" ? `${pkg}${PACKAGE_SEPARATOR}${name}` : name;
209
+ declared.push({ type, key, overlay: bodyRecord[RESERVED_KEY_OVERLAY] === true });
210
+ }
211
+ }
212
+ return declared;
213
+ }
214
+
100
215
  // ---------------------------------------------------------------------------
101
216
  // MetaDataLoader class
102
217
  // ---------------------------------------------------------------------------
@@ -330,92 +445,6 @@ export class MetaDataLoader {
330
445
  MetaDataLoader._yamlParser = mod.parseYaml;
331
446
  }
332
447
 
333
- // ---------------------------------------------------------------------------
334
- // #160 — overlay-only source partition (stable, overlay-only sources last)
335
- // ---------------------------------------------------------------------------
336
-
337
- /**
338
- * Stable-partition `sources` so that "overlay-only" sources — every top-level
339
- * object declaration carries `overlay: true`, i.e. the source declares no base
340
- * objects of its own and only re-opens objects declared elsewhere — are parsed
341
- * LAST. Preserves original order within each group.
342
- *
343
- * A source whose content can't be read or structurally scanned stays in the
344
- * base group (never overlay-only) — this partition must never crash the loader;
345
- * any genuine read/parse failure surfaces later, in the real parse loop.
346
- */
347
- private static async _partitionOverlayLast(
348
- sources: MetaDataSource[],
349
- ): Promise<MetaDataSource[]> {
350
- const base: MetaDataSource[] = [];
351
- const overlayOnly: MetaDataSource[] = [];
352
- for (const source of sources) {
353
- let isOverlayOnly = false;
354
- try {
355
- const content = await source.read();
356
- isOverlayOnly = await MetaDataLoader._isOverlayOnlySource(
357
- content,
358
- source.format,
359
- );
360
- } catch {
361
- isOverlayOnly = false;
362
- }
363
- (isOverlayOnly ? overlayOnly : base).push(source);
364
- }
365
- return [...base, ...overlayOnly];
366
- }
367
-
368
- /**
369
- * Structurally scan a source's raw content (JSON via JSON.parse; sigil-free
370
- * authoring YAML via the raw YAML walker — `overlay: true` is a bare key
371
- * before desugar) and report whether every top-level object declaration under
372
- * `metadata.root.children` carries `overlay: true` (and there is at least one).
373
- */
374
- private static async _isOverlayOnlySource(
375
- content: string,
376
- format: MetaDataFormat,
377
- ): Promise<boolean> {
378
- // Strip UTF-8 BOM if present (mirrors parseJson / parseYaml).
379
- const normalized = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
380
- let parsed: unknown;
381
- if (format === "json") {
382
- parsed = JSON.parse(normalized);
383
- } else if (format === "yaml") {
384
- const { parseYamlWithPositions } = await import(
385
- "../core/yaml-positions-walker.js"
386
- );
387
- parsed = parseYamlWithPositions(normalized).value;
388
- } else {
389
- return false;
390
- }
391
- return MetaDataLoader._rootIsOverlayOnly(parsed);
392
- }
393
-
394
- /** True when the structurally-parsed root has ≥1 child and every top-level
395
- * child node carries `overlay: true` (declares no base objects). */
396
- private static _rootIsOverlayOnly(parsed: unknown): boolean {
397
- if (typeof parsed !== "object" || parsed === null) return false;
398
- const rootKey = `${TYPE_METADATA}.${SUBTYPE_ROOT}`; // "metadata.root"
399
- const rootBody = (parsed as Record<string, unknown>)[rootKey];
400
- if (typeof rootBody !== "object" || rootBody === null) return false;
401
- const children = (rootBody as Record<string, unknown>)[RESERVED_KEY_CHILDREN];
402
- if (!Array.isArray(children) || children.length === 0) return false;
403
- return children.every((child) => {
404
- if (typeof child !== "object" || child === null) return false;
405
- // Each child is a single-key wrapper: { "object.projection": { ... } }.
406
- const bodies = Object.values(child as Record<string, unknown>);
407
- return (
408
- bodies.length > 0 &&
409
- bodies.every(
410
- (body) =>
411
- typeof body === "object" &&
412
- body !== null &&
413
- (body as Record<string, unknown>)[RESERVED_KEY_OVERLAY] === true,
414
- )
415
- );
416
- });
417
- }
418
-
419
448
  // ---------------------------------------------------------------------------
420
449
  // load — async pipeline over MetaDataSource[]
421
450
  // ---------------------------------------------------------------------------
@@ -457,20 +486,17 @@ export class MetaDataLoader {
457
486
  await MetaDataLoader._ensureYamlParser();
458
487
  }
459
488
 
460
- // #160this loader merges DURING parse (each source is streamed into the
461
- // accumulating `root` via parseOpts.intoRoot). A source that ONLY re-opens
462
- // objects declared elsewhere (every top-level object carries `overlay: true`)
463
- // must therefore be parsed AFTER the sources that declare those base objects,
464
- // or the overlaid node lands ahead of its base entities — leaving a projection
465
- // before its base so order-dependent super-resolution can't resolve its
466
- // `extends`/`@via`, and the streaming merge errors ERR_OVERLAY_NO_TARGET.
467
- // Directory discovery order is not guaranteed to present base files first
468
- // (basename sort can put an overlay-only file first), so stable-partition
469
- // overlay-only sources to the END here, making the merge order-independent.
470
- // Stable within each group preserves last-writer-wins overlay semantics.
471
- sources = await MetaDataLoader._partitionOverlayLast(sources);
472
-
489
+ // ADR-0055sources are parsed in the order given. Overlays are not applied
490
+ // during the walk: each parse QUEUES its `overlay: true` declarations and the
491
+ // loader applies them below, once every base exists. That retired the #160
492
+ // overlay-only source partition, whose file-level predicate could not rescue a
493
+ // MIXED file (plain + overlay declarations together) and therefore left the
494
+ // load order-dependent and divergent across ports, since every port's
495
+ // directory walk orders files differently.
473
496
  let root: MetaRoot | undefined;
497
+ // ADR-0055 — every source's queue, concatenated in parse order, which is
498
+ // already "source order, then declaration order within a source" (G2).
499
+ const pendingOverlays: PendingOverlay[] = [];
474
500
 
475
501
  // Parse all sources with super resolution DEFERRED so cross-file super
476
502
  // refs work — one source may declare a super target that's defined in a
@@ -495,6 +521,10 @@ export class MetaDataLoader {
495
521
  registry: this._registry,
496
522
  strict: this._strict,
497
523
  deferSuperResolution: true,
524
+ // ADR-0055 — queue overlays; this loader drains them after every source
525
+ // is parsed. Without it each document would drain its own queue and an
526
+ // overlay could still not reach a base declared in a later source.
527
+ deferOverlays: true,
498
528
  sourceName: source.id,
499
529
  };
500
530
  if (root !== undefined) parseOpts.intoRoot = root;
@@ -507,6 +537,7 @@ export class MetaDataLoader {
507
537
  // source). The legacy `warnings` channel still flows into the
508
538
  // WARN_LEGACY-wrapping path below for unchanged behavior.
509
539
  envelopeWarnings.push(...parseResult.envelopeWarnings);
540
+ pendingOverlays.push(...parseResult.pendingOverlays);
510
541
  root = parseResult.root;
511
542
  } catch (err) {
512
543
  errors.push(
@@ -517,6 +548,21 @@ export class MetaDataLoader {
517
548
  }
518
549
  }
519
550
 
551
+ // ADR-0055 — apply every queued overlay now: all plain declarations from all
552
+ // sources are in the tree (G1), so a base/overlay relation no longer depends
553
+ // on file order (G4). This runs BEFORE super resolution so a node an overlay
554
+ // contributes is visible to every `extends` (G5) — the ordering #160's
555
+ // partition was reaching for, now guaranteed rather than approximated.
556
+ if (root !== undefined && pendingOverlays.length > 0) {
557
+ const applied = applyPendingOverlays(pendingOverlays, {
558
+ registry: this._registry,
559
+ strict: this._strict,
560
+ });
561
+ warnings.push(...applied.warnings);
562
+ errors.push(...applied.errors);
563
+ envelopeWarnings.push(...applied.envelopeWarnings);
564
+ }
565
+
520
566
  // Second pass: resolve every deferred super ref against the full tree.
521
567
  // Unresolved refs are always errors (matches the original eager-throw
522
568
  // behavior — broken metadata is broken regardless of strict mode).
@@ -606,6 +652,12 @@ export class MetaDataLoader {
606
652
  // are invalid on a 1:N relationship.
607
653
  errors.push(...validateRelationships(root));
608
654
 
655
+ // Rule (e) — #368: a `@cardinality: one` relationship must resolve to
656
+ // exactly one identity.reference when its declaring entity holds more
657
+ // than one onto the same target; an unresolvable case is a load error
658
+ // naming the candidates (ADR-0029 §5).
659
+ errors.push(...validateOneSideReferenceResolution(root));
660
+
609
661
  // index.lookup @fields resolution — each index.lookup must name ≥1 field,
610
662
  // and every field must exist in the entity's effective (resolved) field set
611
663
  // (ADR-0039: resolving accessor, so inherited fields via extends are visible).
@@ -27,16 +27,24 @@ async function getReadText(): Promise<(path: string) => Promise<string>> {
27
27
  return _readText;
28
28
  }
29
29
 
30
+ /** Options for {@link FileSource}. */
31
+ export interface FileSourceOptions {
32
+ /** Explicit source id (e.g. `dep:<name>/<artifact>` for a dependency snapshot).
33
+ * Defaults to `basename(path)` when omitted. */
34
+ id?: string;
35
+ }
36
+
30
37
  /** A metadata source backed by a file on disk. */
31
38
  export class FileSource implements MetaDataSource {
32
39
  readonly id: string;
33
40
  readonly format: MetaDataFormat;
34
41
  private readonly _path: string;
35
42
 
36
- constructor(path: string) {
43
+ constructor(path: string, opts?: FileSourceOptions) {
37
44
  this._path = path;
38
45
  // basename() for readable error messages; cross-platform (handles both / and \). Full path retained for read().
39
- this.id = basename(path);
46
+ // An explicit id (FR-023: dependency snapshots load with `dep:<name>/<artifact>`) overrides the default.
47
+ this.id = opts?.id ?? basename(path);
40
48
  this.format = inferFormat(path);
41
49
  }
42
50
 
@@ -129,6 +129,11 @@ import {
129
129
  CARDINALITY_ONE,
130
130
  CARDINALITY_MANY,
131
131
  } from "../core/relationship/relationship-constants.js";
132
+ import {
133
+ referenceCandidatesFor,
134
+ resolveRelationshipReference,
135
+ } from "../core/relationship/resolve-relationship-reference.js";
136
+ import type { MetaRoot } from "../shared/meta-root.js";
132
137
  import { stripPackage } from "../naming.js";
133
138
  import {
134
139
  FILTER_COMPOSE_OR,
@@ -1938,11 +1943,29 @@ export function validateDataGridFilterValues(root: MetaData): ParseError[] {
1938
1943
  // (c) When @through is present: the named entity must exist and declare exactly
1939
1944
  // two identity.reference children; @sourceRefField (if present) must match
1940
1945
  // one of those references' FK fields → ERR_INVALID_RELATIONSHIP.
1941
- // (d) @through / @sourceRefField / @symmetric are invalid on a non-M:N
1942
- // relationship (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
1946
+ // (d) @through / @symmetric are invalid on a non-M:N relationship
1947
+ // (@cardinality != "many", or no @through) → ERR_INVALID_RELATIONSHIP.
1948
+ // @sourceRefField is also invalid there, EXCEPT on @cardinality: "one"
1949
+ // (#368: it then names which of several identity.reference nodes onto
1950
+ // the same target the relationship navigates).
1943
1951
  //
1944
- // Own-relationships only: a relationship is validated on the entity that declares
1945
- // it (matching the own-attrs policy of the other passes).
1952
+ // ADR-0039: resolving, not own-only (#368 fix round 3) every rule above
1953
+ // validates a property of the relationship's OWN declaration (its attrs,
1954
+ // plus for rule (c) the @through target resolved against the root), so a
1955
+ // relationship inherited via extends must be validated wherever it's
1956
+ // visible, or a child entity that only SEES the relationship through
1957
+ // inheritance could carry a violation no pass ever examines. That makes an
1958
+ // inherited, UNMODIFIED relationship visited once per inheriting entity —
1959
+ // checked once below (keyed on the relationship node's own identity), not
1960
+ // once per entity, using the DECLARING entity (rel.parent) rather than
1961
+ // whichever entity's effective view got there first for every piece of
1962
+ // context a rule reads (message text, @through's package per ADR-0042, rule
1963
+ // (a)'s self-join comparison). That keeps each check's result independent of
1964
+ // which entity triggered it, which is what makes "checked once" both
1965
+ // sufficient and correct. An override replaces the node in place
1966
+ // (MetaData._effectiveChildren), so it is never the same object as what it
1967
+ // overrides and is never skipped against it — a genuinely different
1968
+ // declaration is always independently checked.
1946
1969
  // ---------------------------------------------------------------------------
1947
1970
 
1948
1971
  // The junction's reference view: the validator and the runtime/codegen FK
@@ -1970,14 +1993,36 @@ function _countJunctionReferences(junction: MetaData): number {
1970
1993
 
1971
1994
  export function validateRelationships(root: MetaData): ParseError[] {
1972
1995
  const errors: ParseError[] = [];
1996
+ // #368 fix round 3 — the outer loop below is now resolving (obj.relationships()),
1997
+ // so a relationship inherited unmodified by N entities is reached N times. Its
1998
+ // own attrs never change based on who inherits it, so re-validating it more
1999
+ // than once would report the identical finding N times — pure noise. Checked
2000
+ // is keyed on the relationship NODE's own object identity: MetaData._effectiveChildren
2001
+ // reuses the super's child object in place for an unmodified inherited child,
2002
+ // so the same physical declaration IS the same object everywhere it's visible,
2003
+ // while an override replaces it with a genuinely different object (correctly
2004
+ // NOT skipped — a distinct declaration is a distinct finding).
2005
+ const checked = new Set<MetaData>();
1973
2006
  // ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
1974
2007
  for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
1975
- // ADR-0042a bare @through resolves in the declaring entity's package.
1976
- const referrerPkg = obj.package ?? obj.fileDefaultPackage ?? "";
1977
- // ADR-0039: own a relationship is validated on the entity that DECLARES it
1978
- // (the M:N slim-vocabulary rules apply to own-declared relationships; its
1979
- // inheritable attrs are read resolving below).
1980
- for (const rel of obj.ownChildren().filter((c) => c.type === TYPE_RELATIONSHIP)) {
2008
+ // ADR-0039: resolving see the header comment above: rule (d) (like rule
2009
+ // (e)) must see a relationship inherited via extends, not just this
2010
+ // entity's own declarations. `checked` absorbs the resulting revisits.
2011
+ for (const rel of (obj as MetaObject).relationships()) {
2012
+ if (checked.has(rel)) continue;
2013
+ checked.add(rel);
2014
+
2015
+ // Every rule below validates a property of the relationship's OWN
2016
+ // declaration (its attrs, plus for rule (c) the @through target), so
2017
+ // context — the entity name in messages, the package a bare @through
2018
+ // resolves in (ADR-0042), and rule (a)'s self-join comparison — is
2019
+ // always the entity that DECLARES `rel` (rel.parent), never `obj` (the
2020
+ // entity whose effective view happened to reach it first). This keeps
2021
+ // the check's result independent of iteration order/inheritance depth,
2022
+ // which is what makes checking each node exactly once correct.
2023
+ const declaringEntity = rel.parent ?? obj;
2024
+ const referrerPkg = declaringEntity.package ?? declaringEntity.fileDefaultPackage ?? "";
2025
+
1981
2026
  // ADR-0039: resolving — a relationship may inherit its M:N attrs via extends.
1982
2027
  const through = rel.attr(RELATIONSHIP_ATTR_THROUGH);
1983
2028
  const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
@@ -1999,16 +2044,23 @@ export function validateRelationships(root: MetaData): ParseError[] {
1999
2044
  if (hasThrough) {
2000
2045
  errors.push(
2001
2046
  new ParseError(
2002
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` +
2047
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_THROUGH} but is not a M:N ` +
2003
2048
  `relationship (requires @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}").`,
2004
2049
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2005
2050
  ),
2006
2051
  );
2007
2052
  }
2008
- if (hasSourceRefField) {
2053
+ // #368: @sourceRefField also disambiguates a `@cardinality: one`
2054
+ // relationship when the entity holds more than one identity.reference
2055
+ // onto the same target. Only the M:N *junction* reading is rejected
2056
+ // here; rule (e) — validateOneSideReferenceResolution, below in this
2057
+ // file — checks that it names a real local reference.
2058
+ if (hasSourceRefField && cardinality !== CARDINALITY_ONE) {
2009
2059
  errors.push(
2010
2060
  new ParseError(
2011
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is not a M:N relationship.`,
2061
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} but is neither a M:N ` +
2062
+ `relationship (requires @${RELATIONSHIP_ATTR_THROUGH} with @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_MANY}") ` +
2063
+ `nor a @${RELATIONSHIP_ATTR_CARDINALITY}: "${CARDINALITY_ONE}" relationship.`,
2012
2064
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2013
2065
  ),
2014
2066
  );
@@ -2016,7 +2068,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
2016
2068
  if (symmetric) {
2017
2069
  errors.push(
2018
2070
  new ParseError(
2019
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`,
2071
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but is not a M:N relationship.`,
2020
2072
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2021
2073
  ),
2022
2074
  );
@@ -2028,7 +2080,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
2028
2080
  if (symmetric && hasSourceRefField) {
2029
2081
  errors.push(
2030
2082
  new ParseError(
2031
- `relationship "${obj.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` +
2083
+ `relationship "${declaringEntity.name}.${rel.name}" sets both @${RELATIONSHIP_ATTR_SYMMETRIC} and ` +
2032
2084
  `@${RELATIONSHIP_ATTR_SOURCE_REF_FIELD}; they are mutually exclusive.`,
2033
2085
  { code: "ERR_BAD_ATTR_VALUE", source: rel.source },
2034
2086
  ),
@@ -2040,12 +2092,13 @@ export function validateRelationships(root: MetaData): ParseError[] {
2040
2092
  // in this package is self, but an FQN "other::Widget" (a different same-short-
2041
2093
  // name entity) is NOT (comparing stripped short names would misclassify it).
2042
2094
  const isSelfJoin =
2043
- typeof objectRef === "string" && resolveObjectRef(root, objectRef, referrerPkg).node === obj;
2095
+ typeof objectRef === "string" &&
2096
+ resolveObjectRef(root, objectRef, referrerPkg).node === declaringEntity;
2044
2097
  if (symmetric && !isSelfJoin) {
2045
2098
  errors.push(
2046
2099
  new ParseError(
2047
- `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` +
2048
- `"${String(objectRef)}" is not the declaring entity "${obj.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`,
2100
+ `relationship "${declaringEntity.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SYMMETRIC} but @${RELATIONSHIP_ATTR_OBJECT_REF} ` +
2101
+ `"${String(objectRef)}" is not the declaring entity "${declaringEntity.name}"; @${RELATIONSHIP_ATTR_SYMMETRIC} is self-join-only.`,
2049
2102
  { code: "ERR_BAD_ATTR_VALUE", source: rel.source },
2050
2103
  ),
2051
2104
  );
@@ -2056,8 +2109,8 @@ export function validateRelationships(root: MetaData): ParseError[] {
2056
2109
  if (!junction) {
2057
2110
  errors.push(
2058
2111
  new ParseError(
2059
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`,
2060
- { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${obj.fqn()}::${rel.name}`, String(through)) },
2112
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" does not resolve to an entity.${didYouMeanHint(root, String(through))}`,
2113
+ { code: "ERR_INVALID_RELATIONSHIP", source: resolvedSource(rel.source, `${declaringEntity.fqn()}::${rel.name}`, String(through)) },
2061
2114
  ),
2062
2115
  );
2063
2116
  continue;
@@ -2069,7 +2122,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
2069
2122
  if (junction.subType !== OBJECT_SUBTYPE_ENTITY) {
2070
2123
  errors.push(
2071
2124
  new ParseError(
2072
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` +
2125
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" resolves to ` +
2073
2126
  `${junction.type}.${junction.subType}, not an entity — a junction is a persisted join table ` +
2074
2127
  `and must be object.entity.`,
2075
2128
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
@@ -2081,7 +2134,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
2081
2134
  if (refCount !== 2) {
2082
2135
  errors.push(
2083
2136
  new ParseError(
2084
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` +
2137
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_THROUGH} "${through}" must declare exactly two ` +
2085
2138
  `identity.reference children (one per FK side); found ${refCount}.`,
2086
2139
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2087
2140
  ),
@@ -2094,7 +2147,7 @@ export function validateRelationships(root: MetaData): ParseError[] {
2094
2147
  if (!fkFields.includes(sourceRefField as string)) {
2095
2148
  errors.push(
2096
2149
  new ParseError(
2097
- `relationship "${obj.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` +
2150
+ `relationship "${declaringEntity.name}.${rel.name}" @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} "${sourceRefField}" does not match ` +
2098
2151
  `any identity.reference FK field on junction "${through}". Available: ${fkFields.join(", ") || "(none)"}.`,
2099
2152
  { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2100
2153
  ),
@@ -2106,6 +2159,111 @@ export function validateRelationships(root: MetaData): ParseError[] {
2106
2159
  return errors;
2107
2160
  }
2108
2161
 
2162
+ // ---------------------------------------------------------------------------
2163
+ // Rule (e) — #368: a `@cardinality: one` relationship must resolve to exactly
2164
+ // one identity.reference. Two references onto the same target are
2165
+ // indistinguishable from the relationship's @objectRef alone, so the resolver
2166
+ // would silently emit the first one's FK column. ADR-0029 §5: a second path
2167
+ // is a load error naming the candidates.
2168
+ //
2169
+ // Registered alongside validateRelationships (the M:N slim-vocabulary pass,
2170
+ // above) — same deferred-resolution timing (after all files load + extends
2171
+ // resolution).
2172
+ //
2173
+ // Scope differs deliberately from rule (d) — in SUBJECT, not in which
2174
+ // relationships each pass walks (both walk the EFFECTIVE set; rule (d) is
2175
+ // no longer own-scoped, or an M:N declaration reached only via extends
2176
+ // would go unchecked). Rule (d) validates attrs that travel with the
2177
+ // relationship's OWN declaration (@through/@symmetric/@sourceRefField), so
2178
+ // it checks each declaration EXACTLY ONCE — deduped by node identity, and
2179
+ // reported against the entity that declares it — because those attrs don't
2180
+ // change meaning depending on who inherits the relationship. Rule (e)
2181
+ // instead validates whether THIS entity's reference set resolves the
2182
+ // relationship uniquely, which is a property of the EFFECTIVE entity, not of
2183
+ // wherever the relationship happens to be declared. A child entity that
2184
+ // extends a clean parent and adds a second identity.reference onto the same
2185
+ // target makes an INHERITED relationship ambiguous on the child even though
2186
+ // the parent (and the relationship's own declaration) are untouched — own-
2187
+ // scoping this pass would leave that case unchecked, and codegen/runtime
2188
+ // (which resolve against the effective entity) would silently drop the
2189
+ // relation (#368 fix round 2). If a parent and a child are both genuinely
2190
+ // ambiguous, both are reported — two entities are broken, not one error
2191
+ // duplicated.
2192
+ // ---------------------------------------------------------------------------
2193
+
2194
+ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[] {
2195
+ const errors: ParseError[] = [];
2196
+ for (const obj of root.objects()) {
2197
+ // ADR-0039: resolving — see the scope note above: rule (e) checks THIS
2198
+ // entity's effective reference set against every relationship it can see,
2199
+ // including one only inherited via extends.
2200
+ for (const rel of obj.relationships()) {
2201
+ // ADR-0039: resolving — @cardinality/@objectRef may be inherited via extends.
2202
+ if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_ONE) continue;
2203
+ const objectRef = rel.attr(RELATIONSHIP_ATTR_OBJECT_REF);
2204
+ if (typeof objectRef !== "string" || objectRef === "") continue;
2205
+
2206
+ const candidates = referenceCandidatesFor(obj, objectRef);
2207
+
2208
+ const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
2209
+ const declared = typeof sourceRefField === "string" && sourceRefField !== ""
2210
+ ? sourceRefField
2211
+ : undefined;
2212
+
2213
+ if (declared !== undefined) {
2214
+ // A declared @sourceRefField short-circuits the ladder at ANY
2215
+ // candidate count — checked independently of resolveRelationshipReference,
2216
+ // whose step 1 ("exactly one candidate -> that one") would otherwise
2217
+ // silently return the lone candidate even when it disagrees with the
2218
+ // declared field. The author named a specific FK; it must exist,
2219
+ // whether there are zero, one, or many candidates.
2220
+ const matchesDeclared = candidates.some((c) => c.fields[0] === declared);
2221
+ if (matchesDeclared) continue;
2222
+ errors.push(
2223
+ new ParseError(
2224
+ `relationship "${obj.name}.${rel.name}" sets @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} ` +
2225
+ `"${declared}", which names no identity.reference targeting "${objectRef}". ` +
2226
+ `Candidates: ${formatReferenceCandidates(candidates)}.`,
2227
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2228
+ ),
2229
+ );
2230
+ continue;
2231
+ }
2232
+
2233
+ // No @sourceRefField declared: ambiguity only exists with 2+ candidates —
2234
+ // resolveRelationshipReference's name-pairing step (ladder step 3) decides.
2235
+ if (candidates.length <= 1) continue;
2236
+ const resolved = resolveRelationshipReference(obj, rel.name, objectRef);
2237
+ if (resolved) continue;
2238
+
2239
+ errors.push(
2240
+ new ParseError(
2241
+ `relationship "${obj.name}.${rel.name}" is ambiguous: "${obj.name}" declares ` +
2242
+ `${candidates.length} identity.reference nodes targeting "${objectRef}" and the ` +
2243
+ `relationship name does not pair with exactly one. Candidates: ${formatReferenceCandidates(candidates)}. ` +
2244
+ `Set @${RELATIONSHIP_ATTR_SOURCE_REF_FIELD} to the FK field this relationship navigates.`,
2245
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2246
+ ),
2247
+ );
2248
+ }
2249
+ }
2250
+ return errors;
2251
+ }
2252
+
2253
+ /**
2254
+ * Render a candidate reference as `name(fkField)`, or `name(fieldA, fieldB)`
2255
+ * for a composite reference — so two composite references sharing a first
2256
+ * column (e.g. both starting `tenantId`) still print distinguishably.
2257
+ *
2258
+ * NOTE: this is display only. Matching (both here and in
2259
+ * resolveRelationshipReference) still keys on `fields[0]` alone — a
2260
+ * composite reference cannot actually be disambiguated by @sourceRefField.
2261
+ * That's a documented limitation, not fixed by this rendering change.
2262
+ */
2263
+ function formatReferenceCandidates(candidates: readonly MetaReferenceIdentity[]): string {
2264
+ return candidates.map((c) => `${c.name}(${c.fields.join(", ")})`).join(", ");
2265
+ }
2266
+
2109
2267
  // NOTE: identity.reference @references resolution moved to the validation registry
2110
2268
  // (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
2111
2269