@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
@@ -25,34 +25,55 @@
25
25
  import { TypeId, TypeRegistry } from "./registry.js";
26
26
  import { MetaRoot } from "./shared/meta-root.js";
27
27
  import { MetaAttr } from "./core/attr/meta-attr.js";
28
- import { inferAttrSubType } from "./serializer-json.js";
28
+ import { canonicalSerialize, inferAttrSubType } from "./serializer-json.js";
29
29
  import { ParseError } from "./errors.js";
30
+ import { resolvedSource } from "./source.js";
31
+ import { semanticDiff } from "./semantic-diff.js";
30
32
  import { resolveSuperRef } from "./super-resolve.js";
33
+ import { JsonPathBuilder } from "./json-path.js";
34
+ import { getYamlPosition } from "./core/yaml-positions.js";
31
35
  import { TYPE_ATTR, TYPE_FIELD, TYPE_OBJECT, TYPE_VALIDATOR, SUBTYPE_BASE, } from "./shared/base-types.js";
32
36
  import { RESERVED_KEYS, RESERVED_KEY_NAME, RESERVED_KEY_PACKAGE, RESERVED_KEY_EXTENDS, RESERVED_KEY_ABSTRACT, RESERVED_KEY_OVERLAY, RESERVED_KEY_IS_ARRAY, RESERVED_KEY_CHILDREN, RESERVED_KEY_VALUE, JSON_KEY_SCHEMA, ATTR_PREFIX, TYPE_SUBTYPE_SEPARATOR, PACKAGE_SEPARATOR, } from "./shared/structural.js";
33
37
  import { ATTR_SUBTYPE_PROPERTIES } from "./core/attr/attr-constants.js";
34
38
  // ---------------------------------------------------------------------------
35
- // Internal helper — build ParseError opts, omitting undefined fields
36
- // (required by exactOptionalPropertyTypes: true in the project tsconfig)
39
+ // Internal helper — build a parse-phase ErrorSource envelope.
40
+ //
41
+ // FR5a / ADR-0009: ParseError carries a `source: ErrorSource` envelope. For
42
+ // errors raised mid-parse, the envelope's jsonPath comes from the parser's
43
+ // module-level JsonPathBuilder (synced with the walk); files[0] is the parsed
44
+ // source id. Falls back to a code-source envelope if invoked outside a buildTree
45
+ // run (defensive — every callsite below runs inside buildTree).
37
46
  // ---------------------------------------------------------------------------
