diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index 8b3e342ee..4fde2917a 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -29,6 +29,7 @@ "ERR_MALFORMED_YAML": "The YAML metadata input is not valid YAML, or cannot be desugared into canonical metadata.", "ERR_INVALID_ORIGIN": "A field origin (passthrough/aggregate) declares an invalid path or attribute.", "ERR_AMBIGUOUS_PATH": "FR-024 (ADR-0029): an implicit (omitted-@via) origin path is ambiguous — more than one single-hop relationship leads from the base entity to the @from/@of entity (the error names the candidates), or a projection's base entity cannot be derived because its fields extend multiple entities and no extended identity anchors the base.", + "ERR_AMBIGUOUS_REF": "Cross-package reference contract — a BARE reference (no `::`) names an object that exists in more than one package and none is in the referrer's own package. The reference is ambiguous; qualify it with the package (FQN). Applies to every object-ref-bearing attribute (@objectRef, @references, @from/@of/@via heads, extends, @payloadRef/@responseRef, @through).", "ERR_ORIGIN_CARDINALITY": "FR-024 (ADR-0029): origin cardinality contract broken — a passthrough @via path crosses a to-many hop (row-multiplying passthrough — you meant aggregate), or an aggregate @via path is to-one at every hop (aggregating over a to-one path — you meant passthrough). Checked on explicit AND inferred paths.", "ERR_EXTENDS_ORIGIN_MISMATCH": "FR-024 (ADR-0029 decision 7): a field declares both an entity-nested extends (shape lineage) and an origin.passthrough @from (data lineage) and they disagree — the resolved @from target is not the field's resolved extends target (nor anywhere on its extends chain). Host-agnostic (projections, entities, values). origin.aggregate is never judged (it computes something new); a top-level abstract extends target is never judged (shape-only reuse makes no lineage claim).", "ERR_DERIVED_FIELD_NO_READ_SOURCE": "FR-024 (spec §7): an object.entity field carrying an origin.* child is derived (read-only) and must be providable — the entity must declare at least one source with a read-only @kind (view/materializedView/storedProc/tableFunction, e.g. a @role replica view beside the table primary). Table-only or source-less entities with origin-bearing fields error on the field. Projections and object.value hosts are exempt. Until the Phase-E B4b cutover removes view-primary entities, a read-only-kind PRIMARY source also counts as providable (legacy spelling).", diff --git a/fixtures/conformance/error-xpkg-ambiguous-objectref/expected-errors.json b/fixtures/conformance/error-xpkg-ambiguous-objectref/expected-errors.json new file mode 100644 index 000000000..7a2f34b1a --- /dev/null +++ b/fixtures/conformance/error-xpkg-ambiguous-objectref/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_AMBIGUOUS_REF", + "source": { + "format": "resolved", + "files": [ + "meta.sales.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[4]['relationship.association']", + "referrer": "Order", + "target": "Customer" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.crm.json b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.crm.json new file mode 100644 index 000000000..26b19479e --- /dev/null +++ b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.crm.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "xpkg::crm", + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "crm_customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.sales.json b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.sales.json new file mode 100644 index 000000000..416aa73d4 --- /dev/null +++ b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.sales.json @@ -0,0 +1,44 @@ +{ + "metadata.root": { + "package": "xpkg::sales", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "customerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "customer", + "@cardinality": "one", + "@objectRef": "Customer" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.vendor.json b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.vendor.json new file mode 100644 index 000000000..3fd7918ca --- /dev/null +++ b/fixtures/conformance/error-xpkg-ambiguous-objectref/input/meta.vendor.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "xpkg::vendor", + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "vendor_customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-xpkg-ambiguous-objectref/providers.json b/fixtures/conformance/error-xpkg-ambiguous-objectref/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/error-xpkg-ambiguous-objectref/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json b/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json new file mode 100644 index 000000000..4dc953b29 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-bare-same-package/expected-effective.json @@ -0,0 +1,80 @@ +{ + "metadata.root": { + "package": "xpkg::home", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "homeOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Gadget", + "extends": "Base", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "homeOnly" + } + }, + { + "source.rdb": { + "@table": "gadgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "otherOnly" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/expected.json b/fixtures/conformance/xpkg-collision-bare-same-package/expected.json new file mode 100644 index 000000000..6f918a471 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-bare-same-package/expected.json @@ -0,0 +1,70 @@ +{ + "metadata.root": { + "package": "xpkg::home", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "homeOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Gadget", + "extends": "Base", + "children": [ + { + "source.rdb": { + "@table": "gadgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "otherOnly" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.home.json b/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.home.json new file mode 100644 index 000000000..b2010f788 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.home.json @@ -0,0 +1,51 @@ +{ + "metadata.root": { + "package": "xpkg::home", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "homeOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Gadget", + "extends": "Base", + "children": [ + { + "source.rdb": { + "@table": "gadgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.other.json b/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.other.json new file mode 100644 index 000000000..2b55ccc27 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-bare-same-package/input/meta.other.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "xpkg::other", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "otherOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-collision-bare-same-package/providers.json b/fixtures/conformance/xpkg-collision-bare-same-package/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-bare-same-package/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json b/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json new file mode 100644 index 000000000..2f9af7361 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/expected-effective.json @@ -0,0 +1,80 @@ +{ + "metadata.root": { + "package": "xpkg::a", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "aOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "bOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Widget", + "extends": "xpkg::b::Base", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "bOnly" + } + }, + { + "source.rdb": { + "@table": "widgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/expected.json b/fixtures/conformance/xpkg-collision-extends-fqn/expected.json new file mode 100644 index 000000000..25dc443ce --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/expected.json @@ -0,0 +1,70 @@ +{ + "metadata.root": { + "package": "xpkg::a", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "aOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "bOnly" + } + } + ] + } + }, + { + "object.entity": { + "name": "Widget", + "extends": "xpkg::b::Base", + "children": [ + { + "source.rdb": { + "@table": "widgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.a.json b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.a.json new file mode 100644 index 000000000..ce858d6d0 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.a.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "xpkg::a", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "aOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.b.json b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.b.json new file mode 100644 index 000000000..751f0cda4 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.b.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "xpkg::b", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "bOnly" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.c.json b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.c.json new file mode 100644 index 000000000..e87573c6b --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/input/meta.c.json @@ -0,0 +1,33 @@ +{ + "metadata.root": { + "package": "xpkg::c", + "children": [ + { + "object.entity": { + "name": "Widget", + "extends": "xpkg::b::Base", + "children": [ + { + "source.rdb": { + "@table": "widgets" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-collision-extends-fqn/providers.json b/fixtures/conformance/xpkg-collision-extends-fqn/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/xpkg-collision-extends-fqn/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-field-object/expected-effective.json b/fixtures/conformance/xpkg-field-object/expected-effective.json new file mode 100644 index 000000000..8dd657d47 --- /dev/null +++ b/fixtures/conformance/xpkg-field-object/expected-effective.json @@ -0,0 +1,57 @@ +{ + "metadata.root": { + "package": "xpkg::common", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Shipment", + "children": [ + { + "source.rdb": { + "@table": "shipments" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "xpkg::common::Address", + "@storage": "jsonb" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-field-object/expected.json b/fixtures/conformance/xpkg-field-object/expected.json new file mode 100644 index 000000000..8dd657d47 --- /dev/null +++ b/fixtures/conformance/xpkg-field-object/expected.json @@ -0,0 +1,57 @@ +{ + "metadata.root": { + "package": "xpkg::common", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Shipment", + "children": [ + { + "source.rdb": { + "@table": "shipments" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "xpkg::common::Address", + "@storage": "jsonb" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-field-object/input/meta.common.json b/fixtures/conformance/xpkg-field-object/input/meta.common.json new file mode 100644 index 000000000..fcd03f663 --- /dev/null +++ b/fixtures/conformance/xpkg-field-object/input/meta.common.json @@ -0,0 +1,24 @@ +{ + "metadata.root": { + "package": "xpkg::common", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-field-object/input/meta.orders.json b/fixtures/conformance/xpkg-field-object/input/meta.orders.json new file mode 100644 index 000000000..0b899e3f8 --- /dev/null +++ b/fixtures/conformance/xpkg-field-object/input/meta.orders.json @@ -0,0 +1,39 @@ +{ + "metadata.root": { + "package": "xpkg::orders", + "children": [ + { + "object.entity": { + "name": "Shipment", + "children": [ + { + "source.rdb": { + "@table": "shipments" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "xpkg::common::Address", + "@storage": "jsonb" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-field-object/providers.json b/fixtures/conformance/xpkg-field-object/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/xpkg-field-object/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-m2n-through/expected-effective.json b/fixtures/conformance/xpkg-m2n-through/expected-effective.json new file mode 100644 index 000000000..ebd7cd49c --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/expected-effective.json @@ -0,0 +1,119 @@ +{ + "metadata.root": { + "package": "xpkg::blog", + "children": [ + { + "object.entity": { + "name": "Post", + "children": [ + { + "source.rdb": { + "@table": "posts" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "tags", + "@cardinality": "many", + "@objectRef": "xpkg::blog::Tag", + "@through": "xpkg::join::PostTag" + } + } + ] + } + }, + { + "object.entity": { + "name": "Tag", + "children": [ + { + "source.rdb": { + "@table": "tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk2", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "PostTag", + "children": [ + { + "source.rdb": { + "@table": "post_tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "postId" + } + }, + { + "field.long": { + "name": "tagId" + } + }, + { + "identity.primary": { + "name": "pk3", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_post", + "@fields": [ + "postId" + ], + "@references": "xpkg::blog::Post" + } + }, + { + "identity.reference": { + "name": "ref_tag", + "@fields": [ + "tagId" + ], + "@references": "xpkg::blog::Tag" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-m2n-through/expected.json b/fixtures/conformance/xpkg-m2n-through/expected.json new file mode 100644 index 000000000..ebd7cd49c --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/expected.json @@ -0,0 +1,119 @@ +{ + "metadata.root": { + "package": "xpkg::blog", + "children": [ + { + "object.entity": { + "name": "Post", + "children": [ + { + "source.rdb": { + "@table": "posts" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "relationship.association": { + "name": "tags", + "@cardinality": "many", + "@objectRef": "xpkg::blog::Tag", + "@through": "xpkg::join::PostTag" + } + } + ] + } + }, + { + "object.entity": { + "name": "Tag", + "children": [ + { + "source.rdb": { + "@table": "tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk2", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "PostTag", + "children": [ + { + "source.rdb": { + "@table": "post_tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "postId" + } + }, + { + "field.long": { + "name": "tagId" + } + }, + { + "identity.primary": { + "name": "pk3", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_post", + "@fields": [ + "postId" + ], + "@references": "xpkg::blog::Post" + } + }, + { + "identity.reference": { + "name": "ref_tag", + "@fields": [ + "tagId" + ], + "@references": "xpkg::blog::Tag" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-m2n-through/input/meta.blog.json b/fixtures/conformance/xpkg-m2n-through/input/meta.blog.json new file mode 100644 index 000000000..2cacaa98c --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/input/meta.blog.json @@ -0,0 +1,57 @@ +{ + "metadata.root": { + "package": "xpkg::blog", + "children": [ + { + "object.entity": { + "name": "Post", + "children": [ + { + "source.rdb": { + "@table": "posts" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Tag", + "children": [ + { + "source.rdb": { + "@table": "tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk2", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-through/input/meta.join.json b/fixtures/conformance/xpkg-m2n-through/input/meta.join.json new file mode 100644 index 000000000..3cc27cbf1 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/input/meta.join.json @@ -0,0 +1,60 @@ +{ + "metadata.root": { + "package": "xpkg::join", + "children": [ + { + "object.entity": { + "name": "PostTag", + "children": [ + { + "source.rdb": { + "@table": "post_tags" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "postId" + } + }, + { + "field.long": { + "name": "tagId" + } + }, + { + "identity.primary": { + "name": "pk3", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_post", + "@fields": [ + "postId" + ], + "@references": "xpkg::blog::Post" + } + }, + { + "identity.reference": { + "name": "ref_tag", + "@fields": [ + "tagId" + ], + "@references": "xpkg::blog::Tag" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-through/input/meta.rel.json b/fixtures/conformance/xpkg-m2n-through/input/meta.rel.json new file mode 100644 index 000000000..7ddae0b70 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/input/meta.rel.json @@ -0,0 +1,24 @@ +{ + "metadata.root": { + "package": "xpkg::blog", + "overlay": true, + "children": [ + { + "object.entity": { + "name": "Post", + "overlay": true, + "children": [ + { + "relationship.association": { + "name": "tags", + "@cardinality": "many", + "@objectRef": "xpkg::blog::Tag", + "@through": "xpkg::join::PostTag" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-m2n-through/providers.json b/fixtures/conformance/xpkg-m2n-through/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/xpkg-m2n-through/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json b/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json new file mode 100644 index 000000000..e31b28377 --- /dev/null +++ b/fixtures/conformance/xpkg-origin-passthrough/expected-effective.json @@ -0,0 +1,119 @@ +{ + "metadata.root": { + "package": "xpkg::catalog", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.string": { + "name": "name" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Enrollment", + "children": [ + { + "source.rdb": { + "@table": "enrollments" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.uuid": { + "name": "programId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_program", + "@fields": [ + "programId" + ], + "@references": "xpkg::catalog::Program" + } + } + ] + } + }, + { + "object.projection": { + "name": "EnrollmentView", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_enrollment" + } + }, + { + "field.uuid": { + "name": "enrollmentId", + "extends": "xpkg::enroll::Enrollment.id", + "@column": "enrollment_id" + } + }, + { + "field.string": { + "name": "programName", + "children": [ + { + "origin.passthrough": { + "@from": "xpkg::catalog::Program.name", + "@via": "xpkg::enroll::Enrollment.ref_program" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "xpkg::enroll::Enrollment.id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-origin-passthrough/expected.json b/fixtures/conformance/xpkg-origin-passthrough/expected.json new file mode 100644 index 000000000..9ee18498f --- /dev/null +++ b/fixtures/conformance/xpkg-origin-passthrough/expected.json @@ -0,0 +1,116 @@ +{ + "metadata.root": { + "package": "xpkg::catalog", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.string": { + "name": "name" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Enrollment", + "children": [ + { + "source.rdb": { + "@table": "enrollments" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.uuid": { + "name": "programId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_program", + "@fields": [ + "programId" + ], + "@references": "xpkg::catalog::Program" + } + } + ] + } + }, + { + "object.projection": { + "name": "EnrollmentView", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_enrollment" + } + }, + { + "field.uuid": { + "name": "enrollmentId", + "extends": "xpkg::enroll::Enrollment.id", + "@column": "enrollment_id" + } + }, + { + "field.string": { + "name": "programName", + "children": [ + { + "origin.passthrough": { + "@from": "xpkg::catalog::Program.name", + "@via": "xpkg::enroll::Enrollment.ref_program" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "xpkg::enroll::Enrollment.id" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-origin-passthrough/input/meta.catalog.json b/fixtures/conformance/xpkg-origin-passthrough/input/meta.catalog.json new file mode 100644 index 000000000..281ce6e32 --- /dev/null +++ b/fixtures/conformance/xpkg-origin-passthrough/input/meta.catalog.json @@ -0,0 +1,37 @@ +{ + "metadata.root": { + "package": "xpkg::catalog", + "children": [ + { + "object.entity": { + "name": "Program", + "children": [ + { + "source.rdb": { + "@table": "programs" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.string": { + "name": "name" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-origin-passthrough/input/meta.enroll.json b/fixtures/conformance/xpkg-origin-passthrough/input/meta.enroll.json new file mode 100644 index 000000000..6b51d1835 --- /dev/null +++ b/fixtures/conformance/xpkg-origin-passthrough/input/meta.enroll.json @@ -0,0 +1,85 @@ +{ + "metadata.root": { + "package": "xpkg::enroll", + "children": [ + { + "object.entity": { + "name": "Enrollment", + "children": [ + { + "source.rdb": { + "@table": "enrollments" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.uuid": { + "name": "programId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_program", + "@fields": [ + "programId" + ], + "@references": "xpkg::catalog::Program" + } + } + ] + } + }, + { + "object.projection": { + "name": "EnrollmentView", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_enrollment" + } + }, + { + "field.uuid": { + "name": "enrollmentId", + "extends": "xpkg::enroll::Enrollment.id", + "@column": "enrollment_id" + } + }, + { + "field.string": { + "name": "programName", + "children": [ + { + "origin.passthrough": { + "@from": "xpkg::catalog::Program.name", + "@via": "xpkg::enroll::Enrollment.ref_program" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "xpkg::enroll::Enrollment.id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/xpkg-origin-passthrough/providers.json b/fixtures/conformance/xpkg-origin-passthrough/providers.json new file mode 100644 index 000000000..f9bb435a3 --- /dev/null +++ b/fixtures/conformance/xpkg-origin-passthrough/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json b/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json new file mode 100644 index 000000000..03d6837ea --- /dev/null +++ b/fixtures/conformance/xpkg-relationship-objectref/expected-effective.json @@ -0,0 +1,84 @@ +{ + "metadata.root": { + "package": "xpkg::crm", + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "email" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "customerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_customer", + "@fields": [ + "customerId" + ], + "@references": "xpkg::crm::Customer" + } + }, + { + "relationship.association": { + "name": "customer", + "@cardinality": "one", + "@objectRef": "xpkg::crm::Customer" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-relationship-objectref/expected.json b/fixtures/conformance/xpkg-relationship-objectref/expected.json new file mode 100644 index 000000000..03d6837ea --- /dev/null +++ b/fixtures/conformance/xpkg-relationship-objectref/expected.json @@ -0,0 +1,84 @@ +{ + "metadata.root": { + "package": "xpkg::crm", + "children": [ + { + "object.entity": { + "name": "Customer", + "children": [ + { + "source.rdb": { + "@table": "customers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "email" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "customerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "ref_customer", + "@fields": [ + "customerId" + ], + "@references": "xpkg::crm::Customer" + } + }, + { + "relationship.association": { + "name": "customer", + "@cardinality": "one", + "@objectRef": "xpkg::crm::Customer" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/xpkg-relationship-objectref/input/meta.crm.json b/fixtures/conformance/xpkg-relationship-objectref/input/meta.crm.json new file mode 100644 index 000000000..16fe6e723 --- /dev/null +++ b/fixtures/conformance/xpkg-relationship-objectref/input/meta.crm.json @@ -0,0 +1,8 @@ +{ "metadata.root": { "package": "xpkg::crm", "children": [ + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "email" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ]}} +]}} diff --git a/fixtures/conformance/xpkg-relationship-objectref/input/meta.sales.json b/fixtures/conformance/xpkg-relationship-objectref/input/meta.sales.json new file mode 100644 index 000000000..f9e04d2b4 --- /dev/null +++ b/fixtures/conformance/xpkg-relationship-objectref/input/meta.sales.json @@ -0,0 +1,10 @@ +{ "metadata.root": { "package": "xpkg::sales", "children": [ + { "object.entity": { "name": "Order", "children": [ + { "source.rdb": { "@table": "orders" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "customerId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "ref_customer", "@fields": ["customerId"], "@references": "xpkg::crm::Customer" } }, + { "relationship.association": { "name": "customer", "@cardinality": "one", "@objectRef": "xpkg::crm::Customer" } } + ]}} +]}} diff --git a/fixtures/conformance/xpkg-relationship-objectref/providers.json b/fixtures/conformance/xpkg-relationship-objectref/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/xpkg-relationship-objectref/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/server/csharp/MetaObjects/Errors.cs b/server/csharp/MetaObjects/Errors.cs index cd4a1a967..f68dfb03d 100644 --- a/server/csharp/MetaObjects/Errors.cs +++ b/server/csharp/MetaObjects/Errors.cs @@ -84,6 +84,15 @@ public enum ErrorCode // identity.reference @references names an FK target object that does not resolve // to any object in the loaded tree (a dangling cross-reference between metadata). ERR_INVALID_REFERENCE, + // ADR-0041 — cross-package reference contract. A BARE reference (no "::") names + // an object present in MORE THAN ONE package with NO match in the referrer's own + // package → ambiguous; the author must qualify it with the package (FQN). An FQN + // ref resolves exactly (never ambiguous); a bare ref unique anywhere, or matching + // the referrer's own package, resolves fine. Covers every object-ref-bearing attr + // (@objectRef / @references / @from|@of|@via heads / @payloadRef / @responseRef / + // @parameterRef). `extends` is EXCLUDED — its super-resolver is same-package/root- + // strict, so a bare cross-package extends is ERR_UNRESOLVED_SUPER, not ambiguous. + ERR_AMBIGUOUS_REF, ERR_BAD_ATTR_FILTER, ERR_STORAGE_WITHOUT_OBJECT_REF, // ADR-0013: a field.object REQUIRES @objectRef (open/untyped JSON uses the diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index b37173485..1e6c53f00 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -482,6 +482,11 @@ public LoadResult Load(IReadOnlyList sources) // cardinality, extends/origin agreement) errors.AddRange(ValidationPasses.ValidateOriginPaths(root)); + // Pass 5a: ADR-0041 — cross-package OBJECT-reference ambiguity. A bare + // ref (@objectRef/@references/@from|@of|@via head/@payloadRef/…) naming + // an object in multiple packages, none in the referrer's, → ERR_AMBIGUOUS_REF. + errors.AddRange(ValidationPasses.ValidateCrossPackageRefs(root)); + // Pass 5b: FR-024 B6 — derived-field providability on entities errors.AddRange(ValidationPasses.ValidateDerivedFieldProvidability(root)); diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 94d4d66fe..81e1e3b0f 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -494,6 +494,121 @@ internal static bool RefMatchesObject(MetaData node, string reference) || node.Fqn() == reference || node.Name == reference; + // ========================================================================= + // ADR-0041 — cross-package OBJECT reference resolution + ambiguity pass. + // Ported from typescript/packages/metadata/src/naming-refs.ts + // (resolveObjectRef) + loader/validation-passes.ts (validateCrossPackageRefs). + // ========================================================================= + + /// The inline (bare) attribute names whose VALUE names another OBJECT and is + /// therefore subject to the ADR-0041 cross-package resolution contract. Mirrors TS + /// REF_BEARING_ATTR_NAMES. extends is intentionally absent — its super- + /// resolver is same-package/root-strict (a bare cross-package extends is + /// ERR_UNRESOLVED_SUPER, not ambiguous). Attrs are keyed bare (no @) on the node. + private static readonly IReadOnlyList RefBearingAttrNames = new[] + { + RELATIONSHIP_ATTR_OBJECT_REF, // = FIELD_ATTR_OBJECT_REF ("objectRef") + IDENTITY_REFERENCE_ATTR_REFERENCES, // "references" + ORIGIN_PASSTHROUGH_ATTR_FROM, // "from" + ORIGIN_PASSTHROUGH_ATTR_VIA, // = ORIGIN_AGGREGATE_ATTR_VIA ("via") + ORIGIN_AGGREGATE_ATTR_OF, // "of" + SOURCE_ATTR_PARAMETER_REF, // "parameterRef" + TEMPLATE_ATTR_PAYLOAD_REF, // "payloadRef" + TEMPLATE_ATTR_RESPONSE_REF, // "responseRef" + }; + + /// The effective package of a root-level object (its + /// minus the trailing ::<name>; "" for a root-level/empty-package object). + /// Mirrors TS objectPackage. + private static string ObjectPackage(MetaData node) + { + string key = node.ResolutionKey(); + int i = key.LastIndexOf(PACKAGE_SEPARATOR, StringComparison.Ordinal); + return i == -1 ? "" : key[..i]; + } + + /// Outcome of : the uniquely resolved object + /// () or a cross-package-ambiguous bare ref (). + private readonly record struct ObjectRefResolution(MetaData? Node, bool Ambiguous); + + /// + /// Resolve a metadata OBJECT reference under the ADR-0041 cross-package contract: + /// - FQN ( contains ::) → EXACT match on + /// ResolutionKey()/Fqn(); never a bare-tail fallback, so an FQN pointing at one + /// package never binds a same-named object in another. + /// - bare (no ::) → prefer an object of that name in the REFERRER's own + /// package; else a UNIQUE object of that name anywhere; else (>1 across OTHER + /// packages, none in the referrer's) → ambiguous. + /// Mirrors TS resolveObjectRef(root, ref, referrerPkg). + /// + private static ObjectRefResolution ResolveObjectRef(MetaData root, string reference, string referrerPkg) + { + var objects = root.Children().Where(c => c.Type == TYPE_OBJECT).ToList(); + if (reference.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal)) + { + return new ObjectRefResolution( + objects.FirstOrDefault(c => c.ResolutionKey() == reference || c.Fqn() == reference), + false); + } + var matches = objects.Where(c => c.Name == reference).ToList(); + if (matches.Count <= 1) return new ObjectRefResolution(matches.Count == 1 ? matches[0] : null, false); + var own = matches.FirstOrDefault(c => ObjectPackage(c) == referrerPkg); + if (own is not null) return new ObjectRefResolution(own, false); + return new ObjectRefResolution(null, true); + } + + /// + /// ADR-0041 — a BARE object reference (no ::) that names an object present in + /// MORE THAN ONE package, with NO match in the referrer's own package, is ambiguous → + /// ERR_AMBIGUOUS_REF (the author must qualify it with the package). An FQN ref is exact + /// (never ambiguous); a bare ref matching the referrer's own package, or unique anywhere, + /// resolves fine. Covers every object-ref-bearing attr in + /// — the dotted .child tail of the origin heads is stripped to the entity OWNER. + /// extends is intentionally NOT covered (see ). + /// Mirrors TS validateCrossPackageRefs. + /// + public static IReadOnlyList ValidateCrossPackageRefs(MetaData root) + { + var errors = new List(); + foreach (var obj in root.Children().Where(c => c.Type == TYPE_OBJECT)) + { + // Referrer package = the object's own package, else its file-default — + // ObjectPackage(obj) folds both (ResolutionKey minus ::name), matching + // TS `obj.package ?? obj.fileDefaultPackage ?? ""`. + string referrerPkg = ObjectPackage(obj); + VisitCrossPackageRefs(obj, obj, root, referrerPkg, errors); + } + return errors.AsReadOnly(); + } + + private static void VisitCrossPackageRefs( + MetaData node, MetaData obj, MetaData root, string referrerPkg, List errors) + { + foreach (var attrName in RefBearingAttrNames) + { + if (node.OwnAttr(attrName) is not string raw) continue; + // Owner = the object part; strip any FR-024 dotted `.child` tail. An FQN + // owner (has `::`) is resolved exactly and can never be ambiguous. + int dot = raw.IndexOf(CHILD_REF_SEPARATOR, StringComparison.Ordinal); + string owner = dot == -1 ? raw : raw[..dot]; + if (owner.Contains(PACKAGE_SEPARATOR, StringComparison.Ordinal) || owner == "") continue; + if (!ResolveObjectRef(root, owner, referrerPkg).Ambiguous) continue; + var pkgs = root.Children() + .Where(c => c.Type == TYPE_OBJECT && c.Name == owner) + .Select(c => c.ResolutionKey()); + errors.Add(new MetaError( + $"{attrName} \"{raw}\" on {obj.Fqn()}: bare reference \"{owner}\" is ambiguous — it names an object " + + $"in multiple packages ({string.Join(", ", pkgs)}) and none is in the referrer's package \"{referrerPkg}\". " + + "Qualify it with the package (FQN).", + ErrorCode.ERR_AMBIGUOUS_REF, + Envelope: ResolvedSource.From(node.Source, obj.Fqn(), owner))); + } + foreach (var child in node.OwnChildren()) + { + VisitCrossPackageRefs(child, obj, root, referrerPkg, errors); + } + } + // ------------------------------------------------------------------------- // Origin helper: _findField // ------------------------------------------------------------------------- diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt index 982f1bb17..4ab01fd12 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperGenerator.kt @@ -260,10 +260,15 @@ open class KotlinRenderHelperGenerator : MultiFileDirectGeneratorBase resolve(MetaObject entity, MetaDataLoader loader) { private static MetaObject findEntity(MetaRoot root, String name) { if (name == null) return null; - String bare = stripPackage(name); + // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY on the + // object's package-qualified name — it never falls back to a bare-tail match + // (the closed bug: an FQN @objectRef / @through binding a same-named object + // in the WRONG package). A bare ref matches the object's short name (first + // match wins; cross-package same-package-preference is the deferred codegen + // follow-up, mirroring the TS residual note). + boolean fqn = name.indexOf("::") >= 0; // ADR-0039: root-level entity scan — root is never extended, so own children // is correct (entities are declared directly under root, not inherited into it). for (MetaObject mo : root.getChildren(MetaObject.class, false)) { - if (bare.equals(mo.getShortName())) return mo; + if (fqn ? name.equals(mo.getName()) : name.equals(mo.getShortName())) return mo; } return null; } - - private static String stripPackage(String name) { - int idx = name.lastIndexOf("::"); - return idx >= 0 ? name.substring(idx + 2) : name; - } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index d41ee29f0..be680fdba 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -165,6 +165,19 @@ public enum ErrorCode { */ ERR_AMBIGUOUS_PATH, + /** + * ADR-0041 (cross-package reference resolution): a BARE object reference (no + * {@code ::}) names an object that exists in MORE THAN ONE package, and NONE of + * those candidates is in the referrer's own package. The reference is ambiguous; + * the author must qualify it with the package (FQN). Applies to every + * object-ref-bearing attribute ({@code @objectRef}, {@code @references}, the + * {@code @from}/{@code @of}/{@code @via} heads, {@code @payloadRef}/{@code @responseRef}, + * {@code @parameterRef}). {@code extends} is excluded — its same-package/root-strict + * super-resolver never matches a packaged object by bare name, so a bare + * cross-package {@code extends} is {@code ERR_UNRESOLVED_SUPER}, not ambiguous. + */ + ERR_AMBIGUOUS_REF, + /** * FR-024 (ADR-0029): origin cardinality contract broken — a passthrough * {@code @via} path crosses a to-many hop (row-multiplying passthrough — you meant diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index e51f9c3cc..ef79f2adb 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -178,6 +178,9 @@ public static void run(MetaRoot root, MetaDataLoader loader) { pass(collected, () -> validateOnePrimarySource(root)); pass(collected, () -> validateRelationshipReferentialActions(root)); pass(collected, () -> validateRelationshipsM2M(root)); + // ADR-0041 — cross-package reference ambiguity: a BARE object-ref naming an + // object in >1 package with none in the referrer's package → ERR_AMBIGUOUS_REF. + pass(collected, () -> validateCrossPackageRefs(root)); // Phase 2 — validation DERIVED FROM THE TYPE REGISTRY: each node's TypeDefinition // carries its reference descriptors + imperative validator (relationship @objectRef, // identity.reference @references for core; a downstream provider's type carries its @@ -2463,6 +2466,97 @@ private static java.util.List validateViaPath(String viaAttr, MetaRoot return hops; } + // ========================================================================= + // ADR-0041 — cross-package reference ambiguity (ERR_AMBIGUOUS_REF). + // Mirrors TS validateCrossPackageRefs (validation-passes.ts). + // ========================================================================= + + /** + * The inline attribute names whose VALUE names an OBJECT and is therefore + * subject to the cross-package resolution contract (ADR-0041). Mirrors the TS + * {@code REF_BEARING_ATTR_NAMES}: {@code @objectRef} (relationship + field.object, + * same spelling), {@code @references}, the origin heads {@code @from}/{@code @of}/{@code @via}, + * {@code @parameterRef}, and the template {@code @payloadRef}/{@code @responseRef}. + * {@code extends} (structural, FQN-strict super-resolver) and {@code @through} + * are intentionally NOT in this set (parity with TS). + */ + private static final java.util.Set REF_BEARING_OBJECT_ATTRS = java.util.Set.of( + MetaRelationship.ATTR_OBJECT_REF, // = ObjectField.ATTR_OBJECT_REF ("objectRef") + MetaIdentity.ATTR_REFERENCES, // "references" + MetaOrigin.ATTR_FROM, // "from" + MetaOrigin.ATTR_VIA, // "via" + MetaOrigin.ATTR_OF, // "of" + RdbSource.ATTR_PARAMETER_REF, // "parameterRef" + TemplateConstants.ATTR_PAYLOAD_REF, // "payloadRef" + TemplateConstants.ATTR_RESPONSE_REF // "responseRef" + ); + + /** + * ADR-0041 — a BARE object reference (no {@code ::}) that names an object present + * in MORE THAN ONE package, with NO match in the referrer's own package, is + * ambiguous → {@code ERR_AMBIGUOUS_REF} (the author must qualify it with the + * package). An FQN ref is exact (never ambiguous); a bare ref that matches the + * referrer's own package, or exactly one package anywhere, resolves fine. Walks + * every {@link #REF_BEARING_OBJECT_ATTRS} attr on each object's OWN subtree — the + * dotted {@code .child} tail of the origin heads is stripped to the entity OWNER. + * Mirrors the TS {@code validateCrossPackageRefs}. + */ + static void validateCrossPackageRefs(MetaRoot root) { + for (MetaData child : root.getChildren(MetaData.class, false)) { + if (!(child instanceof MetaObject)) continue; + MetaObject obj = (MetaObject) child; + String referrerPkg = obj.getPackage() == null ? "" : obj.getPackage(); + visitCrossPackageRefs(root, obj, obj, referrerPkg); + } + } + + /** Recurse the OWN subtree of {@code obj}, checking each ref-bearing attr on + * {@code node} for a bare cross-package-ambiguous object reference. */ + private static void visitCrossPackageRefs(MetaRoot root, MetaObject obj, MetaData node, + String referrerPkg) { + for (String attrName : REF_BEARING_OBJECT_ATTRS) { + // OWN attr only (mirrors TS node.ownAttr) — an inherited attr is checked + // at its own declaration site, reached through that owner's subtree. + if (!node.hasMetaAttr(attrName, false)) continue; + String raw = node.getMetaAttr(attrName, false).getValueAsString(); + if (raw == null) continue; + // Owner = the object part; strip any FR-024 dotted ".child" tail. An FQN + // owner (contains "::") resolves exactly and can never be ambiguous. + int dot = raw.indexOf('.'); + String owner = (dot < 0) ? raw : raw.substring(0, dot); + if (owner.isEmpty() || owner.indexOf(MetaData.PKG_SEPARATOR) >= 0) continue; + if (!isAmbiguousBareRef(root, owner, referrerPkg)) continue; + throw new MetaDataException( + "ERR_AMBIGUOUS_REF: " + attrName + " \"" + raw + "\" on " + obj.getName() + + ": bare reference \"" + owner + "\" is ambiguous — it names an object in " + + "multiple packages and none is in the referrer's package \"" + referrerPkg + + "\". Qualify it with the package (FQN).", + ErrorCode.ERR_AMBIGUOUS_REF, + ResolvedSource.from(node.getSource(), obj.getShortName(), owner)); + } + for (MetaData c : node.getChildren(MetaData.class, false)) { + visitCrossPackageRefs(root, obj, c, referrerPkg); + } + } + + /** + * True when a BARE object name resolves ambiguously under ADR-0041: it matches + * MORE THAN ONE root object and NONE of them is in {@code referrerPkg}. Mirrors + * the ambiguity arm of the TS {@code resolveObjectRef}. + */ + private static boolean isAmbiguousBareRef(MetaRoot root, String bareOwner, String referrerPkg) { + int matches = 0; + boolean samePackage = false; + for (MetaData child : root.getChildren(MetaData.class, false)) { + if (!(child instanceof MetaObject)) continue; + if (!bareOwner.equals(child.getShortName())) continue; + matches++; + String pkg = child.getPackage() == null ? "" : child.getPackage(); + if (pkg.equals(referrerPkg)) samePackage = true; + } + return matches > 1 && !samePackage; + } + /** * Find a top-level MetaObject child of the root whose bare entity name * matches. Origin reference paths ({@code @from}, {@code @of}, {@code @via}) @@ -2545,15 +2639,17 @@ private static String hopTargetName(MetaData hop) { private static boolean nameMatches(MetaData child, String name) { String bare = shortNameOf(child); if (bare == null) return false; - // FR-032 (ADR-0032): origin/template ref values are FULLY QUALIFIED after - // the desugar/sweep (e.g. "acme::commerce::Program.title" → entity head - // "acme::commerce::Program"). Match the ref's tail segment (after the last - // "::") against the child's bare name — covers a bare ref (tail == whole) - // AND an FQN ref — and also match the child's full FQN name directly. - // Mirrors the TS refMatchesObject helper. - int idx = name.lastIndexOf(MetaData.PKG_SEPARATOR); - String nameTail = (idx >= 0) ? name.substring(idx + MetaData.PKG_SEPARATOR.length()) : name; - return nameTail.equals(bare) || name.equals(child.getName()); + // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY against + // the child's package-qualified name and NEVER falls back to a bare-tail + // match. (The closed bug: an FQN origin/object head like "xpkg::vendor::Customer" + // stripping to "Customer" and binding the first same-named object in ANY + // package.) A bare ref (relationship/field name in a @via path, or a bare + // object head) matches the child's bare name; cross-package ambiguity on a + // bare OBJECT ref is reported separately (ERR_AMBIGUOUS_REF). + if (name.indexOf(MetaData.PKG_SEPARATOR) >= 0) { + return name.equals(child.getName()); + } + return name.equals(bare); } /** diff --git a/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java b/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java index 9eacaadce..4936d02ec 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validation/SymbolTable.java @@ -39,6 +39,16 @@ public MetaObject resolveObject(String ref) { } private static boolean nameMatches(MetaData child, String ref) { + // ADR-0041: a FULLY-QUALIFIED ref (contains "::") resolves EXACTLY — it + // matches the object whose package-qualified name equals it and NOTHING + // else. It must NEVER fall back to a bare-tail match (the closed bug: an + // explicit `xpkg::vendor::Customer` wrongly binding `xpkg::crm::Customer`). + if (ref.indexOf(MetaData.PKG_SEPARATOR) >= 0) { + return ref.equals(child.getName()); + } + // Bare ref → match the object's bare (short) name. Cross-package ambiguity + // on a bare ref is reported separately by the loader's cross-package pass + // (ERR_AMBIGUOUS_REF); resolution here stays lenient (first match wins). String bare = child.getShortName(); if (bare == null || bare.isEmpty()) { String full = child.getName(); @@ -46,8 +56,6 @@ private static boolean nameMatches(MetaData child, String ref) { int i = full.lastIndexOf(MetaData.PKG_SEPARATOR); bare = (i >= 0) ? full.substring(i + MetaData.PKG_SEPARATOR.length()) : full; } - int idx = ref.lastIndexOf(MetaData.PKG_SEPARATOR); - String tail = (idx >= 0) ? ref.substring(idx + MetaData.PKG_SEPARATOR.length()) : ref; - return tail.equals(bare) || ref.equals(child.getName()); + return ref.equals(bare); } } diff --git a/server/python/src/metaobjects/errors.py b/server/python/src/metaobjects/errors.py index 1ce6ccfec..b4d2eeeaa 100644 --- a/server/python/src/metaobjects/errors.py +++ b/server/python/src/metaobjects/errors.py @@ -132,6 +132,17 @@ class ErrorCode(str, Enum): # emit this yet (the T6 guard is deferred — like TS, it belongs in the parser); # the enum tracks the shared corpus code so resolution/desugar stay aligned. ERR_RELATIVE_REF_IN_CANONICAL = "ERR_RELATIVE_REF_IN_CANONICAL" + # ADR-0041 — cross-package reference resolution contract. A BARE object + # reference (no ``::``) that names an object present in MORE THAN ONE package, + # with NO match in the referrer's own package, is ambiguous — the author must + # qualify it with the package (FQN). Applies to every object-ref-bearing attr + # (@objectRef / @references / @from / @of / @via heads / @parameterRef / + # @payloadRef / @responseRef). An FQN ref resolves exactly and is never + # ambiguous; a bare ref that matches the referrer's own package, or exactly one + # package anywhere, resolves fine. ``extends`` is intentionally NOT covered + # (its same-package/root-strict super-resolver yields ERR_UNRESOLVED_SUPER on a + # bare cross-package miss, not ambiguity). + ERR_AMBIGUOUS_REF = "ERR_AMBIGUOUS_REF" # FR-033 — strict structural-placement vocabulary. ERR_CHILD_NOT_ALLOWED: a # structural child (field/identity/source/validator/… — not an attr) is placed # under a parent whose registered childRules do not admit it (the structural diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index a9dbcece3..936e03833 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -107,6 +107,7 @@ from ..meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS from ..meta.core.index.index_constants import INDEX_ATTR_FIELDS, INDEX_SUBTYPE_LOOKUP from ..source import resolved_source +from ..naming_refs import CHILD_REF_SEP, REF_BEARING_ATTR_NAMES, resolve_object_ref # A subtype-specific template attr is valid ONLY on the subtype it is registered # for. The metamodel registers these per-subtype (see the core_types template block), @@ -179,6 +180,8 @@ def run_validations( # @valueType (when set) must name a known scalar subtype. _validate_field_map(root, errors) _validate_templates(root, errors) + # ADR-0041 — cross-package bare-reference ambiguity (the object-ref contract). + _validate_cross_package_refs(root, errors) _validate_subtype_rules(root, errors, warnings) # FR-024 B3 — projection identity pass-through + key correspondence. _validate_identity_passthrough(root, errors) @@ -208,6 +211,68 @@ def _walk(root: MetaData) -> list[MetaData]: return result +# --------------------------------------------------------------------------- +# Pass: cross-package reference ambiguity (the object-ref contract, ADR-0041) +# --------------------------------------------------------------------------- + + +def _validate_cross_package_refs(root: MetaData, errors: list[MetaError]) -> None: + """ADR-0041 — a BARE object reference (no ``::``) that names an object present + in MORE THAN ONE package, with NO match in the referrer's own package, is + ambiguous → ERR_AMBIGUOUS_REF (the author must qualify it with the package). + + An FQN ref is exact (never ambiguous); a bare ref matching the referrer's own + package, or exactly one package anywhere, resolves fine. Covers every + object-ref-bearing attr in ``REF_BEARING_ATTR_NAMES`` (@objectRef / @references + / @from / @of / @via / @parameterRef / @payloadRef / @responseRef) — the dotted + ``.child`` tail of the origin heads is stripped to the entity OWNER. ``extends`` + is intentionally NOT covered: its same-package/root-strict super-resolver never + matches a packaged object by bare name, so a bare cross-package ``extends`` is + unresolved (ERR_UNRESOLVED_SUPER), not ambiguous. Mirrors the TS reference + ``validateCrossPackageRefs``. + """ + + def _visit(node: MetaData, obj: MetaData, referrer_pkg: str) -> None: + for attr_name in REF_BEARING_ATTR_NAMES: + # ADR-0039 sanctioned own: judge the ref AUTHORED on THIS node; an + # inherited ref was judged on its declaring node. Mirrors the TS + # `node.ownAttr(attrName)`. + raw = node.attr(attr_name) + if not isinstance(raw, str): + continue + # Owner = the object part; strip any FR-024 dotted `.child` tail. An FQN + # owner (has `::`) is resolved exactly and can never be ambiguous. + dot = raw.find(CHILD_REF_SEP) + owner = raw if dot == -1 else raw[:dot] + if PACKAGE_SEP in owner or owner == "": + continue + if not resolve_object_ref(root, owner, referrer_pkg).ambiguous: + continue + pkgs = [ + c.resolution_key() + for c in root.children() + if c.type == TYPE_OBJECT and c.name == owner + ] + errors.append( + MetaError( + f'@{attr_name} "{raw}" on {obj.fqn()}: bare reference "{owner}" ' + f"is ambiguous — it names an object in multiple packages " + f"({', '.join(pkgs)}) and none is in the referrer's package " + f'"{referrer_pkg}". Qualify it with the package (FQN).', + ErrorCode.ERR_AMBIGUOUS_REF, + envelope=resolved_source(node.source, obj.fqn(), owner), + ) + ) + for c in node.own_children(): + _visit(c, obj, referrer_pkg) + + for obj in root.children(): + if obj.type != TYPE_OBJECT: + continue + referrer_pkg = obj.package or obj.file_default_package or "" + _visit(obj, obj, referrer_pkg) + + # --------------------------------------------------------------------------- # Pass: attr-schema validation # --------------------------------------------------------------------------- diff --git a/server/python/src/metaobjects/naming_refs.py b/server/python/src/metaobjects/naming_refs.py index 14f10f21e..e340992db 100644 --- a/server/python/src/metaobjects/naming_refs.py +++ b/server/python/src/metaobjects/naming_refs.py @@ -29,6 +29,8 @@ """ from __future__ import annotations +from typing import TYPE_CHECKING, NamedTuple, Optional + from .meta.core.field.field_constants import FIELD_ATTR_OBJECT_REF from .meta.core.identity.identity_constants import IDENTITY_REFERENCE_ATTR_REFERENCES from .meta.core.relationship.relationship_constants import RELATIONSHIP_ATTR_OBJECT_REF @@ -42,8 +44,12 @@ TEMPLATE_ATTR_PAYLOAD_REF, TEMPLATE_ATTR_RESPONSE_REF, ) +from .shared.base_types import TYPE_OBJECT from .shared.separators import PACKAGE_SEP +if TYPE_CHECKING: + from .meta.meta_data import MetaData + # Structural separator literals (Python has no PACKAGE_PARENT / CHILD_REF_SEPARATOR # constant module; the TS values are "::" / "." / ".." — PACKAGE_SEP already # carries "::"). Defined here to mirror TS naming-refs.ts. @@ -139,3 +145,64 @@ def expand_ref(raw: str, package_context: str) -> str: """ owner, tail = _split_child_tail(raw) return _expand_owner(owner, package_context) + tail + + +# --------------------------------------------------------------------------- +# ADR-0041 — cross-package OBJECT reference resolution contract +# --------------------------------------------------------------------------- +# The SINGLE resolver every object-ref site shares so the same-package-preference +# contract is uniform. Mirrors the TS reference ``resolveObjectRef`` / +# ``objectPackage`` in naming-refs.ts. + + +class ObjectRefResolution(NamedTuple): + """Result of :func:`resolve_object_ref` — mirrors the TS ``{ node?, ambiguous? }``. + + * ``node`` — the uniquely-resolved object (``None`` when nothing matched, or + when a bare ref is cross-package-ambiguous). + * ``ambiguous`` — True ONLY for a BARE ref that names an object in more than + one OTHER package with none in the referrer's own package. + """ + + node: Optional["MetaData"] + ambiguous: bool + + +def _object_package(node: "MetaData") -> str: + """The effective package of a root-level object — its ``resolution_key()`` minus + the trailing ``::`` ("" for a root-level/empty-package object). Mirrors + the TS ``objectPackage``. + """ + key = node.resolution_key() + i = key.rfind(PACKAGE_SEP) + return "" if i == -1 else key[:i] + + +def resolve_object_ref( + root: "MetaData", ref: str, referrer_pkg: str +) -> ObjectRefResolution: + """Resolve a metadata OBJECT reference under the ADR-0041 cross-package contract. + + * **FQN** (``ref`` contains ``::``) → EXACT match on ``resolution_key()``/ + ``fqn()``. Never a bare-tail fallback, so an FQN pointing at one package never + binds a same-named object in another (the cross-port bug ADR-0041 closes). + * **bare** (no ``::``) → prefer an object of that name in the REFERRER's own + package; else a UNIQUE object of that name across all packages; else (>1 + across OTHER packages, none in the referrer's) → ``ambiguous``. + + *referrer_pkg* is the effective package of the node carrying the ref. Returns an + :class:`ObjectRefResolution`. Mirrors the TS ``resolveObjectRef``. + """ + objects = [c for c in root.children() if c.type == TYPE_OBJECT] + if PACKAGE_SEP in ref: + for c in objects: + if c.resolution_key() == ref or c.fqn() == ref: + return ObjectRefResolution(c, False) + return ObjectRefResolution(None, False) + matches = [c for c in objects if c.name == ref] + if len(matches) <= 1: + return ObjectRefResolution(matches[0] if matches else None, False) + for c in matches: + if _object_package(c) == referrer_pkg: + return ObjectRefResolution(c, False) + return ObjectRefResolution(None, True) diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index dfbb27bd2..681a96acd 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -71,6 +71,12 @@ export const ERROR_CODES = [ // projection's base entity cannot be derived because its fields extend // multiple entities and no extended identity anchors the base. "ERR_AMBIGUOUS_PATH", + // Cross-package reference contract — a BARE reference (no `::`) to an object + // name that exists in MORE THAN ONE package, with no match in the referrer's + // OWN package, is ambiguous: the author must qualify it with the package + // (FQN). Applies to every object-ref-bearing attr (@objectRef, @references, + // @from/@of/@via heads, extends, @payloadRef/@responseRef, @through). + "ERR_AMBIGUOUS_REF", // FR-024 (ADR-0029 decision 6) — origin cardinality contract broken: a // passthrough @via path crosses a to-many hop (row-multiplying — you meant // aggregate), or an aggregate @via path is to-one at every hop (you meant diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index cb440fcb3..39f787728 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -205,7 +205,7 @@ export type { SerializeOptions } from "./serializer-json.js"; export { resolveSuperRef } from "./super-resolve.js"; // FR-032 (ADR-0032) — canonical reference expansion + FQN object matching. -export { expandRef, isRelativeRef, refMatchesObject, REF_BEARING_ATTR_NAMES } from "./naming-refs.js"; +export { expandRef, isRelativeRef, refMatchesObject, resolveObjectRef, REF_BEARING_ATTR_NAMES } from "./naming-refs.js"; // Loader hierarchy export { MetaDataLoader } from "./loader/meta-data-loader.js"; diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts index 084d45a9c..b591ce4c6 100644 --- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts +++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts @@ -18,7 +18,7 @@ import { ParseError } from "../errors.js"; import type { LoaderWarning } from "../source.js"; import { codeSource, resolvedSource } from "../source.js"; import { parseJson } from "../parser-json.js"; -import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields } from "./validation-passes.js"; +import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateCrossPackageRefs } from "./validation-passes.js"; import { runRegisteredValidation } from "./validation-registry.js"; import { validateSourceRoles } from "../persistence/source/validate-source-roles.js"; import { validateSourcePhysicalNames } from "../persistence/source/validate-source-physical-names.js"; @@ -579,6 +579,7 @@ export class MetaDataLoader { // Sixth pass: origin path validation — validates passthrough.@from, // aggregate.@of, and .@via relationship chains. errors.push(...validateOriginPaths(root)); + errors.push(...validateCrossPackageRefs(root)); // FR-024 B6 — derived-field providability: an entity field carrying an // origin.* is derived (read-only) and must be providable by at least one diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index e86fabb2c..6cf4f870f 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -14,7 +14,8 @@ import type { MetaData } from "../shared/meta-data.js"; import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js"; import { ParseError } from "../errors.js"; -import { refMatchesObject } from "../naming-refs.js"; +import { refMatchesObject, resolveObjectRef, REF_BEARING_ATTR_NAMES } from "../naming-refs.js"; +import { PACKAGE_SEPARATOR, CHILD_REF_SEPARATOR } from "../shared/structural.js"; import { resolvedSource, type ErrorSource } from "../source.js"; import { TYPE_OBJECT, @@ -280,6 +281,57 @@ export function validateTemplatePayloadRefs(root: MetaData): ParseError[] { return errors; } +// --------------------------------------------------------------------------- +// Cross-package reference ambiguity (the object-ref contract) +// --------------------------------------------------------------------------- + +/** + * A BARE object reference (no `::`) that names an object present in MORE THAN ONE + * package, with NO match in the referrer's own package, is ambiguous → emit + * ERR_AMBIGUOUS_REF (the author must qualify it with the package). An FQN ref is + * exact (never ambiguous); a bare ref that matches the referrer's own package, or + * exactly one package anywhere, resolves fine. Covers every object-ref-bearing + * attr in `REF_BEARING_ATTR_NAMES` (@objectRef / @references / @from / @of / @via + * / @parameterRef / @payloadRef / @responseRef) — the dotted `.child` tail of the + * origin heads is stripped to the entity OWNER. `extends` is intentionally NOT + * covered: its FR-032 super-resolver is same-package/root-strict and never + * matches a packaged object by bare name, so a bare cross-package `extends` is + * unresolved (ERR_UNRESOLVED_SUPER), not ambiguous. + */ +export function validateCrossPackageRefs(root: MetaData): ParseError[] { + const errors: ParseError[] = []; + for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { + const referrerPkg = obj.package ?? obj.fileDefaultPackage ?? ""; + const visit = (node: MetaData): void => { + for (const attrName of REF_BEARING_ATTR_NAMES) { + const raw = node.ownAttr(attrName); + if (typeof raw !== "string") continue; + // Owner = the object part; strip any FR-024 dotted `.child` tail. An FQN + // owner (has `::`) is resolved exactly and can never be ambiguous. + const dot = raw.indexOf(CHILD_REF_SEPARATOR); + const owner = dot === -1 ? raw : raw.slice(0, dot); + if (owner.includes(PACKAGE_SEPARATOR) || owner === "") continue; + if (!resolveObjectRef(root, owner, referrerPkg).ambiguous) continue; + const pkgs = root + .children() + .filter((c) => c.type === TYPE_OBJECT && c.name === owner) + .map((c) => c.resolutionKey()); + errors.push( + new ParseError( + `${attrName} "${raw}" on ${obj.fqn()}: bare reference "${owner}" is ambiguous — it names an object ` + + `in multiple packages (${pkgs.join(", ")}) and none is in the referrer's package "${referrerPkg}". ` + + `Qualify it with the package (FQN).`, + { code: "ERR_AMBIGUOUS_REF", source: resolvedSource(node.source, obj.fqn(), owner) }, + ), + ); + } + for (const c of node.ownChildren()) visit(c); + }; + visit(obj); + } + return errors; +} + // --------------------------------------------------------------------------- // @filterable without index validation // --------------------------------------------------------------------------- diff --git a/server/typescript/packages/metadata/src/naming-refs.ts b/server/typescript/packages/metadata/src/naming-refs.ts index fea2c7daf..0629f24fd 100644 --- a/server/typescript/packages/metadata/src/naming-refs.ts +++ b/server/typescript/packages/metadata/src/naming-refs.ts @@ -28,6 +28,7 @@ import type { MetaData } from "./shared/meta-data.js"; import { PACKAGE_SEPARATOR, PACKAGE_PARENT, CHILD_REF_SEPARATOR } from "./shared/structural.js"; +import { TYPE_OBJECT } from "./shared/base-types.js"; import { RELATIONSHIP_ATTR_OBJECT_REF } from "./core/relationship/relationship-constants.js"; import { FIELD_ATTR_OBJECT_REF } from "./core/field/field-constants.js"; import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "./core/identity/identity-constants.js"; @@ -160,3 +161,41 @@ export function expandRef(raw: string, packageContext: string): string { export function refMatchesObject(node: MetaData, ref: string): boolean { return node.resolutionKey() === ref || node.fqn() === ref || node.name === ref; } + +/** The effective package of a root-level object (its `resolutionKey()` minus the + * trailing `::`; "" for a root-level/empty-package object). */ +function objectPackage(node: MetaData): string { + const key = node.resolutionKey(); + const i = key.lastIndexOf(PACKAGE_SEPARATOR); + return i === -1 ? "" : key.slice(0, i); +} + +/** + * Resolve a metadata OBJECT reference under the cross-package contract: + * - **FQN** (`ref` contains `::`) → EXACT match on `resolutionKey()`/`fqn()`. + * Never a bare-tail fallback, so an FQN pointing at one package never binds a + * same-named object in another (the cross-port bug this closes). + * - **bare** (no `::`) → prefer an object of that name in the REFERRER's own + * package; else a UNIQUE object of that name across all packages; else + * (>1 across OTHER packages, none in the referrer's) → `ambiguous`. + * + * `referrerPkg` is the effective package of the node carrying the ref. Returns + * `{ node }` on a unique resolution, `{ ambiguous: true }` when a bare ref is + * cross-package-ambiguous, or `{}` (node undefined) when nothing matches. The + * SINGLE resolver every object-ref site shares so the contract is uniform. + */ +export function resolveObjectRef( + root: MetaData, + ref: string, + referrerPkg: string, +): { node?: MetaData | undefined; ambiguous?: boolean } { + const objects = root.children().filter((c) => c.type === TYPE_OBJECT); + if (ref.includes(PACKAGE_SEPARATOR)) { + return { node: objects.find((c) => c.resolutionKey() === ref || c.fqn() === ref) }; + } + const matches = objects.filter((c) => c.name === ref); + if (matches.length <= 1) return { node: matches[0] }; + const own = matches.find((c) => objectPackage(c) === referrerPkg); + if (own !== undefined) return { node: own }; + return { ambiguous: true }; +} diff --git a/spec/decisions/ADR-0041-cross-package-reference-resolution.md b/spec/decisions/ADR-0041-cross-package-reference-resolution.md new file mode 100644 index 000000000..0f21ac8fd --- /dev/null +++ b/spec/decisions/ADR-0041-cross-package-reference-resolution.md @@ -0,0 +1,40 @@ +# ADR-0041: Cross-package reference resolution contract + +## Status + +**Accepted** (2026-07-05). + +## Context + +Every metadata reference that names another object — `@objectRef` (relationship + `field.object`), `@references` (`identity.reference`), the `@from`/`@of`/`@via` heads of `origin.*`, `extends` (object/field/identity super-references), a relationship's `@through` junction, and the template/source refs `@payloadRef`/`@responseRef`/`@parameterRef` — may name a target in a **different package**. A reference is written either **fully-qualified** (`acme::crm::Customer`, or a member path `acme::crm::Customer.email`) or **bare** (`Customer`). + +The five ports had **no shared, tested contract** for how a bare or FQN reference resolves when object names collide across packages, and **zero cross-package conformance fixtures**. That let real cross-port divergences hide: + +- **Java** (`SymbolTable.nameMatches`, `ValidationPhase.nameMatches`) compared the reference's **bare tail** against bare names *before* the exact-FQN check. So an explicit, unambiguous FQN `acme::vendor::Customer` silently bound to `acme::crm::Customer` — the wrong package (wrong FK table in codegen). A fully-qualified reference — the *unambiguous* form — resolved to the wrong object. +- **Kotlin** (`KotlinGenUtil.resolveObjectByShortOrFqn`) resolved a bare colliding name to the first in load order — arbitrary, load-order-dependent. +- **TypeScript / C#** matched FQNs exactly (correct) but resolved a bare colliding name by arbitrary first/last-match, with no ambiguity signal. + +Unique cross-package names resolved correctly everywhere, which is why the gap went unnoticed until a downstream model with same-named entities across packages exposed it. + +## Decision + +A single reference-resolution contract, identical in all five ports, applied wherever a reference names an object: + +1. **A fully-qualified reference (contains `::`) resolves exactly.** It matches the object whose package-qualified resolution key equals the reference — and nothing else. It **never** falls back to a bare-tail match. An FQN that matches no object is the attribute's existing unresolved error (`ERR_INVALID_RELATIONSHIP` / `ERR_INVALID_ORIGIN` / `ERR_UNRESOLVED_SUPER` / …), never a silent mis-bind. + +2. **A bare reference resolves by precedence:** + - **(a) same package** — an object of that name in the **referrer's own package** wins; + - **(b) unique elsewhere** — else exactly one object of that name across all packages resolves; + - **(c) ambiguous** — else (more than one candidate across other packages, none in the referrer's package) is a hard load error, **`ERR_AMBIGUOUS_REF`** (new). The author must qualify with the FQN; + - **(d) unresolved** — else (no candidate) is the attribute's existing unresolved error. + +The "referrer's package" is the package of the node carrying the reference. This preserves every previously-working case (unique names, whether bare or FQN) and only changes collision behavior — from *silent, arbitrary, port-divergent mis-binding* to *deterministic same-package preference or a loud, portable error*. + +## Consequences + +- **New error code `ERR_AMBIGUOUS_REF`**, registered cross-port (added to each port's error-code set and `fixtures/conformance/ERROR-CODES.json`). +- **Resolvers become referrer-package-aware.** Each port routes object-reference resolution through one shared helper (`resolveObjectRef(root, ref, referrerPkg)` in TS) so relationship/reference/origin/extends/template resolution behave identically; the referrer's package is threaded to each call site. Java's bare-tail-before-FQN ordering and the codegen bare-name-first-match helpers (Java `SpringM2mSupport`/`SpringPayloadGenerator`, Kotlin `KotlinGenUtil`) are corrected to this contract. +- **Conformance-gated.** New `fixtures/conformance/` fixtures cover every ref-bearing attribute cross-package (unique-name working cases) plus the collision contract: FQN-exact-across-collision (via `extends`, observable in effective serialization), bare same-package preference, and bare-ambiguous → `ERR_AMBIGUOUS_REF`. All five ports must serialize/err byte-identically. +- **Breaking only for models that today rely on a bare reference silently binding to an arbitrarily-chosen same-named entity in another package** — previously undefined behavior, now an explicit `ERR_AMBIGUOUS_REF` that is fixed by qualifying the reference. No change for unique names or explicit FQNs. + +See the cross-package conformance corpus (`fixtures/conformance/xpkg-*`, `error-xpkg-*`) for the executable contract.