@metaobjectsdev/metadata 0.6.0 → 0.7.0-rc.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.
Files changed (128) hide show
  1. package/README.md +54 -3
  2. package/dist/attr-schema-validate.js +7 -7
  3. package/dist/attr-schema-validate.js.map +1 -1
  4. package/dist/core/export-json.d.ts +6 -7
  5. package/dist/core/export-json.d.ts.map +1 -1
  6. package/dist/core/export-json.js +15 -17
  7. package/dist/core/export-json.js.map +1 -1
  8. package/dist/core/index.d.ts +4 -2
  9. package/dist/core/index.d.ts.map +1 -1
  10. package/dist/core/index.js +6 -2
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/parser-yaml.d.ts.map +1 -1
  13. package/dist/core/parser-yaml.js +36 -11
  14. package/dist/core/parser-yaml.js.map +1 -1
  15. package/dist/core/yaml-desugar.d.ts.map +1 -1
  16. package/dist/core/yaml-desugar.js +54 -5
  17. package/dist/core/yaml-desugar.js.map +1 -1
  18. package/dist/core/yaml-positions-walker.d.ts +21 -0
  19. package/dist/core/yaml-positions-walker.d.ts.map +1 -0
  20. package/dist/core/yaml-positions-walker.js +75 -0
  21. package/dist/core/yaml-positions-walker.js.map +1 -0
  22. package/dist/core/yaml-positions.d.ts +19 -0
  23. package/dist/core/yaml-positions.d.ts.map +1 -0
  24. package/dist/core/yaml-positions.js +60 -0
  25. package/dist/core/yaml-positions.js.map +1 -0
  26. package/dist/errors.d.ts +32 -9
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +44 -5
  29. package/dist/errors.js.map +1 -1
  30. package/dist/index.d.ts +6 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +9 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/json-path.d.ts +8 -0
  35. package/dist/json-path.d.ts.map +1 -0
  36. package/dist/json-path.js +39 -0
  37. package/dist/json-path.js.map +1 -0
  38. package/dist/loader/meta-data-loader.d.ts +47 -6
  39. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  40. package/dist/loader/meta-data-loader.js +126 -8
  41. package/dist/loader/meta-data-loader.js.map +1 -1
  42. package/dist/loader/meta-data-source.d.ts +6 -2
  43. package/dist/loader/meta-data-source.d.ts.map +1 -1
  44. package/dist/loader/meta-data-source.js +10 -6
  45. package/dist/loader/meta-data-source.js.map +1 -1
  46. package/dist/loader/shortcuts.d.ts +9 -0
  47. package/dist/loader/shortcuts.d.ts.map +1 -0
  48. package/dist/loader/shortcuts.js +19 -0
  49. package/dist/loader/shortcuts.js.map +1 -0
  50. package/dist/loader/sources/directory-source.d.ts +15 -0
  51. package/dist/loader/sources/directory-source.d.ts.map +1 -0
  52. package/dist/loader/sources/directory-source.js +80 -0
  53. package/dist/loader/sources/directory-source.js.map +1 -0
  54. package/dist/loader/sources/file-source.d.ts +12 -0
  55. package/dist/loader/sources/file-source.d.ts.map +1 -0
  56. package/dist/loader/sources/file-source.js +46 -0
  57. package/dist/loader/sources/file-source.js.map +1 -0
  58. package/dist/loader/sources/index.d.ts +5 -0
  59. package/dist/loader/sources/index.d.ts.map +1 -0
  60. package/dist/loader/sources/index.js +5 -0
  61. package/dist/loader/sources/index.js.map +1 -0
  62. package/dist/loader/sources/uri-source.d.ts +9 -0
  63. package/dist/loader/sources/uri-source.d.ts.map +1 -0
  64. package/dist/loader/sources/uri-source.js +42 -0
  65. package/dist/loader/sources/uri-source.js.map +1 -0
  66. package/dist/loader/validation-passes.d.ts.map +1 -1
  67. package/dist/loader/validation-passes.js +92 -28
  68. package/dist/loader/validation-passes.js.map +1 -1
  69. package/dist/naming.d.ts +15 -2
  70. package/dist/naming.d.ts.map +1 -1
  71. package/dist/naming.js +20 -6
  72. package/dist/naming.js.map +1 -1
  73. package/dist/parser-core.d.ts +17 -4
  74. package/dist/parser-core.d.ts.map +1 -1
  75. package/dist/parser-core.js +371 -44
  76. package/dist/parser-core.js.map +1 -1
  77. package/dist/parser-json.d.ts.map +1 -1
  78. package/dist/parser-json.js +10 -2
  79. package/dist/parser-json.js.map +1 -1
  80. package/dist/persistence/source/validate-source-roles.js +2 -2
  81. package/dist/persistence/source/validate-source-roles.js.map +1 -1
  82. package/dist/semantic-diff.d.ts +5 -0
  83. package/dist/semantic-diff.d.ts.map +1 -0
  84. package/dist/semantic-diff.js +49 -0
  85. package/dist/semantic-diff.js.map +1 -0
  86. package/dist/shared/meta-data.d.ts +10 -0
  87. package/dist/shared/meta-data.d.ts.map +1 -1
  88. package/dist/shared/meta-data.js +23 -0
  89. package/dist/shared/meta-data.js.map +1 -1
  90. package/dist/source.d.ts +96 -0
  91. package/dist/source.d.ts.map +1 -0
  92. package/dist/source.js +38 -0
  93. package/dist/source.js.map +1 -0
  94. package/dist/subtype-rules.js +1 -1
  95. package/dist/subtype-rules.js.map +1 -1
  96. package/dist/super-resolve.d.ts +2 -0
  97. package/dist/super-resolve.d.ts.map +1 -1
  98. package/dist/super-resolve.js +1 -1
  99. package/dist/super-resolve.js.map +1 -1
  100. package/package.json +1 -1
  101. package/src/attr-schema-validate.ts +7 -7
  102. package/src/core/export-json.ts +15 -18
  103. package/src/core/index.ts +8 -2
  104. package/src/core/parser-yaml.ts +38 -11
  105. package/src/core/yaml-desugar.ts +58 -4
  106. package/src/core/yaml-positions-walker.ts +101 -0
  107. package/src/core/yaml-positions.ts +80 -0
  108. package/src/errors.ts +57 -8
  109. package/src/index.ts +28 -3
  110. package/src/json-path.ts +46 -0
  111. package/src/loader/meta-data-loader.ts +168 -10
  112. package/src/loader/meta-data-source.ts +10 -6
  113. package/src/loader/shortcuts.ts +31 -0
  114. package/src/loader/sources/directory-source.ts +90 -0
  115. package/src/{core → loader/sources}/file-source.ts +3 -3
  116. package/src/loader/sources/index.ts +6 -0
  117. package/src/loader/sources/uri-source.ts +44 -0
  118. package/src/loader/validation-passes.ts +96 -29
  119. package/src/naming.ts +39 -7
  120. package/src/parser-core.ts +412 -46
  121. package/src/parser-json.ts +11 -2
  122. package/src/persistence/source/validate-source-roles.ts +2 -2
  123. package/src/semantic-diff.ts +48 -0
  124. package/src/shared/meta-data.ts +28 -0
  125. package/src/source.ts +99 -0
  126. package/src/subtype-rules.ts +1 -1
  127. package/src/super-resolve.ts +3 -1
  128. package/src/core/file-meta-data-loader.ts +0 -89