38
- export function errOpts(source, path) {
39
- const opts = {};
40
- if (source !== undefined)
41
- opts.source = source;
42
- if (path !== undefined)
43
- opts.path = path;
44
- return opts;
47
+ export function errSource() {
48
+ if (_currentPath !== undefined && _currentSourceId !== undefined) {
49
+ // FR5b (finalized 2026-05-27, all four ports) — buildTree-emitted errors
50
+ // from a YAML input now emit `format: "yaml"` (was `"json"` interim while
51
+ // each port shipped yamlPosition tracking). The optional `yamlPosition`
52
+ // rides along when the desugar's position map covers the current node.
53
+ if (_currentFormat === "yaml") {
54
+ return {
55
+ format: "yaml",
56
+ files: [_currentSourceId],
57
+ jsonPath: _currentPath.toString(),
58
+ ...(_currentYamlPosition !== undefined
59
+ ? { yamlPosition: _currentYamlPosition }
60
+ : {}),
61
+ };
62
+ }
63
+ return {
64
+ format: "json",
65
+ files: [_currentSourceId],
66
+ jsonPath: _currentPath.toString(),
67
+ };
68
+ }
69
+ return { format: "code", caller: "parser-core" };
45
70
  }
46
71
  // ---------------------------------------------------------------------------
47
72
  // Internal helper — report a problem: throw in strict mode, push warning otherwise
48
73
  // ---------------------------------------------------------------------------
49
- function reportProblem(msg, strict, warnings, source, path, code) {
74
+ function reportProblem(msg, strict, warnings, code) {
50
75
  if (strict) {
51
- const opts = errOpts(source, path);
52
- if (code !== undefined)
53
- throw new ParseError(msg, { ...opts, code });
54
- else
55
- throw new ParseError(msg, opts);
76
+ throw new ParseError(msg, { code, source: errSource() });
56
77
  }
57
78
  warnings.push(msg);
58
79
  }
@@ -107,6 +128,54 @@ let _deferSuperResolution = false;
107
128
  // Safe because buildTree is synchronous — same reentrancy argument as
108
129
  // _deferSuperResolution.
109
130
  let _currentErrors;
131
+ // FR5c — envelope-warnings sink for the merge phase. Set at buildTree entry
132
+ // so deeply-nested merge sites can emit WARN_DUPLICATE_DECLARATION without
133
+ // threading another parameter through every helper signature. Safe under
134
+ // the same synchronous-buildTree reentrancy argument as the others.
135
+ let _currentEnvelopeWarnings;
136
+ // FR5a / ADR-0009 — Module-level JSONPath builder + source id, set at
137
+ // buildTree entry and updated by recursive descent (push on the way down,
138
+ // pop on the way back up). Used by populateNodeSource() to stamp every
139
+ // constructed MetaData with `{ format: "json"|"yaml", files: [sourceId],
140
+ // jsonPath, [yamlPosition?] }`.
141
+ // Safe because buildTree is synchronous — same reentrancy argument as
142
+ // _currentErrors above.
143
+ let _currentPath;
144
+ let _currentSourceId;
145
+ // FR5b — source format discriminant + current node's YAML position, set
146
+ // by the per-child iteration in processChildren (and at root) right before
147
+ // the parseNodeFresh call. The position is read from the wrapper object's
148
+ // position-by-key map (attached by the YAML walker in core/yaml-positions.ts
149
+ // and preserved through core/yaml-desugar.ts).
150
+ let _currentFormat = "json";
151
+ let _currentYamlPosition;
152
+ /** FR5a/FR5b — stamp the source-provenance envelope on a freshly-created
153
+ * node. No-op when invoked outside buildTree's setup (defensive — the
154
+ * module-level state will always be populated during a normal parse).
155
+ *
156
+ * FR5b (finalized 2026-05-27) — YAML-input nodes get `format: "yaml"`
157
+ * envelopes (was `"json"` interim). The optional `yamlPosition` rides
158
+ * along when the desugar's position map covers the current node. */
159
+ function populateNodeSource(node) {
160
+ if (_currentPath === undefined || _currentSourceId === undefined)
161
+ return;
162
+ if (_currentFormat === "yaml") {
163
+ node.setSource({
164
+ format: "yaml",
165
+ files: [_currentSourceId],
166
+ jsonPath: _currentPath.toString(),
167
+ ...(_currentYamlPosition !== undefined
168
+ ? { yamlPosition: _currentYamlPosition }
169
+ : {}),
170
+ });
171
+ return;
172
+ }
173
+ node.setSource({
174
+ format: "json",
175
+ files: [_currentSourceId],
176
+ jsonPath: _currentPath.toString(),
177
+ });
178
+ }
110
179
  /**
111
180
  * buildTree — the shared registry-driven tree-builder.
112
181
  *
@@ -121,27 +190,47 @@ let _currentErrors;
121
190
  export function buildTree(parsed, opts) {
122
191
  const warnings = [];
123
192
  const errors = [];
193
+ const envelopeWarnings = [];
124
194
  const strict = opts.strict ?? false;
125
195
  const source = opts.sourceName;
126
196
  _deferSuperResolution = opts.deferSuperResolution === true;
127
197
  _currentErrors = errors;
198
+ // FR5c — module-level handle so the deeply-nested merge code paths can
199
+ // emit envelope warnings without threading another parameter through the
200
+ // entire walk. Safe because buildTree is fully synchronous.
201
+ _currentEnvelopeWarnings = envelopeWarnings;
202
+ // FR5a — start a fresh JSONPath stack rooted at "$"; sourceId is the
203
+ // source's id (from FileSource / InMemoryStringSource via opts.sourceName).
204
+ // Falls back to "<unknown>" when no name was supplied (e.g. ad-hoc parseJson
205
+ // calls from tests).
206
+ _currentPath = new JsonPathBuilder();
207
+ _currentSourceId = source ?? "<unknown>";
208
+ // FR5b — propagate the per-parse source-format discriminant. parseJson
209
+ // omits the option (defaults to "json"); parseYaml supplies "yaml".
210
+ _currentFormat = opts.sourceFormat ?? "json";
211
+ _currentYamlPosition = undefined;
128
212
  try {
129
213
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
130
- throw new ParseError("Top-level metadata must be an object", { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
214
+ throw new ParseError("Top-level metadata must be an object", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
131
215
  }
132
216
  const topLevel = parsed;
133
217
  // --- Find the wrapper key (skip $schema) ---
134
218
  const wrapperKeys = Object.keys(topLevel).filter((k) => k !== JSON_KEY_SCHEMA);
135
219
  if (wrapperKeys.length === 0) {
136
- throw new ParseError("Top-level metadata object has no type wrapper key", { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
220
+ throw new ParseError("Top-level metadata object has no type wrapper key", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
137
221
  }
138
222
  if (wrapperKeys.length > 1) {
139
- throw new ParseError(`Top-level metadata object must have exactly one wrapper key (found: ${wrapperKeys.join(", ")})`, { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
223
+ throw new ParseError(`Top-level metadata object must have exactly one wrapper key (found: ${wrapperKeys.join(", ")})`, { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
140
224
  }
141
225
  const rootKey = wrapperKeys[0];
142
226
  const rootData = topLevel[rootKey];
143
227
  if (typeof rootData !== "object" || rootData === null || Array.isArray(rootData)) {
144
- throw new ParseError(`Top-level wrapper "${rootKey}" must contain an object`, { ...errOpts(source, rootKey), code: "ERR_TOP_LEVEL_NOT_OBJECT" });
228
+ // The error context references the rootKey wrapper; push it so the
229
+ // envelope's jsonPath includes it (matches the legacy `path` slot).
230
+ _currentPath.pushKey(rootKey);
231
+ const src = errSource();
232
+ _currentPath.pop();
233
+ throw new ParseError(`Top-level wrapper "${rootKey}" must contain an object`, { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: src });
145
234
  }
146
235
  const rootDataObj = rootData;
147
236
  const { type: rootType, subType: rootSubType } = splitTypeKey(rootKey, opts.registry);
@@ -150,7 +239,21 @@ export function buildTree(parsed, opts) {
150
239
  const rootTypeCode = opts.registry.allSubTypesOf(rootType).length > 0
151
240
  ? "ERR_UNKNOWN_SUBTYPE"
152
241
  : "ERR_UNKNOWN_TYPE";
153
- throw new ParseError(`Unknown root type "${rootType}.${rootSubType}" — not registered`, { ...errOpts(source, rootKey), code: rootTypeCode });
242
+ _currentPath.pushKey(rootKey);
243
+ const src = errSource();
244
+ _currentPath.pop();
245
+ throw new ParseError(`Unknown root type "${rootType}.${rootSubType}" — not registered`, { code: rootTypeCode, source: src });
246
+ }
247
+ // FR5a — push the wrapper-key segment onto the JSONPath stack so all
248
+ // descendants emit jsonPath strings rooted at "$.<rootKey>". The merge-mode
249
+ // path keeps the existing root's source untouched; only NEW children get
250
+ // populated with the current source's id (correct: the existing root was
251
+ // already stamped from the file that created it).
252
+ _currentPath.pushKey(rootKey);
253
+ // FR5b — look up the root wrapper's YAML position (when in yaml mode)
254
+ // so parseNodeFresh stamps `source.yamlPosition` on the root node.
255
+ if (_currentFormat === "yaml") {
256
+ _currentYamlPosition = getYamlPosition(topLevel, rootKey);
154
257
  }
155
258
  if (opts.intoRoot !== undefined) {
156
259
  // --- Merge mode: parse root's attrs/children into the existing root ---
@@ -161,7 +264,8 @@ export function buildTree(parsed, opts) {
161
264
  const contextPkg = (typeof newRootPkg === "string" ? newRootPkg : opts.intoRoot.package) ?? "";
162
265
  parseNodeInto(rootDataObj, opts.intoRoot, opts.intoRoot, // accumulating root for super resolution
163
266
  contextPkg, opts.registry, warnings, errors, strict, source, rootKey);
164
- return { root: opts.intoRoot, warnings, errors };
267
+ _currentPath.pop();
268
+ return { root: opts.intoRoot, warnings, errors, envelopeWarnings };
165
269
  }
166
270
  // --- Fresh root mode: create a new root from the JSON ---
167
271
  // The cast is safe within the core provider: `metadata.root` is the only
@@ -174,11 +278,17 @@ export function buildTree(parsed, opts) {
174
278
  const root = parseNodeFresh(rootType, rootSubType, rootDataObj, undefined, // no accumulating root yet — built as we go
175
279
  "", // no inherited context pkg yet for the root itself
176
280
  opts.registry, warnings, errors, strict, source, rootKey);
177
- return { root, warnings, errors };
281
+ _currentPath.pop();
282
+ return { root, warnings, errors, envelopeWarnings };
178
283
  }
179
284
  finally {
180
285
  _deferSuperResolution = false;
181
286
  _currentErrors = undefined;
287
+ _currentEnvelopeWarnings = undefined;
288
+ _currentPath = undefined;
289
+ _currentSourceId = undefined;
290
+ _currentFormat = "json";
291
+ _currentYamlPosition = undefined;
182
292
  }
183
293
  }
184
294
  // ---------------------------------------------------------------------------
@@ -200,10 +310,12 @@ parent) {
200
310
  }
201
311
  else {
202
312
  const msg = `Unknown type "${type}.${subType}" — not registered`;
203
- errors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_UNKNOWN_TYPE" }));
313
+ errors.push(new ParseError(msg, { code: "ERR_UNKNOWN_TYPE", source: errSource() }));
204
314
  const rawName = nodeData[RESERVED_KEY_NAME];
205
315
  const name = typeof rawName === "string" ? rawName : "";
206
- return new MetaRoot(new TypeId(type, subType), name);
316
+ const stub = new MetaRoot(new TypeId(type, subType), name);
317
+ populateNodeSource(stub);
318
+ return stub;
207
319
  }
208
320
  }
209
321
  // --- Determine name ---
@@ -212,6 +324,10 @@ parent) {
212
324
  // --- Create the model ---
213
325
  const def = registry.find(type, subType);
214
326
  const model = def.factory(def.typeId, name);
327
+ // FR5a — stamp the source provenance envelope using the parser's current
328
+ // JSONPath stack + source id. setSource happens BEFORE freeze (the parser
329
+ // is the only caller during loading; freeze runs in the loader after).
330
+ populateNodeSource(model);
215
331
  // --- Apply reserved keys (package, extends, abstract, isArray) ---
216
332
  applyReservedKeys(model, nodeData, strict, source, path, warnings, inheritedContextPkg);
217
333
  // --- Inherit package from context if not explicitly set ---
@@ -243,12 +359,18 @@ parent) {
243
359
  model.setSuperResolved(superModel);
244
360
  }
245
361
  else {
246
- throw new ParseError(`the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`, { ...errOpts(source, path), code: "ERR_UNRESOLVED_SUPER" });
362
+ // FR5d emit format=resolved with referrer + target. referrer is the
363
+ // declaring node's FQN (we just built it above); target is the
364
+ // unresolved supertype ref string.
365
+ throw new ParseError(`the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`, {
366
+ code: "ERR_UNRESOLVED_SUPER",
367
+ source: resolvedSource(errSource(), model.fqn(), model.superRef),
368
+ });
247
369
  }
248
370
  }
249
371
  else if (model.superRef !== undefined && accumRoot === undefined) {
250
372
  // Root node has a super ref — not resolvable against itself.
251
- reportProblem(`super on root node ('${model.superRef}') is not supported and will be ignored`, strict, warnings, source, path, "ERR_UNRESOLVED_SUPER");
373
+ reportProblem(`super on root node ('${model.superRef}') is not supported and will be ignored`, strict, warnings, "ERR_UNRESOLVED_SUPER");
252
374
  }
253
375
  // --- Process inline attributes and other keys ---
254
376
  applyInlineAttrsAndUnknownKeys(model, nodeData, strict, source, path, warnings, registry);
@@ -267,6 +389,36 @@ parent) {
267
389
  // extends, package) — those belong to the original model's identity.
268
390
  // ---------------------------------------------------------------------------
269
391
  function parseNodeInto(nodeData, target, accumRoot, inheritedContextPkg, registry, warnings, errors, strict, source, path) {
392
+ // FR5c — capture pre-merge state so we can decide whether this contribution
393
+ // produced any semantic change. The "new contributor" is the file currently
394
+ // being parsed (_currentSourceId). The "base contributor" is whichever
395
+ // file(s) the existing target already records on its source envelope.
396
+ const newContributorFile = _currentSourceId ?? "<unknown>";
397
+ const targetIsRoot = target instanceof MetaRoot;
398
+ // The root is a synthetic accumulator: it is not a metadata-author node —
399
+ // every file declares { "metadata.root": ... } and the loader merges into
400
+ // a single root. We don't run FR5c diagnostics on the root itself; the
401
+ // merge attribution applies to author-meaningful nodes (object/field/etc).
402
+ const fr5cActive = !targetIsRoot && target.name !== "";
403
+ let preMergeShape;
404
+ let preMergeAttrSnapshot;
405
+ if (fr5cActive) {
406
+ preMergeShape = canonicalSerialize(target);
407
+ // Snapshot of own attrs (name → value) — used to detect ERR_MERGE_CONFLICT
408
+ // when a new contribution sets an attr the target already declared to a
409
+ // different non-empty value.
410
+ preMergeAttrSnapshot = new Map(target.ownAttrs());
411
+ }
412
+ // FR5c — detect attribute-level merge conflicts: for each @-prefixed inline
413
+ // attr in nodeData, if the target already has an own attr of that name
414
+ // with a non-empty value, AND the new value differs from the existing
415
+ // value (both sides non-empty), emit ERR_MERGE_CONFLICT with a `format:
416
+ // "merged"` envelope. Last-writer-wins is preserved for non-conflicting
417
+ // cases (one side unset, same value, etc.) — those carry through to the
418
+ // existing applyInlineAttrsAndUnknownKeys logic below.
419
+ if (fr5cActive && preMergeAttrSnapshot !== undefined) {
420
+ detectAttrMergeConflicts(target, nodeData, preMergeAttrSnapshot, newContributorFile, errors, path);
421
+ }
270
422
  // Apply inline attrs (not reserved keys — those stay on the existing model)
271
423
  applyInlineAttrsAndUnknownKeys(target, nodeData, strict, source, path, warnings, registry);
272
424
  // The effective package for children: use inheritedContextPkg (from the new
@@ -274,6 +426,155 @@ function parseNodeInto(nodeData, target, accumRoot, inheritedContextPkg, registr
274
426
  // being merged into, and new children inherit from the NEW context.
275
427
  const effectivePkg = inheritedContextPkg;
276
428
  processChildren(target, nodeData, accumRoot, effectivePkg, registry, warnings, errors, strict, source, path);
429
+ // FR5c — after merge: compare pre/post shape. If no semantic change → emit
430
+ // WARN_DUPLICATE_DECLARATION (the new contribution declared the same thing
431
+ // the target already had). If there was a change → upgrade the target's
432
+ // source envelope to `format: "merged"` with both contributors listed
433
+ // (ADR-0009 §Source-on-node: overlay merge with semantic change updates
434
+ // source; duplicate-with-no-change leaves source unchanged + emits warning).
435
+ if (fr5cActive && preMergeShape !== undefined) {
436
+ const postMergeShape = canonicalSerialize(target);
437
+ const preParsed = JSON.parse(preMergeShape);
438
+ const postParsed = JSON.parse(postMergeShape);
439
+ const changed = semanticDiff(preParsed, postParsed);
440
+ // Pull the existing contributor file(s) off the target's current source
441
+ // envelope (set at parse-fresh time or by a previous merge).
442
+ const existing = target.source;
443
+ const existingFiles = "files" in existing ? [...existing.files] : [];
444
+ if (changed) {
445
+ // Real overlay — upgrade source to merged with contributors.
446
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
447
+ // Alphabetical sort — matches DirectorySource ordering and gives a
448
+ // deterministic cross-port file list (ADR-0009 §"Cross-port adoption").
449
+ allFiles.sort();
450
+ const contributors = allFiles.map((f, i) => ({
451
+ file: f,
452
+ role: i === 0 ? "overlay-base" : "overlay-extension",
453
+ }));
454
+ const mergedSource = {
455
+ format: "merged",
456
+ files: allFiles,
457
+ jsonPath: "jsonPath" in existing && existing.jsonPath !== undefined
458
+ ? existing.jsonPath
459
+ : (_currentPath?.toString() ?? "$"),
460
+ contributors,
461
+ };
462
+ target.setSource(mergedSource);
463
+ }
464
+ else if (existingFiles.length > 0 &&
465
+ !existingFiles.includes(newContributorFile)) {
466
+ // Identical re-declaration from a different file → warn.
467
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
468
+ allFiles.sort();
469
+ const contributors = allFiles.map((f, i) => ({
470
+ file: f,
471
+ role: i === 0 ? "overlay-base" : "overlay-extension",
472
+ }));
473
+ const warnSource = {
474
+ format: "merged",
475
+ files: allFiles,
476
+ jsonPath: "jsonPath" in existing && existing.jsonPath !== undefined
477
+ ? existing.jsonPath
478
+ : (_currentPath?.toString() ?? "$"),
479
+ contributors,
480
+ };
481
+ _currentEnvelopeWarnings?.push({
482
+ code: "WARN_DUPLICATE_DECLARATION",
483
+ message: `duplicate declaration of ${target.fqn()} with no semantic change`,
484
+ source: warnSource,
485
+ });
486
+ }
487
+ }
488
+ }
489
+ /** FR5c — for every @-prefixed inline attr in `nodeData`, check whether the
490
+ * target already declares the same attr (in `preAttrs`) with a different
491
+ * non-empty value. If so, emit ERR_MERGE_CONFLICT with a `format: "merged"`
492
+ * envelope naming both contributors. The merge itself proceeds (existing
493
+ * last-writer-wins) so the loader sees one canonical tree; the error
494
+ * surfaces the conflict so a consumer can fix the metadata. */
495
+ function detectAttrMergeConflicts(target, nodeData, preAttrs, newContributorFile, errors, path) {
496
+ const existingFiles = "files" in target.source ? [...target.source.files] : [];
497
+ function isEmptyValue(v) {
498
+ if (v === undefined || v === null)
499
+ return true;
500
+ if (typeof v === "string" && v === "")
501
+ return true;
502
+ if (Array.isArray(v) && v.length === 0)
503
+ return true;
504
+ return false;
505
+ }
506
+ function attrValuesEqual(a, b) {
507
+ // String/number/boolean — direct compare.
508
+ if (a === b)
509
+ return true;
510
+ if (Array.isArray(a) && Array.isArray(b)) {
511
+ if (a.length !== b.length)
512
+ return false;
513
+ for (let i = 0; i < a.length; i++) {
514
+ if (a[i] !== b[i])
515
+ return false;
516
+ }
517
+ return true;
518
+ }
519
+ if (typeof a === "object" && a !== null && !Array.isArray(a) &&
520
+ typeof b === "object" && b !== null && !Array.isArray(b)) {
521
+ // Object attrs — structural compare via JSON (key order independent
522
+ // through Object.keys().sort()).
523
+ try {
524
+ const ak = Object.keys(a).sort();
525
+ const bk = Object.keys(b).sort();
526
+ if (ak.length !== bk.length)
527
+ return false;
528
+ for (let i = 0; i < ak.length; i++) {
529
+ if (ak[i] !== bk[i])
530
+ return false;
531
+ if (JSON.stringify(a[ak[i]]) !==
532
+ JSON.stringify(b[bk[i]])) {
533
+ return false;
534
+ }
535
+ }
536
+ return true;
537
+ }
538
+ catch {
539
+ return false;
540
+ }
541
+ }
542
+ return false;
543
+ }
544
+ for (const key of Object.keys(nodeData)) {
545
+ if (!key.startsWith(ATTR_PREFIX))
546
+ continue;
547
+ if (RESERVED_KEYS.has(key))
548
+ continue;
549
+ const attrName = key.slice(ATTR_PREFIX.length);
550
+ if (RESERVED_KEYS.has(attrName))
551
+ continue;
552
+ const newValRaw = nodeData[key];
553
+ const existingVal = preAttrs.get(attrName);
554
+ if (existingVal === undefined)
555
+ continue;
556
+ if (isEmptyValue(newValRaw) || isEmptyValue(existingVal))
557
+ continue;
558
+ // Both sides set a non-empty value — compare. If equal, last-writer-wins
559
+ // is a no-op; if different, that's a conflict.
560
+ if (attrValuesEqual(existingVal, newValRaw))
561
+ continue;
562
+ const allFiles = [...new Set([...existingFiles, newContributorFile])];
563
+ allFiles.sort();
564
+ const contributors = allFiles.map((f, i) => ({
565
+ file: f,
566
+ role: i === 0 ? "overlay-base" : "overlay-extension",
567
+ }));
568
+ const conflictSource = {
569
+ format: "merged",
570
+ files: allFiles,
571
+ jsonPath: `${_currentPath?.toString() ?? path}.${ATTR_PREFIX}${attrName}`,
572
+ contributors,
573
+ };
574
+ errors.push(new ParseError(`attr '${ATTR_PREFIX}${attrName}' conflicts: existing value ` +
575
+ `${JSON.stringify(existingVal)} differs from new value ` +
576
+ `${JSON.stringify(newValRaw)} on ${target.fqn()}`, { code: "ERR_MERGE_CONFLICT", source: conflictSource }));
577
+ }
277
578
  }
278
579
  // ---------------------------------------------------------------------------
279
580
  // createOrFindMetaData — per-node merge logic.
@@ -293,7 +594,7 @@ function createOrFindMetaData(type, subType, nodeData, parent, accumRoot, inheri
293
594
  const existing = name !== "" ? parent.ownChildByTypeAndName(type, name) : undefined;
294
595
  if (isOverlayNode) {
295
596
  if (existing === undefined) {
296
- throw new ParseError(`Overlay operation requested for [${type}:${name}] but no existing metadata found to merge into`, { ...errOpts(source, path), code: "ERR_OVERLAY_NO_TARGET" });
597
+ throw new ParseError(`Overlay operation requested for [${type}:${name}] but no existing metadata found to merge into`, { code: "ERR_OVERLAY_NO_TARGET", source: errSource() });
297
598
  }
298
599
  existing.setIsMerge(true);
299
600
  parseNodeInto(nodeData, existing, accumRoot, inheritedContextPkg, registry, warnings, errors, strict, source, path);
@@ -317,7 +618,7 @@ function applyReservedKeys(model, nodeData, strict, source, path, warnings, cont
317
618
  const rawPkg = nodeData[RESERVED_KEY_PACKAGE];
318
619
  if (rawPkg !== undefined) {
319
620
  if (typeof rawPkg !== "string") {
320
- reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
621
+ reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
321
622
  }
322
623
  else {
323
624
  const expandedPkg = contextPkg !== undefined ? expandPackageForPath(contextPkg, rawPkg) : rawPkg;
@@ -328,7 +629,7 @@ function applyReservedKeys(model, nodeData, strict, source, path, warnings, cont
328
629
  const rawExtends = nodeData[RESERVED_KEY_EXTENDS];
329
630
  if (rawExtends !== undefined) {
330
631
  if (typeof rawExtends !== "string") {
331
- reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, source, path, "ERR_UNRESOLVED_SUPER");
632
+ reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, "ERR_UNRESOLVED_SUPER");
332
633
  }
333
634
  else {
334
635
  model.setSuper(rawExtends);
@@ -338,7 +639,7 @@ function applyReservedKeys(model, nodeData, strict, source, path, warnings, cont
338
639
  const rawAbstract = nodeData[RESERVED_KEY_ABSTRACT];
339
640
  if (rawAbstract !== undefined) {
340
641
  if (typeof rawAbstract !== "boolean") {
341
- reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
642
+ reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
342
643
  }
343
644
  else {
344
645
  model.setIsAbstract(rawAbstract);
@@ -348,7 +649,7 @@ function applyReservedKeys(model, nodeData, strict, source, path, warnings, cont
348
649
  const rawIsArray = nodeData[RESERVED_KEY_IS_ARRAY];
349
650
  if (rawIsArray !== undefined) {
350
651
  if (typeof rawIsArray !== "boolean") {
351
- reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
652
+ reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
352
653
  }
353
654
  else {
354
655
  model.setIsArray(rawIsArray);
@@ -368,7 +669,7 @@ function applyInlineAttrsAndUnknownKeys(model, nodeData, strict, source, path, w
368
669
  continue;
369
670
  if (!key.startsWith(ATTR_PREFIX)) {
370
671
  const displayName = model.name !== "" ? `${model.type}.${model.subType} '${model.name}'` : `${model.type}.${model.subType}`;
371
- reportProblem(`Unknown key '${key}' on ${displayName} at ${path} (must be reserved or ${ATTR_PREFIX}-prefixed)`, strict, warnings, source, path, "ERR_UNKNOWN_ATTR");
672
+ reportProblem(`Unknown key '${key}' on ${displayName} at ${path} (must be reserved or ${ATTR_PREFIX}-prefixed)`, strict, warnings, "ERR_UNKNOWN_ATTR");
372
673
  continue;
373
674
  }
374
675
  // Inline attribute (@-prefixed) — materialize into a MetaAttr instance.
@@ -384,14 +685,14 @@ function applyInlineAttrsAndUnknownKeys(model, nodeData, strict, source, path, w
384
685
  const msg = `Reserved structural key '${attrName}' must not be ${ATTR_PREFIX}-prefixed ` +
385
686
  `on ${displayName} at ${path} (write it bare)`;
386
687
  if (strict) {
387
- throw new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" });
688
+ throw new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() });
388
689
  }
389
690
  // Lax mode: route through the module-level errors sink so the loader
390
691
  // sees this as a hard error (parity with attr-schema-validate's
391
692
  // ERR_BAD_ATTR_VALUE direct pushes). Falls back to warnings only if
392
693
  // _currentErrors isn't bound (unreachable when called from buildTree).
393
694
  if (_currentErrors !== undefined) {
394
- _currentErrors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" }));
695
+ _currentErrors.push(new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() }));
395
696
  }
396
697
  else {
397
698
  warnings.push(msg);
@@ -404,7 +705,7 @@ function applyInlineAttrsAndUnknownKeys(model, nodeData, strict, source, path, w
404
705
  model.setMetaAttr(attr);
405
706
  }
406
707
  catch (err) {
407
- reportProblem(`Failed to convert attribute "${ATTR_PREFIX}${attrName}" at ${path}: ${err.message}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
708
+ reportProblem(`Failed to convert attribute "${ATTR_PREFIX}${attrName}" at ${path}: ${err.message}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
408
709
  }
409
710
  }
410
711
  }
@@ -457,14 +758,20 @@ function processChildren(parent, nodeData, accumRoot, inheritedContextPkg, regis
457
758
  if (rawChildren === undefined)
458
759
  return;
459
760
  if (!Array.isArray(rawChildren)) {
460
- reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, source, path, "ERR_TOP_LEVEL_NOT_OBJECT");
761
+ reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
461
762
  return;
462
763
  }
764
+ // FR5a — push the "children" segment once; each iteration pushes/pops a
765
+ // numeric index + the wrapper-key segment so nested nodes see the correct
766
+ // jsonPath when populateNodeSource() is called during their construction.
767
+ _currentPath?.pushKey(RESERVED_KEY_CHILDREN);
463
768
  for (let i = 0; i < rawChildren.length; i++) {
464
769
  const childEntry = rawChildren[i];
465
770
  const childPath = `${path}.${RESERVED_KEY_CHILDREN}[${i}]`;
771
+ _currentPath?.pushIndex(i);
466
772
  if (typeof childEntry !== "object" || childEntry === null || Array.isArray(childEntry)) {
467
- reportProblem(`Child at ${childPath} must be an object`, strict, warnings, source, childPath, "ERR_TOP_LEVEL_NOT_OBJECT");
773
+ reportProblem(`Child at ${childPath} must be an object`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
774
+ _currentPath?.pop();
468
775
  continue;
469
776
  }
470
777
  const childRecord = childEntry;
@@ -473,14 +780,27 @@ function processChildren(parent, nodeData, accumRoot, inheritedContextPkg, regis
473
780
  const msg = childKeys.length === 0
474
781
  ? `Child at ${childPath} has no type wrapper key`
475
782
  : `Child at ${childPath} has multiple keys (${childKeys.join(", ")}) — each child must have exactly one wrapper key`;
476
- reportProblem(msg, strict, warnings, source, childPath, "ERR_TOP_LEVEL_NOT_OBJECT");
783
+ reportProblem(msg, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
784
+ _currentPath?.pop();
477
785
  continue;
478
786
  }
479
787
  const childKey = childKeys[0];
480
788
  const childData = childRecord[childKey];
481
789
  const childNodePath = `${childPath}.${childKey}`;
790
+ _currentPath?.pushKey(childKey);
791
+ // FR5b — set the current node's YAML position (if any) before the
792
+ // create/merge call. Save the parent's position to restore after the
793
+ // recursion returns (children may push deeper positions during their
794
+ // own processChildren walk).
795
+ const savedYamlPosition = _currentYamlPosition;
796
+ if (_currentFormat === "yaml") {
797
+ _currentYamlPosition = getYamlPosition(childRecord, childKey);
798
+ }
482
799
  if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
483
- reportProblem(`Child wrapper "${childKey}" at ${childNodePath} must contain an object`, strict, warnings, source, childNodePath, "ERR_TOP_LEVEL_NOT_OBJECT");
800
+ reportProblem(`Child wrapper "${childKey}" at ${childNodePath} must contain an object`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
801
+ _currentPath?.pop(); // pop child wrapper key
802
+ _currentPath?.pop(); // pop array index
803
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
484
804
  continue;
485
805
  }
486
806
  const childDataObj = childData;
@@ -498,7 +818,10 @@ function processChildren(parent, nodeData, accumRoot, inheritedContextPkg, regis
498
818
  const childTypeCode = explicit && registry.allSubTypesOf(childType).length > 0
499
819
  ? "ERR_UNKNOWN_SUBTYPE"
500
820
  : "ERR_UNKNOWN_TYPE";
501
- errors.push(new ParseError(`Unknown type "${childType}.${childSubType}" — not registered`, { ...errOpts(source, childNodePath), code: childTypeCode }));
821
+ errors.push(new ParseError(`Unknown type "${childType}.${childSubType}" — not registered`, { code: childTypeCode, source: errSource() }));
822
+ _currentPath?.pop(); // pop child wrapper key
823
+ _currentPath?.pop(); // pop array index
824
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
502
825
  continue; // skip this child
503
826
  }
504
827
  }
@@ -513,7 +836,11 @@ function processChildren(parent, nodeData, accumRoot, inheritedContextPkg, regis
513
836
  parent.addChild(childModel);
514
837
  }
515
838
  }
839
+ _currentPath?.pop(); // pop child wrapper key
840
+ _currentPath?.pop(); // pop array index
841
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
516
842
  }
843
+ _currentPath?.pop(); // pop the "children" key
517
844
  }
518
845
  // ---------------------------------------------------------------------------
519
846
  // Attr child node — materialize into a MetaAttr instance (NOT a child).
@@ -530,11 +857,11 @@ function parseAttrChild(parent, attrType, attrSubType, attrData, registry, warni
530
857
  const attrName = attrData[RESERVED_KEY_NAME];
531
858
  const attrValue = attrData[RESERVED_KEY_VALUE];
532
859
  if (typeof attrName !== "string" || attrName === "") {
533
- reportProblem(`attr child at ${path} requires a non-empty "${RESERVED_KEY_NAME}" string`, strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR");
860
+ reportProblem(`attr child at ${path} requires a non-empty "${RESERVED_KEY_NAME}" string`, strict, warnings, "ERR_MISSING_REQUIRED_ATTR");
534
861
  return;
535
862
  }
536
863
  if (attrValue === undefined) {
537
- reportProblem(`attr child "${attrName}" at ${path} is missing "${RESERVED_KEY_VALUE}"`, strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR");
864
+ reportProblem(`attr child "${attrName}" at ${path} is missing "${RESERVED_KEY_VALUE}"`, strict, warnings, "ERR_MISSING_REQUIRED_ATTR");
538
865
  return;
539
866
  }
540
867
  // Resolve the attr node's own subtype (fall back to base if unregistered).
@@ -551,7 +878,7 @@ function parseAttrChild(parent, attrType, attrSubType, attrData, registry, warni
551
878
  node.setAttr(RESERVED_KEY_VALUE, desugared);
552
879
  }
553
880
  catch (err) {
554
- reportProblem(`Failed to convert attr child "${attrName}" value at ${path}: ${err.message}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
881
+ reportProblem(`Failed to convert attr child "${attrName}" value at ${path}: ${err.message}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
555
882
  return;
556
883
  }
557
884
  parent.setMetaAttr(node);