@metaobjectsdev/metadata 0.7.0-rc.1 → 0.7.0-rc.3

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 (42) hide show
  1. package/dist/core/parser-yaml.d.ts.map +1 -1
  2. package/dist/core/parser-yaml.js +24 -8
  3. package/dist/core/parser-yaml.js.map +1 -1
  4. package/dist/core/yaml-desugar.d.ts.map +1 -1
  5. package/dist/core/yaml-desugar.js +54 -5
  6. package/dist/core/yaml-desugar.js.map +1 -1
  7. package/dist/core/yaml-positions-walker.d.ts +21 -0
  8. package/dist/core/yaml-positions-walker.d.ts.map +1 -0
  9. package/dist/core/yaml-positions-walker.js +75 -0
  10. package/dist/core/yaml-positions-walker.js.map +1 -0
  11. package/dist/core/yaml-positions.d.ts +19 -0
  12. package/dist/core/yaml-positions.d.ts.map +1 -0
  13. package/dist/core/yaml-positions.js +60 -0
  14. package/dist/core/yaml-positions.js.map +1 -0
  15. package/dist/errors.d.ts +4 -1
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +13 -0
  18. package/dist/errors.js.map +1 -1
  19. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  20. package/dist/loader/meta-data-loader.js +26 -6
  21. package/dist/loader/meta-data-loader.js.map +1 -1
  22. package/dist/loader/validation-passes.d.ts.map +1 -1
  23. package/dist/loader/validation-passes.js +81 -17
  24. package/dist/loader/validation-passes.js.map +1 -1
  25. package/dist/parser-core.d.ts +16 -1
  26. package/dist/parser-core.d.ts.map +1 -1
  27. package/dist/parser-core.js +266 -8
  28. package/dist/parser-core.js.map +1 -1
  29. package/dist/source.d.ts +29 -1
  30. package/dist/source.d.ts.map +1 -1
  31. package/dist/source.js +25 -0
  32. package/dist/source.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/core/parser-yaml.ts +24 -8
  35. package/src/core/yaml-desugar.ts +58 -4
  36. package/src/core/yaml-positions-walker.ts +101 -0
  37. package/src/core/yaml-positions.ts +80 -0
  38. package/src/errors.ts +15 -0
  39. package/src/loader/meta-data-loader.ts +26 -6
  40. package/src/loader/validation-passes.ts +83 -20
  41. package/src/parser-core.ts +306 -10
  42. package/src/source.ts +40 -2
@@ -27,11 +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 type { ErrorSource } from "./source.js";
32
+ import { resolvedSource, type ErrorSource, type LoaderWarning, type Contributor } from "./source.js";
33
+ import { semanticDiff } from "./semantic-diff.js";
33
34
  import { resolveSuperRef } from "./super-resolve.js";
34
35
  import { JsonPathBuilder } from "./json-path.js";
