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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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 +13 -4
  14. package/dist/core/parser-yaml.js.map +1 -1
  15. package/dist/errors.d.ts +28 -8
  16. package/dist/errors.d.ts.map +1 -1
  17. package/dist/errors.js +31 -5
  18. package/dist/errors.js.map +1 -1
  19. package/dist/index.d.ts +6 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +9 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/json-path.d.ts +8 -0
  24. package/dist/json-path.d.ts.map +1 -0
  25. package/dist/json-path.js +39 -0
  26. package/dist/json-path.js.map +1 -0
  27. package/dist/loader/meta-data-loader.d.ts +47 -6
  28. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  29. package/dist/loader/meta-data-loader.js +106 -8
  30. package/dist/loader/meta-data-loader.js.map +1 -1
  31. package/dist/loader/meta-data-source.d.ts +6 -2
  32. package/dist/loader/meta-data-source.d.ts.map +1 -1
  33. package/dist/loader/meta-data-source.js +10 -6
  34. package/dist/loader/meta-data-source.js.map +1 -1
  35. package/dist/loader/shortcuts.d.ts +9 -0
  36. package/dist/loader/shortcuts.d.ts.map +1 -0
  37. package/dist/loader/shortcuts.js +19 -0
  38. package/dist/loader/shortcuts.js.map +1 -0
  39. package/dist/loader/sources/directory-source.d.ts +15 -0
  40. package/dist/loader/sources/directory-source.d.ts.map +1 -0
  41. package/dist/loader/sources/directory-source.js +80 -0
  42. package/dist/loader/sources/directory-source.js.map +1 -0
  43. package/dist/loader/sources/file-source.d.ts +12 -0
  44. package/dist/loader/sources/file-source.d.ts.map +1 -0
  45. package/dist/loader/sources/file-source.js +46 -0
  46. package/dist/loader/sources/file-source.js.map +1 -0
  47. package/dist/loader/sources/index.d.ts +5 -0
  48. package/dist/loader/sources/index.d.ts.map +1 -0
  49. package/dist/loader/sources/index.js +5 -0
  50. package/dist/loader/sources/index.js.map +1 -0
  51. package/dist/loader/sources/uri-source.d.ts +9 -0
  52. package/dist/loader/sources/uri-source.d.ts.map +1 -0
  53. package/dist/loader/sources/uri-source.js +42 -0
  54. package/dist/loader/sources/uri-source.js.map +1 -0
  55. package/dist/loader/validation-passes.d.ts.map +1 -1
  56. package/dist/loader/validation-passes.js +27 -27
  57. package/dist/loader/validation-passes.js.map +1 -1
  58. package/dist/naming.d.ts +15 -2
  59. package/dist/naming.d.ts.map +1 -1
  60. package/dist/naming.js +20 -6
  61. package/dist/naming.js.map +1 -1
  62. package/dist/parser-core.d.ts +2 -4
  63. package/dist/parser-core.d.ts.map +1 -1
  64. package/dist/parser-core.js +111 -42
  65. package/dist/parser-core.js.map +1 -1
  66. package/dist/parser-json.d.ts.map +1 -1
  67. package/dist/parser-json.js +10 -2
  68. package/dist/parser-json.js.map +1 -1
  69. package/dist/persistence/source/validate-source-roles.js +2 -2
  70. package/dist/persistence/source/validate-source-roles.js.map +1 -1
  71. package/dist/semantic-diff.d.ts +5 -0
  72. package/dist/semantic-diff.d.ts.map +1 -0
  73. package/dist/semantic-diff.js +49 -0
  74. package/dist/semantic-diff.js.map +1 -0
  75. package/dist/shared/meta-data.d.ts +10 -0
  76. package/dist/shared/meta-data.d.ts.map +1 -1
  77. package/dist/shared/meta-data.js +23 -0
  78. package/dist/shared/meta-data.js.map +1 -1
  79. package/dist/source.d.ts +68 -0
  80. package/dist/source.d.ts.map +1 -0
  81. package/dist/source.js +13 -0
  82. package/dist/source.js.map +1 -0
  83. package/dist/subtype-rules.js +1 -1
  84. package/dist/subtype-rules.js.map +1 -1
  85. package/dist/super-resolve.d.ts +2 -0
  86. package/dist/super-resolve.d.ts.map +1 -1
  87. package/dist/super-resolve.js +1 -1
  88. package/dist/super-resolve.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/attr-schema-validate.ts +7 -7
  91. package/src/core/export-json.ts +15 -18
  92. package/src/core/index.ts +8 -2
  93. package/src/core/parser-yaml.ts +15 -4
  94. package/src/errors.ts +42 -8
  95. package/src/index.ts +28 -3
  96. package/src/json-path.ts +46 -0
  97. package/src/loader/meta-data-loader.ts +148 -10
  98. package/src/loader/meta-data-source.ts +10 -6
  99. package/src/loader/shortcuts.ts +31 -0
  100. package/src/loader/sources/directory-source.ts +90 -0
  101. package/src/{core → loader/sources}/file-source.ts +3 -3
  102. package/src/loader/sources/index.ts +6 -0
  103. package/src/loader/sources/uri-source.ts +44 -0
  104. package/src/loader/validation-passes.ts +28 -24
  105. package/src/naming.ts +39 -7
  106. package/src/parser-core.ts +113 -43
  107. package/src/parser-json.ts +11 -2
  108. package/src/persistence/source/validate-source-roles.ts +2 -2
  109. package/src/semantic-diff.ts +48 -0
  110. package/src/shared/meta-data.ts +28 -0
  111. package/src/source.ts +61 -0
  112. package/src/subtype-rules.ts +1 -1
  113. package/src/super-resolve.ts +3 -1
  114. package/src/core/file-meta-data-loader.ts +0 -89
