@metaobjectsdev/metadata 0.24.0 → 0.24.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 (64) hide show
  1. package/dist/attr-contradictions.d.ts +52 -0
  2. package/dist/attr-contradictions.d.ts.map +1 -0
  3. package/dist/attr-contradictions.js +100 -0
  4. package/dist/attr-contradictions.js.map +1 -0
  5. package/dist/core/identity/identity-definition.embedded.js +2 -2
  6. package/dist/core/identity/identity-definition.embedded.js.map +1 -1
  7. package/dist/core/index/index-definition.embedded.js +2 -2
  8. package/dist/core/index/index-definition.embedded.js.map +1 -1
  9. package/dist/core/requirement/meta-requirement.d.ts +9 -1
  10. package/dist/core/requirement/meta-requirement.d.ts.map +1 -1
  11. package/dist/core/requirement/meta-requirement.js +15 -2
  12. package/dist/core/requirement/meta-requirement.js.map +1 -1
  13. package/dist/core/requirement/requirement-constants.d.ts +30 -2
  14. package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
  15. package/dist/core/requirement/requirement-constants.js +32 -1
  16. package/dist/core/requirement/requirement-constants.js.map +1 -1
  17. package/dist/core/requirement/requirement-definition.embedded.d.ts.map +1 -1
  18. package/dist/core/requirement/requirement-definition.embedded.js +22 -4
  19. package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
  20. package/dist/core/vocabulary-rewrite-yaml.d.ts +21 -0
  21. package/dist/core/vocabulary-rewrite-yaml.d.ts.map +1 -0
  22. package/dist/core/vocabulary-rewrite-yaml.js +348 -0
  23. package/dist/core/vocabulary-rewrite-yaml.js.map +1 -0
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +31 -2
  27. package/dist/errors.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +3 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  33. package/dist/loader/meta-data-loader.js +8 -1
  34. package/dist/loader/meta-data-loader.js.map +1 -1
  35. package/dist/loader/validation-passes.d.ts +2 -0
  36. package/dist/loader/validation-passes.d.ts.map +1 -1
  37. package/dist/loader/validation-passes.js +328 -43
  38. package/dist/loader/validation-passes.js.map +1 -1
  39. package/dist/persistence/origin/origin-definition.embedded.js +5 -5
  40. package/dist/persistence/origin/origin-definition.embedded.js.map +1 -1
  41. package/dist/registry-manifest.d.ts +1 -1
  42. package/dist/registry-manifest.js +1 -1
  43. package/dist/retired-vocabulary.d.ts.map +1 -1
  44. package/dist/retired-vocabulary.js +54 -11
  45. package/dist/retired-vocabulary.js.map +1 -1
  46. package/dist/vocabulary-rewrite.d.ts.map +1 -1
  47. package/dist/vocabulary-rewrite.js +127 -15
  48. package/dist/vocabulary-rewrite.js.map +1 -1
  49. package/package.json +6 -1
  50. package/src/attr-contradictions.ts +141 -0
  51. package/src/core/identity/identity-definition.embedded.ts +2 -2
  52. package/src/core/index/index-definition.embedded.ts +2 -2
  53. package/src/core/requirement/meta-requirement.ts +18 -1
  54. package/src/core/requirement/requirement-constants.ts +34 -1
  55. package/src/core/requirement/requirement-definition.embedded.ts +22 -4
  56. package/src/core/vocabulary-rewrite-yaml.ts +375 -0
  57. package/src/errors.ts +31 -2
  58. package/src/index.ts +9 -0
  59. package/src/loader/meta-data-loader.ts +10 -1
  60. package/src/loader/validation-passes.ts +410 -47
  61. package/src/persistence/origin/origin-definition.embedded.ts +5 -5
  62. package/src/registry-manifest.ts +1 -1
  63. package/src/retired-vocabulary.ts +54 -11
  64. package/src/vocabulary-rewrite.ts +137 -15
@@ -13,7 +13,8 @@
13
13
  import type { MetaData } from "../shared/meta-data.js";
14
14
  import type { MetaObject } from "../core/object/meta-object.js";
15
15
  import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js";
16
- import { ParseError } from "../errors.js";
16
+ import { contradictionHint, contradictionsFor } from "../attr-contradictions.js";
17
+ import { ParseError, type ErrorCode } from "../errors.js";
17
18
  import { resolveObjectRef, didYouMeanHint } from "../naming-refs.js";
18
19
  import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js";
19
20
  import { resolvedSource, type ErrorSource } from "../source.js";
@@ -32,6 +33,8 @@ import {
32
33
  INDEX_SUBTYPE_LOOKUP,
33
34
  INDEX_ATTR_FIELDS,
34
35
  } from "../core/index/index-constants.js";
36
+ import { IDENTITY_SUBTYPE_SECONDARY } from "../core/identity/identity-constants.js";
37
+ import type { MetaIdentity } from "../core/identity/meta-identity.js";
35
38
  import { MetaIndex } from "../core/index/meta-index.js";