36
+ import { getYamlPosition, type YamlPosition } from "./core/yaml-positions.js";
35
37
  import {
36
38
  TYPE_ATTR,
37
39
  TYPE_FIELD,
@@ -82,12 +84,27 @@ export interface ParseOptions {
82
84
  * another file parsed later.
83
85
  */
84
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";
85
94
  }
86
95
 
87
96
  export interface ParseResult {
88
97
  root: MetaRoot;
89
98
  warnings: string[];
90
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[];
91
108
  }
92
109
 
93
110
  // ---------------------------------------------------------------------------
@@ -102,6 +119,20 @@ export interface ParseResult {
102
119
 
103
120
  export function errSource(): ErrorSource {
104
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
+ }
105
136
  return {
106
137
  format: "json",
107
138
  files: [_currentSourceId],
@@ -200,20 +231,49 @@ let _deferSuperResolution = false;
200
231
  // _deferSuperResolution.
201
232
  let _currentErrors: ParseError[] | undefined;
202
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
+
203
240
  // FR5a / ADR-0009 — Module-level JSONPath builder + source id, set at
204
241
  // buildTree entry and updated by recursive descent (push on the way down,
205
242
  // pop on the way back up). Used by populateNodeSource() to stamp every
206
- // constructed MetaData with `{ format: "json", files: [sourceId], jsonPath }`.
243
+ // constructed MetaData with `{ format: "json"|"yaml", files: [sourceId],
244
+ // jsonPath, [yamlPosition?] }`.
207
245
  // Safe because buildTree is synchronous — same reentrancy argument as
208
246
  // _currentErrors above.
209
247
  let _currentPath: JsonPathBuilder | undefined;
210
248
  let _currentSourceId: string | undefined;
211
-
212
- /** Set the parsed-from-JSON provenance envelope on a freshly-created node.
213
- * No-op when the parser is invoked outside buildTree's setup (defensive — the
214
- * module-level state will always be populated during a normal parse). */
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. */
215
264
  function populateNodeSource(node: MetaData): void {
216
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
+ }
217
277
  node.setSource({
218
278
  format: "json",
219
279
  files: [_currentSourceId],
@@ -235,16 +295,25 @@ function populateNodeSource(node: MetaData): void {
235
295
  export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
236
296
  const warnings: string[] = [];
237
297
  const errors: ParseError[] = [];
298
+ const envelopeWarnings: LoaderWarning[] = [];
238
299
  const strict = opts.strict ?? false;
239
300
  const source = opts.sourceName;
240
301
  _deferSuperResolution = opts.deferSuperResolution === true;
241
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;
242
307
  // FR5a — start a fresh JSONPath stack rooted at "$"; sourceId is the
243
308
  // source's id (from FileSource / InMemoryStringSource via opts.sourceName).
244
309
  // Falls back to "<unknown>" when no name was supplied (e.g. ad-hoc parseJson
245
310
  // calls from tests).
246
311
  _currentPath = new JsonPathBuilder();
247
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;
248
317
 
249
318
  try {
250
319
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -304,6 +373,11 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
304
373
  // populated with the current source's id (correct: the existing root was
305
374
  // already stamped from the file that created it).
306
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
+ }
307
381
 
308
382
  if (opts.intoRoot !== undefined) {
309
383
  // --- Merge mode: parse root's attrs/children into the existing root ---
@@ -325,7 +399,7 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
325
399
  rootKey,
326
400
  );
327
401
  _currentPath!.pop();
328
- return { root: opts.intoRoot, warnings, errors };
402
+ return { root: opts.intoRoot, warnings, errors, envelopeWarnings };
329
403
  }
330
404
 
331
405
  // --- Fresh root mode: create a new root from the JSON ---
@@ -350,12 +424,15 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
350
424
  rootKey,
351
425
  ) as MetaRoot;
352
426
  _currentPath!.pop();
353
- return { root, warnings, errors };
427
+ return { root, warnings, errors, envelopeWarnings };
354
428
  } finally {
355
429
  _deferSuperResolution = false;
356
430
  _currentErrors = undefined;
431
+ _currentEnvelopeWarnings = undefined;
357
432
  _currentPath = undefined;
358
433
  _currentSourceId = undefined;
434
+ _currentFormat = "json";
435
+ _currentYamlPosition = undefined;
359
436
  }
360
437
  }
361
438
 
@@ -446,9 +523,15 @@ function parseNodeFresh(
446
523
  if (superModel !== undefined) {
447
524
  model.setSuperResolved(superModel);
448
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.
449
529
  throw new ParseError(
450
530
  `the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`,
451
- { code: "ERR_UNRESOLVED_SUPER", source: errSource() },
531
+ {
532
+ code: "ERR_UNRESOLVED_SUPER",
533
+ source: resolvedSource(errSource(), model.fqn(), model.superRef),
534
+ },
452
535
  );
453
536
  }
454
537
  } else if (model.superRef !== undefined && accumRoot === undefined) {
@@ -493,6 +576,46 @@ function parseNodeInto(
493
576
  source: string | undefined,
494
577
  path: string,
495
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
+
496
619
  // Apply inline attrs (not reserved keys — those stay on the existing model)
497
620
  applyInlineAttrsAndUnknownKeys(target, nodeData, strict, source, path, warnings, registry);
498
621
 
@@ -502,6 +625,168 @@ function parseNodeInto(
502
625
  const effectivePkg = inheritedContextPkg;
503
626
 
504
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
+ }
505
790
  }
506
791
 
507
792
  // ---------------------------------------------------------------------------
@@ -794,6 +1079,14 @@ function processChildren(
794
1079
  const childData = childRecord[childKey];
795
1080
  const childNodePath = `${childPath}.${childKey}`;
796
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
+ }
797
1090
 
798
1091
  if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
799
1092
  reportProblem(
@@ -802,6 +1095,7 @@ function processChildren(
802
1095
  );
803
1096
  _currentPath?.pop(); // pop child wrapper key
804
1097
  _currentPath?.pop(); // pop array index
1098
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
805
1099
  continue;
806
1100
  }
807
1101
 
@@ -828,6 +1122,7 @@ function processChildren(
828
1122
  );
829
1123
  _currentPath?.pop(); // pop child wrapper key
830
1124
  _currentPath?.pop(); // pop array index
1125
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
831
1126
  continue; // skip this child
832
1127
  }
833
1128
  }
@@ -858,6 +1153,7 @@ function processChildren(
858
1153
  }
859
1154
  _currentPath?.pop(); // pop child wrapper key
860
1155
  _currentPath?.pop(); // pop array index
1156
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
861
1157
  }