@@ -27,9 +27,13 @@ import { TypeId, TypeRegistry } from "./registry.js";
27
27
  import type { MetaData } from "./shared/meta-data.js";
28
28
  import { MetaRoot } from "./shared/meta-root.js";
29
29
  import { MetaAttr } from "./core/attr/meta-attr.js";
30
- import { inferAttrSubType } from "./serializer-json.js";
30
+ import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
31
31
  import { ParseError, type ErrorCode } from "./errors.js";
32
+ import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
33
+ import { semanticDiff } from "./semantic-diff.js";
32
34
  import { resolveSuperRef } from "./super-resolve.js";
35
+ import { JsonPathBuilder } from "./json-path.js";
36
+ import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js";
33
37
  import {
34
38
  TYPE_ATTR,
35
39
  TYPE_FIELD,
@@ -80,27 +84,62 @@ export interface ParseOptions {
80
84
  * another file parsed later.
81
85
  */
82
86
  deferSuperResolution?: boolean;
87
+ /**
88
+ * FR5b — discriminant for the source-on-node envelope's `format` field.
89
+ * Defaults to `"json"` (parseJson supplies nothing). parseYaml passes
90
+ * `"yaml"` so populateNodeSource emits `format: "yaml"` and, when the
91
+ * desugar attached one, the optional `yamlPosition`.
92
+ */
93
+ sourceFormat?: "json" | "yaml";
83
94
  }
84
95
 
85
96
  export interface ParseResult {
86
97
  root: MetaRoot;
87
98
  warnings: string[];
88
99
  errors: ParseError[];
100
+ /**
101
+ * FR5c — envelope-shaped warnings (e.g. WARN_DUPLICATE_DECLARATION) produced
102
+ * during the parse/merge pipeline. Distinct from the legacy `warnings:
103
+ * string[]` channel: those messages get wrapped in a `WARN_LEGACY` envelope
104
+ * at the loader boundary, while envelope warnings already carry their own
105
+ * `code` + `source` and are surfaced unchanged. Defaults to `[]`.
106
+ */
107
+ envelopeWarnings: LoaderWarning[];
89
108
  }
90
109
 
91
110
  // ---------------------------------------------------------------------------
92
- // Internal helper — build ParseError opts, omitting undefined fields
93
- // (required by exactOptionalPropertyTypes: true in the project tsconfig)
111
+ // Internal helper — build a parse-phase ErrorSource envelope.
112
+ //
113
+ // FR5a / ADR-0009: ParseError carries a `source: ErrorSource` envelope. For
114
+ // errors raised mid-parse, the envelope's jsonPath comes from the parser's
115
+ // module-level JsonPathBuilder (synced with the walk); files[0] is the parsed
116
+ // source id. Falls back to a code-source envelope if invoked outside a buildTree
117
+ // run (defensive — every callsite below runs inside buildTree).
94
118
  // ---------------------------------------------------------------------------
95
119
 
96
- export function errOpts(
97
- source: string | undefined,
98
- path?: string,
99
- ): { source?: string; path?: string } {
100
- const opts: { source?: string; path?: string } = {};
101
- if (source !== undefined) opts.source = source;
102
- if (path !== undefined) opts.path = path;
103
- return opts;
120
+ export function errSource(): ErrorSource {
121
+ if (_currentPath !== undefined && _currentSourceId !== undefined) {
122
+ // FR5b (finalized 2026-05-27, all four ports) — buildTree-emitted errors
123
+ // from a YAML input now emit `format: "yaml"` (was `"json"` interim while
124
+ // each port shipped yamlPosition tracking). The optional `yamlPosition`
125
+ // rides along when the desugar's position map covers the current node.
126
+ if (_currentFormat === "yaml") {
127
+ return {
128
+ format: "yaml",
129
+ files: [_currentSourceId],
130
+ jsonPath: _currentPath.toString(),
131
+ ...(_currentYamlPosition !== undefined
132
+ ? { yamlPosition: _currentYamlPosition }
133
+ : {}),
134
+ };
135
+ }
136
+ return {
137
+ format: "json",
138
+ files: [_currentSourceId],
139
+ jsonPath: _currentPath.toString(),
140
+ };
141
+ }
142
+ return { format: "code", caller: "parser-core" };
104
143
  }
105
144
 
106
145
  // ---------------------------------------------------------------------------
@@ -111,14 +150,10 @@ function reportProblem(
111
150
  msg: string,
112
151
  strict: boolean,
113
152
  warnings: string[],
114
- source: string | undefined,
115
- path: string,
116
- code?: ErrorCode,
153
+ code: ErrorCode,
117
154
  ): void {
118
155
  if (strict) {
119
- const opts = errOpts(source, path);
120
- if (code !== undefined) throw new ParseError(msg, { ...opts, code });
121
- else throw new ParseError(msg, opts);
156
+ throw new ParseError(msg, { code, source: errSource() });
122
157
  }
123
158
  warnings.push(msg);
124
159
  }
@@ -196,6 +231,56 @@ let _deferSuperResolution = false;
196
231
  // _deferSuperResolution.
197
232
  let _currentErrors: ParseError[] | undefined;
198
233
 
234
+ // FR5c — envelope-warnings sink for the merge phase. Set at buildTree entry
235
+ // so deeply-nested merge sites can emit WARN_DUPLICATE_DECLARATION without
236
+ // threading another parameter through every helper signature. Safe under
237
+ // the same synchronous-buildTree reentrancy argument as the others.
238
+ let _currentEnvelopeWarnings: LoaderWarning[] | undefined;
239
+
240
+ // FR5a / ADR-0009 — Module-level JSONPath builder + source id, set at
241
+ // buildTree entry and updated by recursive descent (push on the way down,
242
+ // pop on the way back up). Used by populateNodeSource() to stamp every
243
+ // constructed MetaData with `{ format: "json"|"yaml", files: [sourceId],
244
+ // jsonPath, [yamlPosition?] }`.
245
+ // Safe because buildTree is synchronous — same reentrancy argument as
246
+ // _currentErrors above.
247
+ let _currentPath: JsonPathBuilder | undefined;
248
+ let _currentSourceId: string | undefined;
249
+ // FR5b — source format discriminant + current node's YAML position, set
250
+ // by the per-child iteration in processChildren (and at root) right before
251
+ // the parseNodeFresh call. The position is read from the wrapper object's
252
+ // position-by-key map (attached by the YAML walker in core/yaml-positions.ts
253
+ // and preserved through core/yaml-desugar.ts).
254
+ let _currentFormat: "json" | "yaml" = "json";
255
+ let _currentYamlPosition: YamlPosition | undefined;
256
+
257
+ /** FR5a/FR5b — stamp the source-provenance envelope on a freshly-created
258
+ * node. No-op when invoked outside buildTree's setup (defensive — the
259
+ * module-level state will always be populated during a normal parse).
260
+ *
261
+ * FR5b (finalized 2026-05-27) — YAML-input nodes get `format: "yaml"`
262
+ * envelopes (was `"json"` interim). The optional `yamlPosition` rides
263
+ * along when the desugar's position map covers the current node. */
264
+ function populateNodeSource(node: MetaData): void {
265
+ if (_currentPath === undefined || _currentSourceId === undefined) return;
266
+ if (_currentFormat === "yaml") {
267
+ node.setSource({
268
+ format: "yaml",
269
+ files: [_currentSourceId],
270
+ jsonPath: _currentPath.toString(),
271
+ ...(_currentYamlPosition !== undefined
272
+ ? { yamlPosition: _currentYamlPosition }
273
+ : {}),
274
+ });
275
+ return;
276
+ }
277
+ node.setSource({
278
+ format: "json",
279
+ files: [_currentSourceId],
280
+ jsonPath: _currentPath.toString(),
281
+ });
282
+ }
283
+
199
284
  /**
200
285
  * buildTree — the shared registry-driven tree-builder.
201
286
  *
@@ -210,14 +295,29 @@ let _currentErrors: ParseError[] | undefined;
210
295
  export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
211
296
  const warnings: string[] = [];
212
297
  const errors: ParseError[] = [];
298
+ const envelopeWarnings: LoaderWarning[] = [];
213
299
  const strict = opts.strict ?? false;
214
300
  const source = opts.sourceName;
215
301
  _deferSuperResolution = opts.deferSuperResolution === true;
216
302
  _currentErrors = errors;
303
+ // FR5c — module-level handle so the deeply-nested merge code paths can
304
+ // emit envelope warnings without threading another parameter through the
305
+ // entire walk. Safe because buildTree is fully synchronous.
306
+ _currentEnvelopeWarnings = envelopeWarnings;
307
+ // FR5a — start a fresh JSONPath stack rooted at "$"; sourceId is the
308
+ // source's id (from FileSource / InMemoryStringSource via opts.sourceName).
309
+ // Falls back to "<unknown>" when no name was supplied (e.g. ad-hoc parseJson
310
+ // calls from tests).
311
+ _currentPath = new JsonPathBuilder();
312
+ _currentSourceId = source ?? "<unknown>";
313
+ // FR5b — propagate the per-parse source-format discriminant. parseJson
314
+ // omits the option (defaults to "json"); parseYaml supplies "yaml".
315
+ _currentFormat = opts.sourceFormat ?? "json";
316
+ _currentYamlPosition = undefined;
217
317
 
218
318
  try {
219
319
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
220
- throw new ParseError("Top-level metadata must be an object", { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
320
+ throw new ParseError("Top-level metadata must be an object", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
221
321
  }
222
322
 
223
323
  const topLevel = parsed as Record<string, unknown>;
@@ -226,12 +326,12 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
226
326
  const wrapperKeys = Object.keys(topLevel).filter((k) => k !== JSON_KEY_SCHEMA);
227
327
 
228
328
  if (wrapperKeys.length === 0) {
229
- throw new ParseError("Top-level metadata object has no type wrapper key", { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
329
+ throw new ParseError("Top-level metadata object has no type wrapper key", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
230
330
  }
231
331
  if (wrapperKeys.length > 1) {
232
332
  throw new ParseError(
233
333
  `Top-level metadata object must have exactly one wrapper key (found: ${wrapperKeys.join(", ")})`,
234
- { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" },
334
+ { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() },
235
335
  );
236
336
  }
237
337
 
@@ -239,9 +339,14 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
239
339
  const rootData = topLevel[rootKey];
240
340
 
241
341
  if (typeof rootData !== "object" || rootData === null || Array.isArray(rootData)) {
342
+ // The error context references the rootKey wrapper; push it so the
343
+ // envelope's jsonPath includes it (matches the legacy `path` slot).
344
+ _currentPath!.pushKey(rootKey);
345
+ const src = errSource();
346
+ _currentPath!.pop();
242
347
  throw new ParseError(
243
348
  `Top-level wrapper "${rootKey}" must contain an object`,
244
- { ...errOpts(source, rootKey), code: "ERR_TOP_LEVEL_NOT_OBJECT" },
349
+ { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: src },
245
350
  );
246
351
  }
247
352
 
@@ -253,12 +358,27 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
253
358
  const rootTypeCode = opts.registry.allSubTypesOf(rootType).length > 0
254
359
  ? "ERR_UNKNOWN_SUBTYPE" as const
255
360
  : "ERR_UNKNOWN_TYPE" as const;
361
+ _currentPath!.pushKey(rootKey);
362
+ const src = errSource();
363
+ _currentPath!.pop();
256
364
  throw new ParseError(
257
365
  `Unknown root type "${rootType}.${rootSubType}" — not registered`,
258
- { ...errOpts(source, rootKey), code: rootTypeCode },
366
+ { code: rootTypeCode, source: src },
259
367
  );
260
368
  }
261
369
 
370
+ // FR5a — push the wrapper-key segment onto the JSONPath stack so all
371
+ // descendants emit jsonPath strings rooted at "$.<rootKey>". The merge-mode
372
+ // path keeps the existing root's source untouched; only NEW children get
373
+ // populated with the current source's id (correct: the existing root was
374
+ // already stamped from the file that created it).
375
+ _currentPath!.pushKey(rootKey);
376
+ // FR5b — look up the root wrapper's YAML position (when in yaml mode)
377
+ // so parseNodeFresh stamps `source.yamlPosition` on the root node.
378
+ if (_currentFormat === "yaml") {
379
+ _currentYamlPosition = getYamlPosition(topLevel, rootKey);
380
+ }
381
+
262
382
  if (opts.intoRoot !== undefined) {
263
383
  // --- Merge mode: parse root's attrs/children into the existing root ---
264
384
  // The JSON root's own package/super/reserved-keys are not re-applied to the
@@ -278,7 +398,8 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
278
398
  source,
279
399
  rootKey,
280
400
  );
281
- return { root: opts.intoRoot, warnings, errors };
401
+ _currentPath!.pop();
402
+ return { root: opts.intoRoot, warnings, errors, envelopeWarnings };
282
403
  }
283
404
 
284
405
  // --- Fresh root mode: create a new root from the JSON ---
@@ -302,10 +423,16 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
302
423
  source,
303
424
  rootKey,
304
425
  ) as MetaRoot;
305
- return { root, warnings, errors };
426
+ _currentPath!.pop();
427
+ return { root, warnings, errors, envelopeWarnings };
306
428
  } finally {
307
429
  _deferSuperResolution = false;
308
430
  _currentErrors = undefined;
431
+ _currentEnvelopeWarnings = undefined;
432
+ _currentPath = undefined;
433
+ _currentSourceId = undefined;
434
+ _currentFormat = "json";
435
+ _currentYamlPosition = undefined;
309
436
  }
310
437
  }
311
438
 
@@ -341,10 +468,12 @@ function parseNodeFresh(
341
468
  subType = SUBTYPE_BASE;
342
469
  } else {
343
470
  const msg = `Unknown type "${type}.${subType}" — not registered`;
344
- errors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_UNKNOWN_TYPE" }));
471
+ errors.push(new ParseError(msg, { code: "ERR_UNKNOWN_TYPE", source: errSource() }));
345
472
  const rawName = nodeData[RESERVED_KEY_NAME];
346
473
  const name = typeof rawName === "string" ? rawName : "";
347
- return new MetaRoot(new TypeId(type, subType), name);
474
+ const stub = new MetaRoot(new TypeId(type, subType), name);
475
+ populateNodeSource(stub);
476
+ return stub;
348
477
  }
349
478
  }
350
479
 
@@ -355,6 +484,10 @@ function parseNodeFresh(
355
484
  // --- Create the model ---
356
485
  const def = registry.find(type, subType)!;
357
486
  const model = def.factory(def.typeId, name);
487
+ // FR5a — stamp the source provenance envelope using the parser's current
488
+ // JSONPath stack + source id. setSource happens BEFORE freeze (the parser
489
+ // is the only caller during loading; freeze runs in the loader after).
490
+ populateNodeSource(model);
358
491
 
359
492
  // --- Apply reserved keys (package, extends, abstract, isArray) ---
360
493
  applyReservedKeys(model, nodeData, strict, source, path, warnings, inheritedContextPkg);
@@ -390,9 +523,15 @@ function parseNodeFresh(
390
523
  if (superModel !== undefined) {
391
524
  model.setSuperResolved(superModel);
392
525
  } else {
526
+ // FR5d — emit format=resolved with referrer + target. referrer is the
527
+ // declaring node's FQN (we just built it above); target is the
528
+ // unresolved supertype ref string.
393
529
  throw new ParseError(
394
530
  `the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`,
395
- { ...errOpts(source, path), code: "ERR_UNRESOLVED_SUPER" },
531
+ {
532
+ code: "ERR_UNRESOLVED_SUPER",
533
+ source: resolvedSource(errSource(), model.fqn(), model.superRef),
534
+ },
396
535
  );
397
536
  }
398
537
  } else if (model.superRef !== undefined && accumRoot === undefined) {
@@ -401,8 +540,6 @@ function parseNodeFresh(
401
540
  `super on root node ('${model.superRef}') is not supported and will be ignored`,
402
541
  strict,
403
542
  warnings,
404
- source,
405
- path,
406
543
  "ERR_UNRESOLVED_SUPER",
407
544
  );
408
545
  }
@@ -439,6 +576,46 @@ function parseNodeInto(
439
576
  source: string | undefined,
440
577
  path: string,
441
578
  ): void {
579
+ // FR5c — capture pre-merge state so we can decide whether this contribution
580
+ // produced any semantic change. The "new contributor" is the file currently
581
+ // being parsed (_currentSourceId). The "base contributor" is whichever
582
+ // file(s) the existing target already records on its source envelope.
583
+ const newContributorFile = _currentSourceId ?? "<unknown>";
584
+ const targetIsRoot = target instanceof MetaRoot;
585
+ // The root is a synthetic accumulator: it is not a metadata-author node —
586
+ // every file declares { "metadata.root": ... } and the loader merges into
587
+ // a single root. We don't run FR5c diagnostics on the root itself; the
588
+ // merge attribution applies to author-meaningful nodes (object/field/etc).
589
+ const fr5cActive = !targetIsRoot && target.name !== "";
590
+
591
+ let preMergeShape: string | undefined;
592
+ let preMergeAttrSnapshot: Map<string, AttrValue> | undefined;
593
+ if (fr5cActive) {
594
+ preMergeShape = canonicalSerialize(target);
595
+ // Snapshot of own attrs (name → value) — used to detect ERR_MERGE_CONFLICT
596
+ // when a new contribution sets an attr the target already declared to a
597
+ // different non-empty value.
598
+ preMergeAttrSnapshot = new Map(target.ownAttrs());
599
+ }
600
+
601
+ // FR5c — detect attribute-level merge conflicts: for each @-prefixed inline
602
+ // attr in nodeData, if the target already has an own attr of that name
603
+ // with a non-empty value, AND the new value differs from the existing
604
+ // value (both sides non-empty), emit ERR_MERGE_CONFLICT with a `format:
605
+ // "merged"` envelope. Last-writer-wins is preserved for non-conflicting
606
+ // cases (one side unset, same value, etc.) — those carry through to the
607
+ // existing applyInlineAttrsAndUnknownKeys logic below.
608
+ if (fr5cActive && preMergeAttrSnapshot !== undefined) {
609
+ detectAttrMergeConflicts(
610
+ target,
611
+ nodeData,
612
+ preMergeAttrSnapshot,
613
+ newContributorFile,
614
+ errors,
615
+ path,
616
+ );
617
+ }
618
+
442
619
  // Apply inline attrs (not reserved keys — those stay on the existing model)
443
620
  applyInlineAttrsAndUnknownKeys(target, nodeData, strict, source, path, warnings, registry);
444
621
 
@@ -448,6 +625,168 @@ function parseNodeInto(
448
625
  const effectivePkg = inheritedContextPkg;
449
626
 
450
627
  processChildren(target, nodeData, accumRoot, effectivePkg, registry, warnings, errors, strict, source, path);
628
+
629
+ // FR5c — after merge: compare pre/post shape. If no semantic change → emit
630
+ // WARN_DUPLICATE_DECLARATION (the new contribution declared the same thing
631
+ // the target already had). If there was a change → upgrade the target's
632
+ // source envelope to `format: "merged"` with both contributors listed
633
+ // (ADR-0009 §Source-on-node: overlay merge with semantic change updates
634
+ // source; duplicate-with-no-change leaves source unchanged + emits warning).
635
+ if (fr5cActive && preMergeShape !== undefined) {
636
+ const postMergeShape = canonicalSerialize(target);
637
+ const preParsed = JSON.parse(preMergeShape) as Record<string, unknown>;
638
+ const postParsed = JSON.parse(postMergeShape) as Record<string, unknown>;
639
+ const changed = semanticDiff(preParsed, postParsed);
640
+
641
+ // Pull the existing contributor file(s) off the target's current source
642
+ // envelope (set at parse-fresh time or by a previous merge).
643
+ const existing = target.source;
644
+ const existingFiles = "files" in existing ? [...existing.files] : [];
645
+
646
+ if (changed) {
647
+ // Real overlay — upgrade source to merged with contributors.
648
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
649
+ // Alphabetical sort — matches DirectorySource ordering and gives a
650
+ // deterministic cross-port file list (ADR-0009 §"Cross-port adoption").
651
+ allFiles.sort();
652
+ const contributors: Contributor[] = allFiles.map((f, i) => ({
653
+ file: f,
654
+ role: i === 0 ? "overlay-base" : "overlay-extension",
655
+ }));
656
+ const mergedSource: ErrorSource = {
657
+ format: "merged",
658
+ files: allFiles,
659
+ jsonPath:
660
+ "jsonPath" in existing && existing.jsonPath !== undefined
661
+ ? existing.jsonPath
662
+ : (_currentPath?.toString() ?? "$"),
663
+ contributors,
664
+ };
665
+ target.setSource(mergedSource);
666
+ } else if (
667
+ existingFiles.length > 0 &&
668
+ !existingFiles.includes(newContributorFile)
669
+ ) {
670
+ // Identical re-declaration from a different file → warn.
671
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
672
+ allFiles.sort();
673
+ const contributors: Contributor[] = allFiles.map((f, i) => ({
674
+ file: f,
675
+ role: i === 0 ? "overlay-base" : "overlay-extension",
676
+ }));
677
+ const warnSource: ErrorSource = {
678
+ format: "merged",
679
+ files: allFiles,
680
+ jsonPath:
681
+ "jsonPath" in existing && existing.jsonPath !== undefined
682
+ ? existing.jsonPath
683
+ : (_currentPath?.toString() ?? "$"),
684
+ contributors,
685
+ };
686
+ _currentEnvelopeWarnings?.push({
687
+ code: "WARN_DUPLICATE_DECLARATION",
688
+ message: `duplicate declaration of ${target.fqn()} with no semantic change`,
689
+ source: warnSource,
690
+ });
691
+ }
692
+ }
693
+ }
694
+
695
+ /** FR5c — for every @-prefixed inline attr in `nodeData`, check whether the
696
+ * target already declares the same attr (in `preAttrs`) with a different
697
+ * non-empty value. If so, emit ERR_MERGE_CONFLICT with a `format: "merged"`
698
+ * envelope naming both contributors. The merge itself proceeds (existing
699
+ * last-writer-wins) so the loader sees one canonical tree; the error
700
+ * surfaces the conflict so a consumer can fix the metadata. */
701
+ function detectAttrMergeConflicts(
702
+ target: MetaData,
703
+ nodeData: Record<string, unknown>,
704
+ preAttrs: Map<string, AttrValue>,
705
+ newContributorFile: string,
706
+ errors: ParseError[],
707
+ path: string,
708
+ ): void {
709
+ const existingFiles =
710
+ "files" in target.source ? [...target.source.files] : [];
711
+
712
+ function isEmptyValue(v: unknown): boolean {
713
+ if (v === undefined || v === null) return true;
714
+ if (typeof v === "string" && v === "") return true;
715
+ if (Array.isArray(v) && v.length === 0) return true;
716
+ return false;
717
+ }
718
+
719
+ function attrValuesEqual(a: AttrValue, b: AttrValue): boolean {
720
+ // String/number/boolean — direct compare.
721
+ if (a === b) return true;
722
+ if (Array.isArray(a) && Array.isArray(b)) {
723
+ if (a.length !== b.length) return false;
724
+ for (let i = 0; i < a.length; i++) {
725
+ if (a[i] !== b[i]) return false;
726
+ }
727
+ return true;
728
+ }
729
+ if (
730
+ typeof a === "object" && a !== null && !Array.isArray(a) &&
731
+ typeof b === "object" && b !== null && !Array.isArray(b)
732
+ ) {
733
+ // Object attrs — structural compare via JSON (key order independent
734
+ // through Object.keys().sort()).
735
+ try {
736
+ const ak = Object.keys(a).sort();
737
+ const bk = Object.keys(b).sort();
738
+ if (ak.length !== bk.length) return false;
739
+ for (let i = 0; i < ak.length; i++) {
740
+ if (ak[i] !== bk[i]) return false;
741
+ if (JSON.stringify((a as Record<string, unknown>)[ak[i]!]) !==
742
+ JSON.stringify((b as Record<string, unknown>)[bk[i]!])) {
743
+ return false;
744
+ }
745
+ }
746
+ return true;
747
+ } catch {
748
+ return false;
749
+ }
750
+ }
751
+ return false;
752
+ }
753
+
754
+ for (const key of Object.keys(nodeData)) {
755
+ if (!key.startsWith(ATTR_PREFIX)) continue;
756
+ if (RESERVED_KEYS.has(key)) continue;
757
+ const attrName = key.slice(ATTR_PREFIX.length);
758
+ if (RESERVED_KEYS.has(attrName)) continue;
759
+
760
+ const newValRaw = nodeData[key];
761
+ const existingVal = preAttrs.get(attrName);
762
+
763
+ if (existingVal === undefined) continue;
764
+ if (isEmptyValue(newValRaw) || isEmptyValue(existingVal)) continue;
765
+ // Both sides set a non-empty value — compare. If equal, last-writer-wins
766
+ // is a no-op; if different, that's a conflict.
767
+ if (attrValuesEqual(existingVal, newValRaw as AttrValue)) continue;
768
+
769
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
770
+ allFiles.sort();
771
+ const contributors: Contributor[] = allFiles.map((f, i) => ({
772
+ file: f,
773
+ role: i === 0 ? "overlay-base" : "overlay-extension",
774
+ }));
775
+ const conflictSource: ErrorSource = {
776
+ format: "merged",
777
+ files: allFiles,
778
+ jsonPath: `${_currentPath?.toString() ?? path}.${ATTR_PREFIX}${attrName}`,
779
+ contributors,
780
+ };
781
+ errors.push(
782
+ new ParseError(
783
+ `attr '${ATTR_PREFIX}${attrName}' conflicts: existing value ` +
784
+ `${JSON.stringify(existingVal)} differs from new value ` +
785
+ `${JSON.stringify(newValRaw)} on ${target.fqn()}`,
786
+ { code: "ERR_MERGE_CONFLICT", source: conflictSource },
787
+ ),
788
+ );
789
+ }
451
790
  }
452
791
 
453
792
  // ---------------------------------------------------------------------------
@@ -487,7 +826,7 @@ function createOrFindMetaData(
487
826
  if (existing === undefined) {
488
827
  throw new ParseError(
489
828
  `Overlay operation requested for [${type}:${name}] but no existing metadata found to merge into`,
490
- { ...errOpts(source, path), code: "ERR_OVERLAY_NO_TARGET" },
829
+ { code: "ERR_OVERLAY_NO_TARGET", source: errSource() },
491
830
  );
492
831
  }
493
832
  existing.setIsMerge(true);
@@ -524,7 +863,7 @@ function applyReservedKeys(
524
863
  const rawPkg = nodeData[RESERVED_KEY_PACKAGE];
525
864
  if (rawPkg !== undefined) {
526
865
  if (typeof rawPkg !== "string") {
527
- reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
866
+ reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
528
867
  } else {
529
868
  const expandedPkg = contextPkg !== undefined ? expandPackageForPath(contextPkg, rawPkg) : rawPkg;
530
869
  model.setPackage(expandedPkg);
@@ -535,7 +874,7 @@ function applyReservedKeys(
535
874
  const rawExtends = nodeData[RESERVED_KEY_EXTENDS];
536
875
  if (rawExtends !== undefined) {
537
876
  if (typeof rawExtends !== "string") {
538
- reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, source, path, "ERR_UNRESOLVED_SUPER");
877
+ reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, "ERR_UNRESOLVED_SUPER");
539
878
  } else {
540
879
  model.setSuper(rawExtends);
541
880
  }
@@ -545,7 +884,7 @@ function applyReservedKeys(
545
884
  const rawAbstract = nodeData[RESERVED_KEY_ABSTRACT];
546
885
  if (rawAbstract !== undefined) {
547
886
  if (typeof rawAbstract !== "boolean") {
548
- reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
887
+ reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
549
888
  } else {
550
889
  model.setIsAbstract(rawAbstract);
551
890
  }
@@ -555,7 +894,7 @@ function applyReservedKeys(
555
894
  const rawIsArray = nodeData[RESERVED_KEY_IS_ARRAY];
556
895
  if (rawIsArray !== undefined) {
557
896
  if (typeof rawIsArray !== "boolean") {
558
- reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
897
+ reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
559
898
  } else {
560
899
  model.setIsArray(rawIsArray);
561
900
  }
@@ -587,7 +926,7 @@ function applyInlineAttrsAndUnknownKeys(
587
926
  model.name !== "" ? `${model.type}.${model.subType} '${model.name}'` : `${model.type}.${model.subType}`;
588
927
  reportProblem(
589
928
  `Unknown key '${key}' on ${displayName} at ${path} (must be reserved or ${ATTR_PREFIX}-prefixed)`,
590
- strict, warnings, source, path, "ERR_UNKNOWN_ATTR",
929
+ strict, warnings, "ERR_UNKNOWN_ATTR",
591
930
  );
592
931
  continue;
593
932
  }
@@ -608,14 +947,14 @@ function applyInlineAttrsAndUnknownKeys(
608
947
  `Reserved structural key '${attrName}' must not be ${ATTR_PREFIX}-prefixed ` +
609
948
  `on ${displayName} at ${path} (write it bare)`;
610
949
  if (strict) {
611
- throw new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" });
950
+ throw new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() });
612
951
  }
613
952
  // Lax mode: route through the module-level errors sink so the loader
614
953
  // sees this as a hard error (parity with attr-schema-validate's
615
954
  // ERR_BAD_ATTR_VALUE direct pushes). Falls back to warnings only if
616
955
  // _currentErrors isn't bound (unreachable when called from buildTree).
617
956
  if (_currentErrors !== undefined) {
618
- _currentErrors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" }));
957
+ _currentErrors.push(new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() }));
619
958
  } else {
620
959
  warnings.push(msg);
621
960
  }
@@ -630,7 +969,7 @@ function applyInlineAttrsAndUnknownKeys(
630
969
  } catch (err) {
631
970
  reportProblem(
632
971
  `Failed to convert attribute "${ATTR_PREFIX}${attrName}" at ${path}: ${(err as Error).message}`,
633
- strict, warnings, source, path, "ERR_BAD_ATTR_VALUE",
972
+ strict, warnings, "ERR_BAD_ATTR_VALUE",
634
973
  );
635
974
  }
636
975
  }
@@ -703,16 +1042,23 @@ function processChildren(
703
1042
  if (rawChildren === undefined) return;
704
1043
 
705
1044
  if (!Array.isArray(rawChildren)) {
706
- reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, source, path, "ERR_TOP_LEVEL_NOT_OBJECT");
1045
+ reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
707
1046
  return;
708
1047
  }
709
1048
 
1049
+ // FR5a — push the "children" segment once; each iteration pushes/pops a
1050
+ // numeric index + the wrapper-key segment so nested nodes see the correct
1051
+ // jsonPath when populateNodeSource() is called during their construction.
1052
+ _currentPath?.pushKey(RESERVED_KEY_CHILDREN);
1053
+
710
1054
  for (let i = 0; i < rawChildren.length; i++) {
711
1055
  const childEntry = rawChildren[i];
712
1056
  const childPath = `${path}.${RESERVED_KEY_CHILDREN}[${i}]`;
1057
+ _currentPath?.pushIndex(i);
713
1058
 
714
1059
  if (typeof childEntry !== "object" || childEntry === null || Array.isArray(childEntry)) {
715
- reportProblem(`Child at ${childPath} must be an object`, strict, warnings, source, childPath, "ERR_TOP_LEVEL_NOT_OBJECT");
1060
+ reportProblem(`Child at ${childPath} must be an object`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
1061
+ _currentPath?.pop();
716
1062
  continue;
717
1063
  }
718
1064
 
@@ -724,19 +1070,32 @@ function processChildren(
724
1070
  childKeys.length === 0
725
1071
  ? `Child at ${childPath} has no type wrapper key`
726
1072
  : `Child at ${childPath} has multiple keys (${childKeys.join(", ")}) — each child must have exactly one wrapper key`;
727
- reportProblem(msg, strict, warnings, source, childPath, "ERR_TOP_LEVEL_NOT_OBJECT");
1073
+ reportProblem(msg, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
1074
+ _currentPath?.pop();
728
1075
  continue;
729
1076
  }
730
1077
 
731
1078
  const childKey = childKeys[0]!;
732
1079
  const childData = childRecord[childKey];
733
1080
  const childNodePath = `${childPath}.${childKey}`;
1081
+ _currentPath?.pushKey(childKey);
1082
+ // FR5b — set the current node's YAML position (if any) before the
1083
+ // create/merge call. Save the parent's position to restore after the
1084
+ // recursion returns (children may push deeper positions during their
1085
+ // own processChildren walk).
1086
+ const savedYamlPosition = _currentYamlPosition;
1087
+ if (_currentFormat === "yaml") {
1088
+ _currentYamlPosition = getYamlPosition(childRecord, childKey);
1089
+ }
734
1090
 
735
1091
  if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
736
1092
  reportProblem(
737
1093
  `Child wrapper "${childKey}" at ${childNodePath} must contain an object`,
738
- strict, warnings, source, childNodePath, "ERR_TOP_LEVEL_NOT_OBJECT",
1094
+ strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT",
739
1095
  );
1096
+ _currentPath?.pop(); // pop child wrapper key
1097
+ _currentPath?.pop(); // pop array index
1098
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
740
1099
  continue;
741
1100
  }
742
1101
 
@@ -758,9 +1117,12 @@ function processChildren(
758
1117
  errors.push(
759
1118
  new ParseError(
760
1119
  `Unknown type "${childType}.${childSubType}" — not registered`,
761
- { ...errOpts(source, childNodePath), code: childTypeCode },
1120
+ { code: childTypeCode, source: errSource() },
762
1121
  ),
763
1122
  );
1123
+ _currentPath?.pop(); // pop child wrapper key
1124
+ _currentPath?.pop(); // pop array index
1125
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
764
1126
  continue; // skip this child
765
1127
  }
766
1128
  }
@@ -789,7 +1151,11 @@ function processChildren(
789
1151
  parent.addChild(childModel);
790
1152
  }
791
1153
  }
1154
+ _currentPath?.pop(); // pop child wrapper key
1155
+ _currentPath?.pop(); // pop array index
1156
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
792
1157
  }
1158
+ _currentPath?.pop(); // pop the "children" key
793
1159
  }
794
1160
 
795
1161
  // ---------------------------------------------------------------------------
@@ -821,7 +1187,7 @@ function parseAttrChild(
821
1187
  if (typeof attrName !== "string" || attrName === "") {
822
1188
  reportProblem(
823
1189
  `attr child at ${path} requires a non-empty "${RESERVED_KEY_NAME}" string`,
824
- strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR",
1190
+ strict, warnings, "ERR_MISSING_REQUIRED_ATTR",
825
1191
  );
826
1192
  return;
827
1193
  }
@@ -829,7 +1195,7 @@ function parseAttrChild(
829
1195
  if (attrValue === undefined) {
830
1196
  reportProblem(
831
1197
  `attr child "${attrName}" at ${path} is missing "${RESERVED_KEY_VALUE}"`,
832
- strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR",
1198
+ strict, warnings, "ERR_MISSING_REQUIRED_ATTR",
833
1199
  );
834
1200
  return;
835
1201
  }
@@ -852,7 +1218,7 @@ function parseAttrChild(
852
1218
  } catch (err) {
853
1219
  reportProblem(
854
1220
  `Failed to convert attr child "${attrName}" value at ${path}: ${(err as Error).message}`,
855
- strict, warnings, source, path, "ERR_BAD_ATTR_VALUE",
1221
+ strict, warnings, "ERR_BAD_ATTR_VALUE",
856
1222
  );
857
1223
  return;
858
1224
  }