36
39
  import {
37
40
  TEMPLATE_ATTR_PAYLOAD_REF,
@@ -59,6 +62,7 @@ import {
59
62
  } from "../presentation/layout/layout-constants.js";
60
63
  import {
61
64
  FIELD_ATTR_FILTERABLE,
65
+ FIELD_ATTR_SORTABLE,
62
66
  FIELD_ATTR_OBJECT_REF,
63
67
  FIELD_ATTR_STORAGE,
64
68
  STORAGE_FLATTENED,
@@ -81,7 +85,7 @@ import {
81
85
  FIELD_SUBTYPE_TIMESTAMP,
82
86
  FIELD_SUBTYPE_UUID,
83
87
  } from "../core/field/field-constants.js";
84
- import { FIELD_ATTR_DB_INDEXED } from "../persistence/db/db-constants.js";
88
+ import { FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js";
85
89
  import {
86
90
  IDENTITY_ATTR_FIELDS,
87
91
  IDENTITY_SUBTYPE_REFERENCE,
@@ -132,6 +136,13 @@ import {
132
136
  opsForSubType,
133
137
  opsForField,
134
138
  } from "../core/query/query-constants.js";
139
+ import {
140
+ REQUIREMENT,
141
+ REQUIREMENT_ATTR_STATUS,
142
+ REQUIREMENT_ATTR_IMPLEMENTED_BY,
143
+ REQUIREMENT_ATTR_SUPERSEDED_BY,
144
+ REQUIREMENT_STATUS_RETIRED,
145
+ } from "../core/requirement/requirement-constants.js";
135
146
 
136
147
  // ---------------------------------------------------------------------------
137
148
  // Layout dataGrid @defaultSortField validation
@@ -441,6 +452,26 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
441
452
  for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
442
453
  // ADR-0039: resolving — a concrete field may inherit @filterable via extends.
443
454
  if (field.attr(FIELD_ATTR_FILTERABLE) !== true) continue;
455
+
456
+ // #335 Half B — an ARRAY field has no operator band either. Every FR-009
457
+ // operator (eq/ne/gt/gte/lt/lte/in/like/isNull) is a scalar comparison;
458
+ // none applies to a collection column. The allowlist template does not
459
+ // consult isArray and falls through to the "string" band, so this
460
+ // previously emitted a `like` rule against a text[] column — SQL that
461
+ // cannot execute. Same reason as the subtype check below, so same code.
462
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
463
+ if (field.resolvedIsArray()) {
464
+ errors.push(
465
+ new ParseError(
466
+ `Field "${obj.name}.${field.name}" has @filterable: true but is an array ` +
467
+ `(isArray: true). No filter operator applies to a collection column. ` +
468
+ `Remove @filterable from this field.`,
469
+ { code: "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE", source: field.source },
470
+ ),
471
+ );
472
+ continue;
473
+ }
474
+
444
475
  if (opsForSubType(field.subType).length > 0) continue;
445
476
  errors.push(
446
477
  new ParseError(
@@ -455,6 +486,40 @@ export function validateFilterableHasSupportedOps(root: MetaData): ParseError[]
455
486
  return errors;
456
487
  }
457
488
 
489
+ // ---------------------------------------------------------------------------
490
+ // @sortable on a subtype or shape that cannot be ordered (#335 Half B)
491
+ // ---------------------------------------------------------------------------
492
+ // @sortable defaults FROM @filterable, so it is independently set only when
493
+ // explicit — and nothing validated it, while @filterable has had a hard error
494
+ // since SP-H Unit9. A @sortable JSON or array column emits a sort entry over a
495
+ // column no dialect can ORDER BY meaningfully. → ERR_SORTABLE_UNSUPPORTED_SUBTYPE.
496
+
497
+ export function validateSortableHasSupportedSubtype(root: MetaData): ParseError[] {
498
+ const errors: ParseError[] = [];
499
+ // ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
500
+ for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
501
+ // children() — inherited @sortable fields (via extends:/super:) are visible.
502
+ for (const field of obj.children().filter((c) => c.type === TYPE_FIELD)) {
503
+ // ADR-0039: resolving — a concrete field may inherit @sortable via extends.
504
+ if (field.attr(FIELD_ATTR_SORTABLE) !== true) continue;
505
+ // ADR-0039: resolvedIsArray(), never the own `isArray` flag.
506
+ const isArray = field.resolvedIsArray();
507
+ if (!isArray && opsForSubType(field.subType).length > 0) continue;
508
+ errors.push(
509
+ new ParseError(
510
+ `Field "${obj.name}.${field.name}" has @sortable: true but ` +
511
+ (isArray
512
+ ? `is an array (isArray: true) — a collection column has no ordering.`
513
+ : `its subtype "${field.subType}" cannot be ordered.`) +
514
+ ` Remove @sortable from this field.`,
515
+ { code: "ERR_SORTABLE_UNSUPPORTED_SUBTYPE", source: field.source },
516
+ ),
517
+ );
518
+ }
519
+ }
520
+ return errors;
521
+ }
522
+
458
523
  // ---------------------------------------------------------------------------
459
524
  // Origin path validation
460
525
  //
@@ -592,10 +657,25 @@ function _validateFromPath(
592
657
  return { entity: sourceObj, field: sourceField };
593
658
  }
594
659
 
660
+ /** A fully-walked `@via` path: the relationship hop nodes in path order, and
661
+ * the TERMINAL entity reached after the last hop. Both fall out of one walk,
662
+ * so they are returned together — recovering the terminal with a second walk
663
+ * means maintaining a second copy of the ADR-0042 package-resolution rule.
664
+ * Shape mirrors `_validateFromPath`'s `ResolvedFromTarget`, which returns a
665
+ * pair for the same reason. */
666
+ interface WalkedViaPath {
667
+ hops: MetaData[];
668
+ terminal: MetaData;
669
+ }
670
+
595
671
  /**
596
- * Validate an explicit `@via` "Entity.rel[.rel...]" path. Returns the walked
597
- * relationship hop nodes (in path order) on full success — FR-024 B5 runs the
598
- * cardinality checks over them — or undefined when any error was pushed.
672
+ * Validate an explicit `@via` "Entity.rel[.rel...]" path. On full success
673
+ * returns the walked relationship hop nodes (in path order) — FR-024 B5 runs
674
+ * the cardinality checks over them — together with the terminal entity node
675
+ * (#335: a whole-object `@agg:collect` has no `@of` entity, so `@orderBy` keys
676
+ * and value-object members resolve against the terminal instead). Returns
677
+ * undefined when any error was pushed, so `terminal` is defined exactly when
678
+ * `hops` is.
599
679
  */
600
680
  function _validateViaPath(
601
681
  viaAttr: string,
@@ -604,7 +684,7 @@ function _validateViaPath(
604
684
  fieldName: string,
605
685
  originSource: ErrorSource,
606
686
  errors: ParseError[],
607
- ): MetaData[] | undefined {
687
+ ): WalkedViaPath | undefined {
608
688
  const projectionName = projection.name;
609
689
  // FR5d — referrer is `<projection-FQN>::<fieldName>`.
610
690
  const referrer = `${projection.fqn()}::${fieldName}`;
@@ -706,7 +786,8 @@ function _validateViaPath(
706
786
  hops.push(rel);
707
787
  currentObj = nextObj;
708
788
  }
709
- return hops;
789
+ // currentObj is the terminal: every earlier exit returned undefined.
790
+ return { hops, terminal: currentObj };
710
791
  }
711
792
 
712
793
  // ---------------------------------------------------------------------------
@@ -956,6 +1037,58 @@ function _checkAggregateCardinality(
956
1037
  }
957
1038
  }
958
1039
 
1040
+ /**
1041
+ * #335 — a whole-object `@agg:collect` projects EXACTLY the declared value
1042
+ * object's members, each matched by NAME against the `@via` terminal entity.
1043
+ *
1044
+ * Two rules, both fail-closed:
1045
+ * - a member with no matching field on the terminal is unresolvable. Failing
1046
+ * OPEN here is how #270 turned a curated value object into the full entity,
1047
+ * invisible in a diff because the metadata still read as curated.
1048
+ * - a matched member must agree on BOTH type axes (#185 type-preserving
1049
+ * doctrine), so a scalar member cannot bind an array field or vice versa.
1050
+ *
1051
+ * Both refusals carry a whole-object-specific code — ERR_COLLECT_MEMBER_UNRESOLVED
1052
+ * for the unmatched member, ERR_COLLECT_WHOLE_OBJECT for the type disagreement.
1053
+ * The latter is deliberately NOT the scalar arm's ERR_INVALID_ORIGIN: a loader
1054
+ * that still requires @of rejects this metadata with ERR_INVALID_ORIGIN too, so
1055
+ * sharing the code would make a corpus fixture pass on a port that implements
1056
+ * nothing (the corpus compares only code + source, never message text).
1057
+ */
1058
+ function _checkCollectMembers(
1059
+ refTarget: MetaData,
1060
+ terminal: MetaData,
1061
+ obj: MetaData,
1062
+ field: MetaData,
1063
+ src: ErrorSource,
1064
+ errors: ParseError[],
1065
+ ): void {
1066
+ // ADR-0039: resolving — a value object may inherit members via extends, and
1067
+ // the terminal entity may inherit fields; own-only would silently skip
1068
+ // inherited members, which is exactly the #270 bug class this guards.
1069
+ const terminalFields = terminal.children().filter((c) => c.type === TYPE_FIELD);
1070
+ for (const member of refTarget.children().filter((c) => c.type === TYPE_FIELD)) {
1071
+ const match = terminalFields.find((f) => f.name === member.name);
1072
+ if (match === undefined) {
1073
+ errors.push(new ParseError(
1074
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1075
+ `'${member.name}' has no matching field on '${terminal.name}' — a whole-object ` +
1076
+ `rollup projects exactly the declared members.`,
1077
+ { code: "ERR_COLLECT_MEMBER_UNRESOLVED", source: src }));
1078
+ continue;
1079
+ }
1080
+ const memberLabel = _typeLabel(member);
1081
+ const matchLabel = _typeLabel(match);
1082
+ if (memberLabel !== matchLabel) {
1083
+ errors.push(new ParseError(
1084
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: value-object member ` +
1085
+ `'${member.name}' is ${memberLabel} but '${terminal.name}.${match.name}' ` +
1086
+ `is ${matchLabel} — a whole-object rollup preserves each member's type.`,
1087
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1088
+ }
1089
+ }
1090
+ }
1091
+
959
1092
  /**
960
1093
  * FR-024 B6 (spec §4; ADR-0029 decision 7) — extends/origin agreement.
961
1094
  *
@@ -1021,6 +1154,17 @@ function _checkExtendsOriginAgreement(
1021
1154
  * FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_
1022
1155
  * TYPE_MISMATCH used to cover).
1023
1156
  */
1157
+ /**
1158
+ * Both type axes in one comparable token. Subtype names never contain "[]", so
1159
+ * equal labels ⇔ same subType AND same array-ness. Nullability is deliberately
1160
+ * NOT judged — an outer-join view legitimately widens NOT NULL.
1161
+ * ADR-0039: resolvedIsArray(), never the own `isArray` flag — a field may
1162
+ * inherit its array-ness via extends.
1163
+ */
1164
+ function _typeLabel(field: MetaData): string {
1165
+ return `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1166
+ }
1167
+
1024
1168
  function _checkPassthroughType(
1025
1169
  field: MetaData,
1026
1170
  fromField: MetaData,
@@ -1031,11 +1175,8 @@ function _checkPassthroughType(
1031
1175
  errors: ParseError[],
1032
1176
  ): void {
1033
1177
  if (convert) return; // deliberate type change acknowledged
1034
- // Compare both axes at once via the type-label: subtype names never contain
1035
- // "[]", so equal labels ⇔ same subType AND same array-ness (nullability is
1036
- // deliberately not judged — an outer-join view legitimately widens NOT NULL).
1037
- const declared = `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`;
1038
- const source = `field.${fromField.subType}${fromField.resolvedIsArray() ? "[]" : ""}`;
1178
+ const declared = _typeLabel(field);
1179
+ const source = _typeLabel(fromField);
1039
1180
  if (declared === source) return;
1040
1181
  errors.push(
1041
1182
  new ParseError(
@@ -1057,6 +1198,10 @@ function _checkPassthroughType(
1057
1198
  * and carries no vocabulary. Shared by `@agg:collect` (element order) and
1058
1199
  * `origin.first` (row selection). A missing related entity means a prior error
1059
1200
  * already fired — skip silently.
1201
+ *
1202
+ * `code` lets the whole-object `@agg:collect` arm report ERR_COLLECT_WHOLE_OBJECT
1203
+ * instead; it defaults to ERR_INVALID_ORIGIN so the scalar `@of` and `origin.first`
1204
+ * call sites keep their existing envelope byte-for-byte.
1060
1205
  */
1061
1206
  function _validateOrderByKeys(
1062
1207
  orderBy: unknown,
@@ -1066,6 +1211,7 @@ function _validateOrderByKeys(
1066
1211
  label: string,
1067
1212
  originSource: ErrorSource,
1068
1213
  errors: ParseError[],
1214
+ code: ErrorCode = "ERR_INVALID_ORIGIN",
1069
1215
  ): void {
1070
1216
  if (!Array.isArray(orderBy) || relatedEntity === undefined) return;
1071
1217
  for (const raw of orderBy) {
@@ -1079,14 +1225,14 @@ function _validateOrderByKeys(
1079
1225
  errors.push(
1080
1226
  new ParseError(
1081
1227
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — no such field "${key}" on ${relatedEntity.name}.`,
1082
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1228
+ { code, source: originSource },
1083
1229
  ),
1084
1230
  );
1085
1231
  } else if (dir !== undefined && !(SORT_ORDER_VALUES as readonly string[]).includes(dir)) {
1086
1232
  errors.push(
1087
1233
  new ParseError(
1088
1234
  `${label} on ${obj.name}.${fieldName}: @orderBy key "${raw}" — direction must be one of ${SORT_ORDER_VALUES.join("|")}.`,
1089
- { code: "ERR_INVALID_ORIGIN", source: originSource },
1235
+ { code, source: originSource },
1090
1236
  ),
1091
1237
  );
1092
1238
  }
@@ -1156,9 +1302,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1156
1302
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1157
1303
  const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA);
1158
1304
  if (typeof via === "string" && via !== "") {
1159
- const hops = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1160
- if (hops !== undefined) {
1161
- _checkPassthroughCardinality(hops, obj, field.name, origin.source, errors);
1305
+ const walked = _validateViaPath(via, root, obj, field.name, origin.source, errors);
1306
+ if (walked !== undefined) {
1307
+ _checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors);
1162
1308
  }
1163
1309
  } else if (fromTarget !== undefined && !isValueHost) {
1164
1310
  // FR-024 §6 — no @via: derive the base entity; a @from targeting
@@ -1243,17 +1389,89 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1243
1389
  `origin.aggregate @agg:${String(agg)} on ${obj.name}.${field.name}: requires an explicit @via (a quantifier has no @of to infer the path from).`,
1244
1390
  { code: "ERR_INVALID_ORIGIN", source: src }));
1245
1391
  } else {
1246
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1247
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1392
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1393
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1248
1394
  }
1249
1395
  continue;
1250
1396
  }