862
1158
  _currentPath?.pop(); // pop the "children" key
863
1159
  }
package/src/source.ts CHANGED
@@ -7,9 +7,17 @@
7
7
  // them byte-identically.
8
8
 
9
9
  /** Discriminated union over the provenance variants a metadata node or error
10
- * can carry. See ADR-0009 §Decision for the canonical shape. */
10
+ * can carry. See ADR-0009 §Decision for the canonical shape.
11
+ *
12
+ * FR5b note (finalized 2026-05-27, all four ports): buildTree-emitted errors
13
+ * from a YAML input emit `format: "yaml"` and carry the optional
14
+ * `yamlPosition` (line/col from the desugar's position map). The
15
+ * `yamlPosition` field is also declared optionally on the `format: "json"`
16
+ * variant for backward shape-compat with any FR5b-interim envelopes still
17
+ * serialized to disk; new YAML errors no longer use that variant. */
11
18
  export type ErrorSource =
12
- | { format: "json"; files: [string]; jsonPath: string }
19
+ | { format: "json"; files: [string]; jsonPath: string;
20
+ yamlPosition?: { line: number; col: number } }
13
21
  | { format: "yaml"; files: [string]; jsonPath: string;
14
22
  yamlPosition?: { line: number; col: number } }
15
23
  | { format: "merged"; files: string[]; jsonPath: string;
@@ -59,3 +67,33 @@ export interface LoaderWarning {
59
67
  export function codeSource(caller?: string): ErrorSource {
60
68
  return caller ? { format: "code", caller } : { format: "code" };
61
69
  }
70
+
71
+ /** FR5d — build a `format: "resolved"` envelope from a referrer node's source
72
+ * envelope (typically a `format: "json"` or `format: "yaml"` parse-time
73
+ * source) plus the referrer FQN and unresolved target string.
74
+ *
75
+ * The resolved envelope carries:
76
+ * - `files`: the referrer's source files (so editors can jump to it),
77
+ * - `jsonPath`: the referrer's jsonPath when known (the location of the
78
+ * broken reference on disk),
79
+ * - `referrer`: the FQN of the metadata node that declared the broken
80
+ * reference (e.g. "myapp::content::Video"),
81
+ * - `target`: the unresolved reference string itself (e.g. "BaseEntity",
82
+ * "Program.weeks.invalid", "DoesNotExist").
83
+ *
84
+ * `referrerSource` may be any FR5a variant — we read its files/jsonPath
85
+ * best-effort and fall back to an empty `files: []` when the referrer's
86
+ * source is itself a `code`/`database` envelope. */
87
+ export function resolvedSource(
88
+ referrerSource: ErrorSource,
89
+ referrer: string,
90
+ target: string,
91
+ ): ErrorSource {
92
+ const files = "files" in referrerSource ? [...referrerSource.files] : [];
93
+ const jsonPath = "jsonPath" in referrerSource ? referrerSource.jsonPath : undefined;
94
+ const out: ErrorSource = { format: "resolved", files, referrer, target };
95
+ if (jsonPath !== undefined) {
96
+ (out as { jsonPath?: string }).jsonPath = jsonPath;
97
+ }
98
+ return out;
99
+ }