package/src/naming.ts CHANGED
@@ -28,6 +28,31 @@ export function toSnakeCase(s: string): string {
28
28
  .toLowerCase();
29
29
  }
30
30
 
31
+ export function toKebabCase(s: string): string {
32
+ return toSnakeCase(s).replace(/_/g, "-");
33
+ }
34
+
35
+ /**
36
+ * Column-naming strategy applied by the persistence layer to fields with no
37
+ * explicit `@column` override. Persistence-layer config (set on
38
+ * ObjectManager / buildExpectedSchema / codegen config), not a metadata attr —
39
+ * the same metadata can drive snake_case (PG convention) or literal (EF
40
+ * convention) consumers. TS port defaults to snake_case; C# port defaults to
41
+ * literal.
42
+ */
43
+ export type ColumnNamingStrategy = "snake_case" | "literal" | "kebab-case";
44
+
45
+ /** Single source of truth for the TS-port default. */
46
+ export const DEFAULT_COLUMN_NAMING_STRATEGY: ColumnNamingStrategy = "snake_case";
47
+
48
+ export function applyColumnNamingStrategy(name: string, strategy: ColumnNamingStrategy): string {
49
+ switch (strategy) {
50
+ case "literal": return name;
51
+ case "kebab-case": return toKebabCase(name);
52
+ case "snake_case": return toSnakeCase(name);
53
+ }
54
+ }
55
+
31
56
  export function pluralize(s: string): string {
32
57
  if (/(s|x|z|ch|sh)$/i.test(s)) return s + "es";
33
58
  if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + "ies";
@@ -35,20 +60,24 @@ export function pluralize(s: string): string {
35
60
  }
36
61
 
37
62
  export function resolveTableName(entity: MetaData): string {
38
- // Primary writable source carries the physical table name (@table).
63
+ // Primary source carries the physical table/view name (@table). Writability
64
+ // (table vs view/storedProc/tableFunction) only affects write-routing — for
65
+ // SELECT-side name resolution, a read-only primary source is the right answer.
39
66
  const source = entity.ownChildren().find(
40
- (c): c is MetaSource =>
41
- c instanceof MetaSource && c.isWritable() && c.role === SOURCE_ROLE_PRIMARY,
67
+ (c): c is MetaSource => c instanceof MetaSource && c.role === SOURCE_ROLE_PRIMARY,
42
68
  );
43
69
  const name = source?.tableName;
44
70
  if (typeof name === "string" && name !== "") return name;
45
71
  return pluralize(toSnakeCase(entity.name));
46
72
  }
47
73
 
48
- export function resolveColumnName(field: MetaData): string {
74
+ export function resolveColumnName(
75
+ field: MetaData,
76
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
77
+ ): string {
49
78
  const col = field.ownAttr(FIELD_ATTR_COLUMN);
50
79
  if (typeof col === "string" && col) return col;
51
- return toSnakeCase(field.name);
80
+ return applyColumnNamingStrategy(field.name, strategy);
52
81
  }
53
82
 
54
83
  /**
@@ -75,12 +104,15 @@ export interface EntityNameMap {
75
104
  dbToJs: Map<string, string>;
76
105
  }
77
106
 
78
- export function buildNameMap(entity: MetaData): EntityNameMap {
107
+ export function buildNameMap(
108
+ entity: MetaData,
109
+ strategy: ColumnNamingStrategy = DEFAULT_COLUMN_NAMING_STRATEGY,
110
+ ): EntityNameMap {
79
111
  const jsToDb = new Map<string, string>();
80
112
  const dbToJs = new Map<string, string>();
81
113
  for (const child of entity.ownChildren()) {
82
114
  if (child.type !== TYPE_FIELD) continue;
83
- const dbCol = resolveColumnName(child);
115
+ const dbCol = resolveColumnName(child, strategy);
84
116
  jsToDb.set(child.name, dbCol);
85
117
  dbToJs.set(dbCol, child.name);
86
118
  }
@@ -29,7 +29,9 @@ import { MetaRoot } from "./shared/meta-root.js";
29
29
  import { MetaAttr } from "./core/attr/meta-attr.js";
30
30
  import { inferAttrSubType } from "./serializer-json.js";
31
31
  import { ParseError, type ErrorCode } from "./errors.js";
32
+ import type { ErrorSource } from "./source.js";
32
33
  import { resolveSuperRef } from "./super-resolve.js";
34
+ import { JsonPathBuilder } from "./json-path.js";
33
35
  import {
34
36
  TYPE_ATTR,
35
37
  TYPE_FIELD,
@@ -89,18 +91,24 @@ export interface ParseResult {
89
91
  }
90
92
 
91
93
  // ---------------------------------------------------------------------------
92
- // Internal helper — build ParseError opts, omitting undefined fields
93
- // (required by exactOptionalPropertyTypes: true in the project tsconfig)
94
+ // Internal helper — build a parse-phase ErrorSource envelope.
95
+ //
96
+ // FR5a / ADR-0009: ParseError carries a `source: ErrorSource` envelope. For
97
+ // errors raised mid-parse, the envelope's jsonPath comes from the parser's
98
+ // module-level JsonPathBuilder (synced with the walk); files[0] is the parsed
99
+ // source id. Falls back to a code-source envelope if invoked outside a buildTree
100
+ // run (defensive — every callsite below runs inside buildTree).
94
101
  // ---------------------------------------------------------------------------
95
102
 
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;
103
+ export function errSource(): ErrorSource {
104
+ if (_currentPath !== undefined && _currentSourceId !== undefined) {
105
+ return {
106
+ format: "json",
107
+ files: [_currentSourceId],
108
+ jsonPath: _currentPath.toString(),
109
+ };
110
+ }
111
+ return { format: "code", caller: "parser-core" };
104
112
  }
105
113
 
106
114
  // ---------------------------------------------------------------------------
@@ -111,14 +119,10 @@ function reportProblem(
111
119
  msg: string,
112
120
  strict: boolean,
113
121
  warnings: string[],
114
- source: string | undefined,
115
- path: string,
116
- code?: ErrorCode,
122
+ code: ErrorCode,
117
123
  ): void {
118
124
  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);
125
+ throw new ParseError(msg, { code, source: errSource() });
122
126
  }
123
127
  warnings.push(msg);
124
128
  }
@@ -196,6 +200,27 @@ let _deferSuperResolution = false;
196
200
  // _deferSuperResolution.
197
201
  let _currentErrors: ParseError[] | undefined;
198
202
 
203
+ // FR5a / ADR-0009 — Module-level JSONPath builder + source id, set at
204
+ // buildTree entry and updated by recursive descent (push on the way down,
205
+ // pop on the way back up). Used by populateNodeSource() to stamp every
206
+ // constructed MetaData with `{ format: "json", files: [sourceId], jsonPath }`.
207
+ // Safe because buildTree is synchronous — same reentrancy argument as
208
+ // _currentErrors above.
209
+ let _currentPath: JsonPathBuilder | undefined;
210
+ 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). */
215
+ function populateNodeSource(node: MetaData): void {
216
+ if (_currentPath === undefined || _currentSourceId === undefined) return;
217
+ node.setSource({
218
+ format: "json",
219
+ files: [_currentSourceId],
220
+ jsonPath: _currentPath.toString(),
221
+ });
222
+ }
223
+
199
224
  /**
200
225
  * buildTree — the shared registry-driven tree-builder.
201
226
  *
@@ -214,10 +239,16 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
214
239
  const source = opts.sourceName;
215
240
  _deferSuperResolution = opts.deferSuperResolution === true;
216
241
  _currentErrors = errors;
242
+ // FR5a — start a fresh JSONPath stack rooted at "$"; sourceId is the
243
+ // source's id (from FileSource / InMemoryStringSource via opts.sourceName).
244
+ // Falls back to "<unknown>" when no name was supplied (e.g. ad-hoc parseJson
245
+ // calls from tests).
246
+ _currentPath = new JsonPathBuilder();
247
+ _currentSourceId = source ?? "<unknown>";
217
248
 
218
249
  try {
219
250
  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" });
251
+ throw new ParseError("Top-level metadata must be an object", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
221
252
  }
222
253
 
223
254
  const topLevel = parsed as Record<string, unknown>;
@@ -226,12 +257,12 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
226
257
  const wrapperKeys = Object.keys(topLevel).filter((k) => k !== JSON_KEY_SCHEMA);
227
258
 
228
259
  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" });
260
+ throw new ParseError("Top-level metadata object has no type wrapper key", { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() });
230
261
  }
231
262
  if (wrapperKeys.length > 1) {
232
263
  throw new ParseError(
233
264
  `Top-level metadata object must have exactly one wrapper key (found: ${wrapperKeys.join(", ")})`,
234
- { ...errOpts(source), code: "ERR_TOP_LEVEL_NOT_OBJECT" },
265
+ { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: errSource() },
235
266
  );
236
267
  }
237
268
 
@@ -239,9 +270,14 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
239
270
  const rootData = topLevel[rootKey];
240
271
 
241
272
  if (typeof rootData !== "object" || rootData === null || Array.isArray(rootData)) {
273
+ // The error context references the rootKey wrapper; push it so the
274
+ // envelope's jsonPath includes it (matches the legacy `path` slot).
275
+ _currentPath!.pushKey(rootKey);
276
+ const src = errSource();
277
+ _currentPath!.pop();
242
278
  throw new ParseError(
243
279
  `Top-level wrapper "${rootKey}" must contain an object`,
244
- { ...errOpts(source, rootKey), code: "ERR_TOP_LEVEL_NOT_OBJECT" },
280
+ { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: src },
245
281
  );
246
282
  }
247
283
 
@@ -253,12 +289,22 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
253
289
  const rootTypeCode = opts.registry.allSubTypesOf(rootType).length > 0
254
290
  ? "ERR_UNKNOWN_SUBTYPE" as const
255
291
  : "ERR_UNKNOWN_TYPE" as const;
292
+ _currentPath!.pushKey(rootKey);
293
+ const src = errSource();
294
+ _currentPath!.pop();
256
295
  throw new ParseError(
257
296
  `Unknown root type "${rootType}.${rootSubType}" — not registered`,
258
- { ...errOpts(source, rootKey), code: rootTypeCode },
297
+ { code: rootTypeCode, source: src },
259
298
  );
260
299
  }
261
300
 
301
+ // FR5a — push the wrapper-key segment onto the JSONPath stack so all
302
+ // descendants emit jsonPath strings rooted at "$.<rootKey>". The merge-mode
303
+ // path keeps the existing root's source untouched; only NEW children get
304
+ // populated with the current source's id (correct: the existing root was
305
+ // already stamped from the file that created it).
306
+ _currentPath!.pushKey(rootKey);
307
+
262
308
  if (opts.intoRoot !== undefined) {
263
309
  // --- Merge mode: parse root's attrs/children into the existing root ---
264
310
  // The JSON root's own package/super/reserved-keys are not re-applied to the
@@ -278,6 +324,7 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
278
324
  source,
279
325
  rootKey,
280
326
  );
327
+ _currentPath!.pop();
281
328
  return { root: opts.intoRoot, warnings, errors };
282
329
  }
283
330
 
@@ -302,10 +349,13 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
302
349
  source,
303
350
  rootKey,
304
351
  ) as MetaRoot;
352
+ _currentPath!.pop();
305
353
  return { root, warnings, errors };
306
354
  } finally {
307
355
  _deferSuperResolution = false;
308
356
  _currentErrors = undefined;
357
+ _currentPath = undefined;
358
+ _currentSourceId = undefined;
309
359
  }
310
360
  }
311
361
 
@@ -341,10 +391,12 @@ function parseNodeFresh(
341
391
  subType = SUBTYPE_BASE;
342
392
  } else {
343
393
  const msg = `Unknown type "${type}.${subType}" — not registered`;
344
- errors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_UNKNOWN_TYPE" }));
394
+ errors.push(new ParseError(msg, { code: "ERR_UNKNOWN_TYPE", source: errSource() }));
345
395
  const rawName = nodeData[RESERVED_KEY_NAME];
346
396
  const name = typeof rawName === "string" ? rawName : "";
347
- return new MetaRoot(new TypeId(type, subType), name);
397
+ const stub = new MetaRoot(new TypeId(type, subType), name);
398
+ populateNodeSource(stub);
399
+ return stub;
348
400
  }
349
401
  }
350
402
 
@@ -355,6 +407,10 @@ function parseNodeFresh(
355
407
  // --- Create the model ---
356
408
  const def = registry.find(type, subType)!;
357
409
  const model = def.factory(def.typeId, name);
410
+ // FR5a — stamp the source provenance envelope using the parser's current
411
+ // JSONPath stack + source id. setSource happens BEFORE freeze (the parser
412
+ // is the only caller during loading; freeze runs in the loader after).
413
+ populateNodeSource(model);
358
414
 
359
415
  // --- Apply reserved keys (package, extends, abstract, isArray) ---
360
416
  applyReservedKeys(model, nodeData, strict, source, path, warnings, inheritedContextPkg);
@@ -392,7 +448,7 @@ function parseNodeFresh(
392
448
  } else {
393
449
  throw new ParseError(
394
450
  `the SuperClass '${model.superRef}' does not exist in file '${source ?? "<unknown>"}'`,
395
- { ...errOpts(source, path), code: "ERR_UNRESOLVED_SUPER" },
451
+ { code: "ERR_UNRESOLVED_SUPER", source: errSource() },
396
452
  );
397
453
  }
398
454
  } else if (model.superRef !== undefined && accumRoot === undefined) {
@@ -401,8 +457,6 @@ function parseNodeFresh(
401
457
  `super on root node ('${model.superRef}') is not supported and will be ignored`,
402
458
  strict,
403
459
  warnings,
404
- source,
405
- path,
406
460
  "ERR_UNRESOLVED_SUPER",
407
461
  );
408
462
  }
@@ -487,7 +541,7 @@ function createOrFindMetaData(
487
541
  if (existing === undefined) {
488
542
  throw new ParseError(
489
543
  `Overlay operation requested for [${type}:${name}] but no existing metadata found to merge into`,
490
- { ...errOpts(source, path), code: "ERR_OVERLAY_NO_TARGET" },
544
+ { code: "ERR_OVERLAY_NO_TARGET", source: errSource() },
491
545
  );
492
546
  }
493
547
  existing.setIsMerge(true);
@@ -524,7 +578,7 @@ function applyReservedKeys(
524
578
  const rawPkg = nodeData[RESERVED_KEY_PACKAGE];
525
579
  if (rawPkg !== undefined) {
526
580
  if (typeof rawPkg !== "string") {
527
- reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
581
+ reportProblem(`"${RESERVED_KEY_PACKAGE}" must be a string at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
528
582
  } else {
529
583
  const expandedPkg = contextPkg !== undefined ? expandPackageForPath(contextPkg, rawPkg) : rawPkg;
530
584
  model.setPackage(expandedPkg);
@@ -535,7 +589,7 @@ function applyReservedKeys(
535
589
  const rawExtends = nodeData[RESERVED_KEY_EXTENDS];
536
590
  if (rawExtends !== undefined) {
537
591
  if (typeof rawExtends !== "string") {
538
- reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, source, path, "ERR_UNRESOLVED_SUPER");
592
+ reportProblem(`"${RESERVED_KEY_EXTENDS}" must be a string at ${path}`, strict, warnings, "ERR_UNRESOLVED_SUPER");
539
593
  } else {
540
594
  model.setSuper(rawExtends);
541
595
  }
@@ -545,7 +599,7 @@ function applyReservedKeys(
545
599
  const rawAbstract = nodeData[RESERVED_KEY_ABSTRACT];
546
600
  if (rawAbstract !== undefined) {
547
601
  if (typeof rawAbstract !== "boolean") {
548
- reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
602
+ reportProblem(`"${RESERVED_KEY_ABSTRACT}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
549
603
  } else {
550
604
  model.setIsAbstract(rawAbstract);
551
605
  }
@@ -555,7 +609,7 @@ function applyReservedKeys(
555
609
  const rawIsArray = nodeData[RESERVED_KEY_IS_ARRAY];
556
610
  if (rawIsArray !== undefined) {
557
611
  if (typeof rawIsArray !== "boolean") {
558
- reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, source, path, "ERR_BAD_ATTR_VALUE");
612
+ reportProblem(`"${RESERVED_KEY_IS_ARRAY}" must be a boolean at ${path}`, strict, warnings, "ERR_BAD_ATTR_VALUE");
559
613
  } else {
560
614
  model.setIsArray(rawIsArray);
561
615
  }
@@ -587,7 +641,7 @@ function applyInlineAttrsAndUnknownKeys(
587
641
  model.name !== "" ? `${model.type}.${model.subType} '${model.name}'` : `${model.type}.${model.subType}`;
588
642
  reportProblem(
589
643
  `Unknown key '${key}' on ${displayName} at ${path} (must be reserved or ${ATTR_PREFIX}-prefixed)`,
590
- strict, warnings, source, path, "ERR_UNKNOWN_ATTR",
644
+ strict, warnings, "ERR_UNKNOWN_ATTR",
591
645
  );
592
646
  continue;
593
647
  }
@@ -608,14 +662,14 @@ function applyInlineAttrsAndUnknownKeys(
608
662
  `Reserved structural key '${attrName}' must not be ${ATTR_PREFIX}-prefixed ` +
609
663
  `on ${displayName} at ${path} (write it bare)`;
610
664
  if (strict) {
611
- throw new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" });
665
+ throw new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() });
612
666
  }
613
667
  // Lax mode: route through the module-level errors sink so the loader
614
668
  // sees this as a hard error (parity with attr-schema-validate's
615
669
  // ERR_BAD_ATTR_VALUE direct pushes). Falls back to warnings only if
616
670
  // _currentErrors isn't bound (unreachable when called from buildTree).
617
671
  if (_currentErrors !== undefined) {
618
- _currentErrors.push(new ParseError(msg, { ...errOpts(source, path), code: "ERR_RESERVED_ATTR" }));
672
+ _currentErrors.push(new ParseError(msg, { code: "ERR_RESERVED_ATTR", source: errSource() }));
619
673
  } else {
620
674
  warnings.push(msg);
621
675
  }
@@ -630,7 +684,7 @@ function applyInlineAttrsAndUnknownKeys(
630
684
  } catch (err) {
631
685
  reportProblem(
632
686
  `Failed to convert attribute "${ATTR_PREFIX}${attrName}" at ${path}: ${(err as Error).message}`,
633
- strict, warnings, source, path, "ERR_BAD_ATTR_VALUE",
687
+ strict, warnings, "ERR_BAD_ATTR_VALUE",
634
688
  );
635
689
  }
636
690
  }
@@ -703,16 +757,23 @@ function processChildren(
703
757
  if (rawChildren === undefined) return;
704
758
 
705
759
  if (!Array.isArray(rawChildren)) {
706
- reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, source, path, "ERR_TOP_LEVEL_NOT_OBJECT");
760
+ reportProblem(`"${RESERVED_KEY_CHILDREN}" must be an array at ${path}`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
707
761
  return;
708
762
  }
709
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);
768
+
710
769
  for (let i = 0; i < rawChildren.length; i++) {
711
770
  const childEntry = rawChildren[i];
712
771
  const childPath = `${path}.${RESERVED_KEY_CHILDREN}[${i}]`;
772
+ _currentPath?.pushIndex(i);
713
773
 
714
774
  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");
775
+ reportProblem(`Child at ${childPath} must be an object`, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
776
+ _currentPath?.pop();
716
777
  continue;
717
778
  }
718
779
 
@@ -724,19 +785,23 @@ function processChildren(
724
785
  childKeys.length === 0
725
786
  ? `Child at ${childPath} has no type wrapper key`
726
787
  : `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");
788
+ reportProblem(msg, strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT");
789
+ _currentPath?.pop();
728
790
  continue;
729
791
  }
730
792
 
731
793
  const childKey = childKeys[0]!;
732
794
  const childData = childRecord[childKey];
733
795
  const childNodePath = `${childPath}.${childKey}`;
796
+ _currentPath?.pushKey(childKey);
734
797
 
735
798
  if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
736
799
  reportProblem(
737
800
  `Child wrapper "${childKey}" at ${childNodePath} must contain an object`,
738
- strict, warnings, source, childNodePath, "ERR_TOP_LEVEL_NOT_OBJECT",
801
+ strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT",
739
802
  );
803
+ _currentPath?.pop(); // pop child wrapper key
804
+ _currentPath?.pop(); // pop array index
740
805
  continue;
741
806
  }
742
807
 
@@ -758,9 +823,11 @@ function processChildren(
758
823
  errors.push(
759
824
  new ParseError(
760
825
  `Unknown type "${childType}.${childSubType}" — not registered`,
761
- { ...errOpts(source, childNodePath), code: childTypeCode },
826
+ { code: childTypeCode, source: errSource() },
762
827
  ),
763
828
  );
829
+ _currentPath?.pop(); // pop child wrapper key
830
+ _currentPath?.pop(); // pop array index
764
831
  continue; // skip this child
765
832
  }
766
833
  }
@@ -789,7 +856,10 @@ function processChildren(
789
856
  parent.addChild(childModel);
790
857
  }
791
858
  }
859
+ _currentPath?.pop(); // pop child wrapper key
860
+ _currentPath?.pop(); // pop array index
792
861
  }
862
+ _currentPath?.pop(); // pop the "children" key
793
863
  }
794
864
 
795
865
  // ---------------------------------------------------------------------------
@@ -821,7 +891,7 @@ function parseAttrChild(
821
891
  if (typeof attrName !== "string" || attrName === "") {
822
892
  reportProblem(
823
893
  `attr child at ${path} requires a non-empty "${RESERVED_KEY_NAME}" string`,
824
- strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR",
894
+ strict, warnings, "ERR_MISSING_REQUIRED_ATTR",
825
895
  );
826
896
  return;
827
897
  }
@@ -829,7 +899,7 @@ function parseAttrChild(
829
899
  if (attrValue === undefined) {
830
900
  reportProblem(
831
901
  `attr child "${attrName}" at ${path} is missing "${RESERVED_KEY_VALUE}"`,
832
- strict, warnings, source, path, "ERR_MISSING_REQUIRED_ATTR",
902
+ strict, warnings, "ERR_MISSING_REQUIRED_ATTR",
833
903
  );
834
904
  return;
835
905
  }
@@ -852,7 +922,7 @@ function parseAttrChild(
852
922
  } catch (err) {
853
923
  reportProblem(
854
924
  `Failed to convert attr child "${attrName}" value at ${path}: ${(err as Error).message}`,
855
- strict, warnings, source, path, "ERR_BAD_ATTR_VALUE",
925
+ strict, warnings, "ERR_BAD_ATTR_VALUE",
856
926
  );
857
927
  return;
858
928
  }
@@ -4,8 +4,9 @@
4
4
  // the canonical object to the shared buildTree (parser-core.ts).
5
5
 
6
6
  import { ParseError } from "./errors.js";
7
- import { buildTree, errOpts } from "./parser-core.js";
7
+ import { buildTree } from "./parser-core.js";
8
8
  import type { ParseOptions, ParseResult } from "./parser-core.js";
9
+ import type { ErrorSource } from "./source.js";
9
10
 
10
11
  export type { ParseOptions, ParseResult } from "./parser-core.js";
11
12
 
@@ -18,9 +19,17 @@ export function parseJson(content: string, opts: ParseOptions): ParseResult {
18
19
  try {
19
20
  parsed = JSON.parse(normalizedContent);
20
21
  } catch (err) {
22
+ // FR5a / ADR-0009 — pre-buildTree errors can't reach the parser's
23
+ // module-level JsonPathBuilder; build a minimal json-source envelope
24
+ // rooted at "$" so cross-port callers see a consistent shape.
25
+ const source: ErrorSource = {
26
+ format: "json",
27
+ files: [opts.sourceName ?? "<unknown>"],
28
+ jsonPath: "$",
29
+ };
21
30
  throw new ParseError(
22
31
  `Invalid JSON: ${(err as Error).message}`,
23
- { ...errOpts(opts.sourceName), code: "ERR_MALFORMED_JSON" },
32
+ { code: "ERR_MALFORMED_JSON", source },
24
33
  );
25
34
  }
26
35
 
@@ -36,14 +36,14 @@ export function validateSourceRoles(root: MetaData): ParseError[] {
36
36
  errors.push(
37
37
  new ParseError(
38
38
  `object "${obj.name}" declares ${sources.length} source(s) but none has role "${SOURCE_ROLE_PRIMARY}"`,
39
- { code: "ERR_SOURCE_NO_PRIMARY" },
39
+ { code: "ERR_SOURCE_NO_PRIMARY", source: obj.source },
40
40
  ),
41
41
  );
42
42
  } else if (primaryCount > 1) {
43
43
  errors.push(
44
44
  new ParseError(
45
45
  `object "${obj.name}" declares ${primaryCount} sources with role "${SOURCE_ROLE_PRIMARY}"; exactly one is required`,
46
- { code: "ERR_SOURCE_MULTIPLE_PRIMARY" },
46
+ { code: "ERR_SOURCE_MULTIPLE_PRIMARY", source: obj.source },
47
47
  ),
48
48
  );
49
49
  }
@@ -0,0 +1,48 @@
1
+ // server/typescript/packages/metadata/src/semantic-diff.ts
2
+ //
3
+ // FR5a / ADR-0009 — Cross-port-aligned semantic-equality compare for metadata
4
+ // trees. Returns `true` if the two inputs differ in any semantically-meaningful
5
+ // way (excluding `source`, which is loader output).
6
+ //
7
+ // Algorithm (ADR-0009 §semantic_diff):
8
+ // 1. Sort attrs lexicographically; compare attr-by-attr; values by canonical
9
+ // structural equality (key-order independent, whitespace-insensitive).
10
+ // 2. Children are compared as ordered sequences.
11
+ // 3. Reserved structural keys (name, package, extends, abstract, overlay,
12
+ // isArray, value) participate like attrs.
13
+ // 4. `source` excluded from the diff.
14
+
15
+ type Tree = Record<string, unknown>;
16
+
17
+ const EXCLUDED = new Set(["source"]);
18
+
19
+ function isObject(v: unknown): v is Tree {
20
+ return typeof v === "object" && v !== null && !Array.isArray(v);
21
+ }
22
+
23
+ function equal(a: unknown, b: unknown): boolean {
24
+ if (a === b) return true;
25
+ if (Array.isArray(a) && Array.isArray(b)) {
26
+ if (a.length !== b.length) return false;
27
+ for (let i = 0; i < a.length; i++) {
28
+ if (!equal(a[i], b[i])) return false;
29
+ }
30
+ return true;
31
+ }
32
+ if (isObject(a) && isObject(b)) {
33
+ const aKeys = Object.keys(a).filter((k) => !EXCLUDED.has(k)).sort();
34
+ const bKeys = Object.keys(b).filter((k) => !EXCLUDED.has(k)).sort();
35
+ if (aKeys.length !== bKeys.length) return false;
36
+ for (let i = 0; i < aKeys.length; i++) {
37
+ if (aKeys[i] !== bKeys[i]) return false;
38
+ if (!equal(a[aKeys[i]!], b[bKeys[i]!])) return false;
39
+ }
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /** Returns `true` if the inputs differ in any semantically-meaningful way. */
46
+ export function semanticDiff(a: Tree, b: Tree): boolean {
47
+ return !equal(a, b);
48
+ }
@@ -5,6 +5,7 @@ import type { DataType } from "../data-type.js";
5
5
  import type { MetaAttr } from "../core/attr/meta-attr.js";
6
6
  import { inferAttrSubType } from "../serializer-json.js";
7
7
  import { attrClassFor } from "../attr-class-map.js";
8
+ import type { ErrorSource } from "../source.js";
8
9
 
9
10
  export type AttrValue = string | number | boolean | string[] | AttrObject;
10
11
 
@@ -57,6 +58,14 @@ export abstract class MetaData {
57
58
  // construction (for field/attr nodes). Read via MetaField/MetaAttr.dataType.
58
59
  protected _dataType?: DataType;
59
60
 
61
+ // ADR-0009 provenance. Always populated; defaults to `{ format: "code" }`
62
+ // for programmatic construction (tests, factories, in-code builders). The
63
+ // JSON parser overwrites via setSource() during tree walk; future phases
64
+ // (overlay merge, super resolution) may overwrite with merged/resolved
65
+ // variants. Excluded from canonical JSON serialization — loader-derived
66
+ // state, not metadata.
67
+ private _source: ErrorSource = { format: "code" };
68
+
60
69
  // Per-instance read cache: only populated once the node is frozen.
61
70
  private readonly _cache = new Map<string, unknown>();
62
71
 
@@ -171,6 +180,25 @@ export abstract class MetaData {
171
180
  this._dataType = dt;
172
181
  }
173
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // source (ADR-0009 provenance)
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /** Provenance envelope for this node. Always populated. See ADR-0009. */
188
+ get source(): ErrorSource {
189
+ return this._source;
190
+ }
191
+
192
+ /**
193
+ * Loader-internal: assign provenance. Called by parser and merge phases as
194
+ * they build the tree. Honors the frozen-guard like other setters.
195
+ * @internal
196
+ */
197
+ setSource(s: ErrorSource): void {
198
+ this._assertNotFrozen();
199
+ this._source = s;
200
+ }
201
+
174
202
  // ---------------------------------------------------------------------------
175
203
  // isAbstract
176
204
  // ---------------------------------------------------------------------------