1251
1397
 
1252
- // --- count/sum/avg/min/max/collect: @of REQUIRED ---
1398
+ // --- @of: REQUIRED for count/sum/avg/min/max; OPTIONAL for collect ---
1399
+ // #335 — an @of-absent collect is a WHOLE-OBJECT rollup: collect the
1400
+ // related rows as an array of the field's declared @objectRef value
1401
+ // object rather than an array of one scalar column.
1253
1402
  if (!ofPresent) {
1254
- errors.push(new ParseError(
1255
- `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1256
- { code: "ERR_INVALID_ORIGIN", source: src }));
1403
+ if (!isCollect) {
1404
+ errors.push(new ParseError(
1405
+ `origin.aggregate on ${obj.name}.${field.name}: missing @of.`,
1406
+ { code: "ERR_INVALID_ORIGIN", source: src }));
1407
+ continue;
1408
+ }
1409
+ // Whole-object rollup. The carrying field must be a field.object
1410
+ // naming a value object, and @via must be explicit (there is no @of
1411
+ // entity to infer the single-hop relation from).
1412
+ // ADR-0039: resolving — @objectRef may be inherited via extends.
1413
+ const objectRef = field.attr(FIELD_ATTR_OBJECT_REF);
1414
+ if (field.subType !== FIELD_SUBTYPE_OBJECT || typeof objectRef !== "string" || objectRef === "") {
1415
+ errors.push(new ParseError(
1416
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @of is omitted, so this is a ` +
1417
+ `whole-object rollup — the carrying field must be a field.object declaring @objectRef ` +
1418
+ `(add @of to collect a single column instead).`,
1419
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1420
+ continue;
1421
+ }
1422
+ // #210's value-only rule is PAYLOAD-scoped and never reaches a
1423
+ // projection-hosted field, so this branch enforces it itself.
1424
+ // Without it an @objectRef to an entity silently rolls up the FULL
1425
+ // entity — the #270 shape, this time baked into DDL.
1426
+ // ADR-0042 — a bare @objectRef resolves in the DECLARING owner's
1427
+ // package (an inherited field resolves in the package that
1428
+ // declared it) — same rule _checkNestedPayloadRefsValueOnly uses.
1429
+ const refOwner = field.parent ?? obj;
1430
+ const refPkg = refOwner.package ?? refOwner.fileDefaultPackage ?? "";
1431
+ const refTarget = resolveObjectRef(root, objectRef, refPkg).node;
1432
+ if (refTarget !== undefined && refTarget.subType !== OBJECT_SUBTYPE_VALUE) {
1433
+ errors.push(new ParseError(
1434
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @objectRef '${objectRef}' ` +
1435
+ `resolves to ${TYPE_OBJECT}.${refTarget.subType} — a whole-object rollup must target an ` +
1436
+ `object.value (#210, ADR-0028).`,
1437
+ { code: "ERR_SUBTYPE_RULE_VIOLATION", source: src }));
1438
+ continue;
1439
+ }
1440
+ // ADR-0039: own — origin.* never inherits (ADR-0029).
1441
+ const viaAttr = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1442
+ if (typeof viaAttr !== "string" || viaAttr === "") {
1443
+ errors.push(new ParseError(
1444
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @via is required on a ` +
1445
+ `whole-object rollup — there is no @of entity to infer the relationship from.`,
1446
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1447
+ continue;
1448
+ }
1449
+ // @distinct is refused on the object form. It is NOT an engine limit
1450
+ // (both engines dedupe JSON objects); it is a guaranteed no-op
1451
+ // whenever the value object carries the entity's primary key, which
1452
+ // is the common case, and a silent no-op is worse than a refusal.
1453
+ if (hasDistinct) {
1454
+ errors.push(new ParseError(
1455
+ `origin.aggregate @agg:collect on ${obj.name}.${field.name}: @distinct is not supported on a ` +
1456
+ `whole-object rollup (it is a no-op whenever the value object carries the primary key).`,
1457
+ { code: "ERR_COLLECT_WHOLE_OBJECT", source: src }));
1458
+ continue;
1459
+ }
1460
+ // One walk yields both the hops (cardinality) and the terminal
1461
+ // entity (@orderBy keys, member resolution). An invalid @via
1462
+ // (e.g. single-segment "A") returns undefined having already
1463
+ // pushed its own error, so everything downstream is skipped and
1464
+ // no second, misleadingly-scoped error is emitted.
1465
+ const via = _validateViaPath(viaAttr, root, obj, field.name, src, errors);
1466
+ if (via !== undefined) {
1467
+ _checkAggregateCardinality(via.hops, obj, field.name, src, errors);
1468
+ // @orderBy keys resolve against the @via TERMINAL entity, not @of.
1469
+ _validateOrderByKeys(orderBy, via.terminal, obj, field.name, "origin.aggregate @agg:collect", src, errors,
1470
+ "ERR_COLLECT_WHOLE_OBJECT");
1471
+ if (refTarget !== undefined) {
1472
+ _checkCollectMembers(refTarget, via.terminal, obj, field, src, errors);
1473
+ }
1474
+ }
1257
1475
  continue;
1258
1476
  }
1259
1477
  // NOTE (FR-024 B6): NO extends/origin agreement check on aggregates —
@@ -1274,9 +1492,9 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1274
1492
  // ADR-0039: own — origin.* never inherits (ADR-0029).
1275
1493
  const via = origin.ownAttr(ORIGIN_AGGREGATE_ATTR_VIA);
1276
1494
  if (typeof via === "string" && via !== "") {
1277
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1278
- if (hops !== undefined) {
1279
- _checkAggregateCardinality(hops, obj, field.name, src, errors);
1495
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1496
+ if (walked !== undefined) {
1497
+ _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1280
1498
  }
1281
1499
  continue;
1282
1500
  }
@@ -1369,8 +1587,8 @@ export function validateOriginPaths(root: MetaData): ParseError[] {
1369
1587
  // @via — explicit (validated + cardinality) or single-hop-unique inferred.
1370
1588
  const via = origin.ownAttr(ORIGIN_FIRST_ATTR_VIA);
1371
1589
  if (typeof via === "string" && via !== "") {
1372
- const hops = _validateViaPath(via, root, obj, field.name, src, errors);
1373
- if (hops !== undefined) _checkAggregateCardinality(hops, obj, field.name, src, errors);
1590
+ const walked = _validateViaPath(via, root, obj, field.name, src, errors);
1591
+ if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors);
1374
1592
  } else if (ofTarget !== undefined) {
1375
1593
  // (A value host never reaches here — the #210 assembly-origin
1376
1594
  // check above already rejected origin.first on a value.)
@@ -1887,12 +2105,32 @@ export function validateRelationships(root: MetaData): ParseError[] {
1887
2105
  // (defaultValidationRegistry → a declarative reference descriptor with dottedFieldPath).
1888
2106
 
1889
2107
  // ---------------------------------------------------------------------------
1890
- // index.lookup @fields resolution (Task 3)
2108
+ // Index-key resolution for index.lookup AND identity.secondary (#342)
2109
+ //
2110
+ // An index declares its key EXACTLY ONE of two ways: plain columns (@fields) or
2111
+ // a key expression (@expr, e.g. `lower(email)` / `(payload->>'device_id')`).
2112
+ // The registry has always said so — @expr is described as "Used INSTEAD of
2113
+ // @fields" — and `migrate-ts` has always implemented it that way
2114
+ // (`columns: expr ? [] : cols`, expected-schema.ts). Only the LOADER disagreed,
2115
+ // requiring @fields unconditionally, which made an expression index
2116
+ // unreachable: omitting @fields failed to load, and the one spelling that DID
2117
+ // load (@fields AND @expr) had its @fields silently discarded by the engine.
2118
+ //
2119
+ // So the two rules below are one rule — the key is @fields XOR @expr:
2120
+ // - NEITHER: nothing declares the key.
2121
+ // - BOTH: contradictory, and previously half-honored. Rejected rather than
2122
+ // given a precedence rule, because an accepted-but-half-ignored declaration
2123
+ // is exactly the silent-wrong-output the sealed strict registry exists to
2124
+ // prevent (cf. ERR_SQL_BODY_WITH_UNMANAGED — @sql vs @unmanaged is the same
2125
+ // "two mutually exclusive non-default states of one axis" shape).
2126
+ //
2127
+ // Applies to identity.secondary too: per ADR-0040 uniqueness lives in the TYPE,
2128
+ // so identity.secondary IS a unique index and keys itself identically. Both
2129
+ // carry @expr from the same db provider, and migrate-ts branches on @expr for
2130
+ // both — the loader was the only tier treating them differently.
1891
2131
  //
1892
- // Every index.lookup on an entity must name at least one field, and every
1893
- // named field must exist in the entity's EFFECTIVE (resolved) field set.
1894
- // ADR-0039: use children() / MetaIndex.fields() — never own* — so that a
1895
- // field inherited via extends still resolves correctly.
2132
+ // ADR-0039: children() / MetaIndex.fields() never own* so a field inherited
2133
+ // via extends still resolves.
1896
2134
  // ---------------------------------------------------------------------------
1897
2135
 
1898
2136
  export function validateIndexLookupFields(root: MetaData): ParseError[] {
@@ -1903,34 +2141,87 @@ export function validateIndexLookupFields(root: MetaData): ParseError[] {
1903
2141
  const effectiveFieldNames = new Set(
1904
2142
  obj.children().filter((c) => c.type === TYPE_FIELD).map((f) => f.name),
1905
2143
  );
1906
- for (const node of obj.children().filter(
1907
- (c) => c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP,
1908
- )) {
1909
- // MetaIndex.fields() uses the resolving attr() accessor per ADR-0039.
1910
- const idx = node as MetaIndex;
1911
- const fields = idx.fields();
1912
-
1913
- // Rule 1: must have at least one field.
1914
- if (fields.length === 0) {
2144
+ const keyed = obj.children().filter(
2145
+ (c) =>
2146
+ (c.type === TYPE_INDEX && c.subType === INDEX_SUBTYPE_LOOKUP) ||
2147
+ (c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_SECONDARY),
2148
+ );
2149
+ for (const node of keyed) {
2150
+ const label = `${node.type}.${node.subType}`;
2151
+ // PRESENCE vs CONTENT are two different questions here, and conflating them
2152
+ // is a bug in both directions:
2153
+ //
2154
+ // - The CONTRADICTION check needs PRESENCE. `@fields: []` alongside @expr
2155
+ // is still a declaration of both, and keying it on non-emptiness let the
2156
+ // total-discard spelling load clean while `@fields: ["x"]` + @expr was
2157
+ // refused — the rule missing exactly the case it exists to catch.
2158
+ // - The KEY-RESOLUTION check needs normalized CONTENT, via the guarded
2159
+ // accessor. Reading the raw attr with a cast meant a scalar `@fields: 5`
2160
+ // threw an uncaught TypeError out of load() in TS while the other three
2161
+ // ports reported a clean error.
2162
+ //
2163
+ // The guarded accessor is the fix for the second and the OBSTACLE for the
2164
+ // first — it collapses absent, scalar and explicit `[]` to the same `[]` —
2165
+ // so the two questions are asked separately and never routed through one
2166
+ // predicate. ADR-0039: both reads resolve through extends.
2167
+ const hasFieldsAttr = node.attr(IDENTITY_ATTR_FIELDS) !== undefined;
2168
+ // MetaIndex.fields() / MetaIdentity.fields are the same guarded read
2169
+ // (`Array.isArray(f) ? f : []`); never re-hand-roll it.
2170
+ const fields =
2171
+ node instanceof MetaIndex ? node.fields() : (node as MetaIdentity).fields;
2172
+ const exprRaw = node.attr(IDENTITY_ATTR_EXPR);
2173
+ const hasExpr = typeof exprRaw === "string" && exprRaw.trim().length > 0;
2174
+
2175
+ // Rule 1a: exactly one of @fields / @expr may be DECLARED.
2176
+ //
2177
+ // The closing sentence comes from `attr-contradictions.ts`, which is also what
2178
+ // `meta upgrade` rewrites from — so the fix an adopter is TOLD about and the edit
2179
+ // the tool MAKES are one statement, and a change to either moves both. #337 is the
2180
+ // reason it names the command at all: an adopter shown only that their metadata is
2181
+ // invalid concludes the tool has a bug, because nothing points at the way out.
2182
+ if (hasFieldsAttr && hasExpr) {
2183
+ const contradiction = contradictionsFor(label).find(
2184
+ (c) => c.drop === INDEX_ATTR_FIELDS && c.keep === IDENTITY_ATTR_EXPR,
2185
+ );
1915
2186
  errors.push(
1916
2187
  new ParseError(
1917
- `index.lookup "${idx.name}" on "${obj.name}" has no @${INDEX_ATTR_FIELDS}; ` +
1918
- `at least one field is required`,
1919
- { code: "ERR_INVALID_INDEX", source: idx.source },
2188
+ // The site states WHICH node broke the rule; the table states the RULE and the
2189
+ // fix. Splitting it that way is what keeps one copy of each.
2190
+ `${label} "${node.name}" on "${obj.name}" declares BOTH ` +
2191
+ `@${INDEX_ATTR_FIELDS} and @${IDENTITY_ATTR_EXPR}: ` +
2192
+ (contradiction === undefined
2193
+ ? `@${IDENTITY_ATTR_EXPR} is used INSTEAD of @${INDEX_ATTR_FIELDS} — drop one.`
2194
+ : contradictionHint(contradiction)),
2195
+ { code: "ERR_INVALID_INDEX", source: node.source },
1920
2196
  ),
1921
2197
  );
1922
2198
  continue;
1923
2199
  }
1924
2200
 
2201
+ // Rule 1b: whichever is declared must actually supply a key.
2202
+ if (fields.length === 0 && !hasExpr) {
2203
+ errors.push(
2204
+ new ParseError(
2205
+ `${label} "${node.name}" on "${obj.name}" declares no key: ` +
2206
+ `it must have @${INDEX_ATTR_FIELDS} (one or more columns) or ` +
2207
+ `@${IDENTITY_ATTR_EXPR} (a key expression)`,
2208
+ { code: "ERR_INVALID_INDEX", source: node.source },
2209
+ ),
2210
+ );
2211
+ continue;
2212
+ }
1925
2213
  // Rule 2: every named field must resolve against the entity's effective field set.
2214
+ // An expression index has no @fields to resolve — @expr is raw SQL over the
2215
+ // physical columns, deliberately not parsed here (ADR-0023 keeps the grammar
2216
+ // closed only where the loader owns it).
1926
2217
  for (const fieldName of fields) {
1927
2218
  if (!effectiveFieldNames.has(fieldName)) {
1928
2219
  errors.push(
1929
2220
  new ParseError(
1930
- `index.lookup "${idx.name}" on "${obj.name}" references field "${fieldName}" ` +
2221
+ `${label} "${node.name}" on "${obj.name}" references field "${fieldName}" ` +
1931
2222
  `which does not exist on "${obj.name}". ` +
1932
2223
  `Available fields: ${[...effectiveFieldNames].join(", ") || "(none)"}`,
1933
- { code: "ERR_INVALID_INDEX", source: idx.source },
2224
+ { code: "ERR_INVALID_INDEX", source: node.source },
1934
2225
  ),
1935
2226
  );
1936
2227
  }
@@ -2119,3 +2410,75 @@ function checkProjectionFilterRefs(
2119
2410
  }
2120
2411
  }
2121
2412
  }
2413
+
2414
+ // ---------------------------------------------------------------------------
2415
+ // FR-039 — a retired requirement's link vocabulary
2416
+ //
2417
+ // Two rules, and the first is the whole structural point of the FR.
2418
+ //
2419
+ // 1. `@status: retired` may NOT carry `@implementedBy`. Not exempt from the
2420
+ // dangling-reference check — REFUSED. 0.24.0 removed the previous retired
2421
+ // vocabulary because `verify` was SILENT on dangling refs for it, hiding 29
2422
+ // unresolvable references across 14 entries in one estate while reporting
2423
+ // zero. That silence was a deliberate EXEMPTION, so the fix was diagnosed as
2424
+ // "delete the vocabulary". Forbidding the attribute makes the bug class
2425
+ // UNREACHABLE instead: a retired capability has no implementation by
2426
+ // definition, so its references cannot dangle because they cannot exist.
2427
+ //
2428
+ // 2. `@supersededBy` is legal ONLY on `retired`. It names what REPLACED a
2429
+ // withdrawn capability; on a live one there is nothing to have replaced it.
2430
+ // Resolution of the reference itself is `verify`'s job (it owns every other
2431
+ // @implementedBy-style resolution) — the loader owns the shape.
2432
+ //
2433
+ // Runs in every port. `requirement.*` gate logic otherwise lives only in the TS
2434
+ // CLI, which is exactly why THIS rule belongs in the loader: a Java or Python
2435
+ // estate would otherwise be free to author the shape the rule exists to prevent.
2436
+ //
2437
+ // ADR-0039: children()/attr() throughout — never own* — so a requirement that
2438
+ // inherits its status through `extends` is judged on its EFFECTIVE status.
2439
+ // ---------------------------------------------------------------------------
2440
+
2441
+ export function validateRetiredRequirementLinks(root: MetaData): ParseError[] {
2442
+ const errors: ParseError[] = [];
2443
+
2444
+ const walk = (node: MetaData): void => {
2445
+ for (const child of node.children()) {
2446
+ if (child.type === REQUIREMENT) {
2447
+ const status = child.attr(REQUIREMENT_ATTR_STATUS);
2448
+ const isRetired = status === REQUIREMENT_STATUS_RETIRED;
2449
+
2450
+ if (isRetired && child.attr(REQUIREMENT_ATTR_IMPLEMENTED_BY) !== undefined) {
2451
+ errors.push(
2452
+ new ParseError(
2453
+ `requirement.${child.subType} "${child.name}" is @status: ` +
2454
+ `${REQUIREMENT_STATUS_RETIRED} and declares @${REQUIREMENT_ATTR_IMPLEMENTED_BY}. ` +
2455
+ `A retired capability has no implementation — that is what retiring it means. ` +
2456
+ `Delete the attribute; if the nodes are still there, the capability is not retired. ` +
2457
+ `What used to implement it belongs in \`notes\`, and what REPLACED it in ` +
2458
+ `@${REQUIREMENT_ATTR_SUPERSEDED_BY}.`,
2459
+ { code: "ERR_REQUIREMENT_RETIRED_HAS_IMPLEMENTORS", source: child.source },
2460
+ ),
2461
+ );
2462
+ }
2463
+
2464
+ if (!isRetired && child.attr(REQUIREMENT_ATTR_SUPERSEDED_BY) !== undefined) {
2465
+ errors.push(
2466
+ new ParseError(
2467
+ `requirement.${child.subType} "${child.name}" declares ` +
2468
+ `@${REQUIREMENT_ATTR_SUPERSEDED_BY} but its @${REQUIREMENT_ATTR_STATUS} is ` +
2469
+ `"${status ?? "(absent)"}". That attribute names the requirement which REPLACED a ` +
2470
+ `withdrawn one, so it is legal only on @${REQUIREMENT_ATTR_STATUS}: ` +
2471
+ `${REQUIREMENT_STATUS_RETIRED}.`,
2472
+ { code: "ERR_REQUIREMENT_SUPERSEDED_BY_NOT_RETIRED", source: child.source },
2473
+ ),
2474
+ );
2475
+ }
2476
+ }
2477
+ // Hierarchy IS nesting, so a retired requirement can sit at any depth.
2478
+ walk(child);
2479
+ }
2480
+ };
2481
+
2482
+ walk(root);
2483
+ return errors;
2484
+ }