From 155ae322c61d25aea92a9f22fc0a0c0afc3a99fc Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 21:19:47 -0400 Subject: [PATCH 1/2] fix(python): expand a ::-relative package against the declaring file's root package The Python parser kept a relative package ("::parts") raw, and later code expanded it against the merged root's package, which is the first file's. In a multi-file collection whose files declare different root packages that gave a different package, resolution key, canonical JSON and field-lint address than TypeScript, C# and Java. The parser now expands at parse time against the declaring file's context package, mirroring TS applyReservedKeys. - field_lint.py drops its own expansion of the loaded model and shares the parser's helper for the raw-file walk. - MetaData.effective_package() exposes the package a node resolves under. - New conformance fixture loader-relative-package-multi-root and field-lint fixture reference-field-missing-multi-root-package gate every port. - The Python corner note in the field-lint docs is removed. --- AGENTS.md | 2 +- CHANGELOG.md | 15 +++++ README.md | 2 +- docs/CONFORMANCE.md | 8 +-- docs/features/cli.md | 5 +- docs/ports/python.md | 14 +++++ .../expected.json | 62 +++++++++++++++++++ .../input/meta.app.json | 27 ++++++++ .../input/meta.other.json | 42 +++++++++++++ fixtures/field-lint-conformance/README.md | 5 +- .../expected.json | 9 +++ .../input/meta.app.json | 27 ++++++++ .../input/meta.other.json | 42 +++++++++++++ server/python/src/metaobjects/field_lint.py | 11 +--- .../python/src/metaobjects/meta/meta_data.py | 16 ++++- server/python/src/metaobjects/parser.py | 19 +++++- .../test_relative_package_multi_root.py | 45 ++++++++++++++ .../python/tests/unit/test_resolution_key.py | 13 ++++ 18 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 fixtures/conformance/loader-relative-package-multi-root/expected.json create mode 100644 fixtures/conformance/loader-relative-package-multi-root/input/meta.app.json create mode 100644 fixtures/conformance/loader-relative-package-multi-root/input/meta.other.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing-multi-root-package/expected.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.other.json create mode 100644 server/python/tests/loader/test_relative_package_multi_root.py diff --git a/AGENTS.md b/AGENTS.md index 3dfb9bf0d..e73efe755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (363 fixtures; 26 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (364 fixtures; 26 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Two sub-corpora run the **generated lane only, on all five ports** — `write-through/` and `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce794df8..9f15964e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,21 @@ it until 1.1 ships._ used to split it on commas, which left stray quotes in the items; TypeScript and Python already kept it whole. All three now agree, so a Java model that relied on the split (a comma-separated string where a list belongs) now reads one item, as the other ports always did. +- **Python: a `::`-relative package is expanded against the declaring file's root package.** + An object that declares `"package": "::parts"` in a file whose root package is `beta::other` + now loads as `beta::other::parts`, the same as in TypeScript, C# and Java. The Python parser + used to keep the raw `::parts`, and the code that expanded it later used the merged root's + package, which is the first file's. The result differed only in a multi-file collection + whose files declare different root packages: there the node's `package`, its + `resolution_key()`, its canonical JSON and the address in a `metaobjects verify` field-lint + warning now carry the declaring file's package (`beta::other::parts::Gadget`, not + `acme::app::parts::Gadget`). Everything that loaded before still loads. A node's `package` + now holds the expanded form in a single-file model too, where it held `::parts` before. + Gated by the new `loader-relative-package-multi-root` conformance fixture and + `reference-field-missing-multi-root-package` in `fixtures/field-lint-conformance/`. +- **Python: `MetaData.effective_package()`** returns the package a node resolves under, where + `package` is `None` for an object that inherits its file's root package. See + `docs/ports/python.md`, "Use". - **Python: the `@filter` desugar now matches TypeScript.** It recurses into `and` / `or`; previously it turned each list into an `in` clause on a field named `and` or `or`, so the clauses inside a composition were never checked. A dangling field reference inside an `or` diff --git a/README.md b/README.md index 4721c73ca..1b487052f 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ metaobjects/ ├── CLAUDE.md # project instructions for Claude ├── spec/ # canonical metamodel docs, ADRs, roadmap ├── fixtures/ # 25 cross-language conformance corpora — the oracle -│ ├── conformance/ # metamodel (loader + serializer + navigation), 363 fixtures +│ ├── conformance/ # metamodel (loader + serializer + navigation), 364 fixtures │ ├── yaml-conformance/ # YAML authoring desugar │ ├── render-conformance/ # FR-004 byte-identical render oracle │ ├── verify-conformance/ # FR-004 template-drift gate diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 94b182ede..6c6a2ce96 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 363 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 364 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -49,7 +49,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — | | [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — | | [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | -| [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 14 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | +| [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 15 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary is inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | @@ -204,7 +204,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (363) +### `fixtures/conformance/` — metamodel loader + canonical serializer (364) | Fixture prefix | Feature doc | |---|---| @@ -399,7 +399,7 @@ own those two functions), and ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 363 + yaml 16 + verify 31 +The fixtures in the nine corpora mapped above (metamodel 364 + yaml 16 + verify 31 + render 15 + persistence 39 + api-contract 61 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/cli.md b/docs/features/cli.md index 5c99f44c5..0c3c1f8e3 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -139,10 +139,7 @@ Rules of the contract: | `metaobjects verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` | The codes and message text are identical in every port, gated by - [`fixtures/field-lint-conformance/`](../../fixtures/field-lint-conformance/README.md). In - Python, for a multi-file collection whose roots declare different packages, the address - printed for a `::`-relative package is expanded against the merged root's package and can - differ from the other ports. + [`fixtures/field-lint-conformance/`](../../fixtures/field-lint-conformance/README.md). ### The prompt directory: `--prompts` everywhere (F101) diff --git a/docs/ports/python.md b/docs/ports/python.md index 801cbede4..b090723ff 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -437,6 +437,20 @@ name_field = [f for f in author.children() if f.name == "name"][0] print(name_field.get_meta_attr("maxLength")) # -> 200 ``` +To ask which package a node is in, call `effective_package()`. The `package` attribute holds +only a package the node declares itself, so it is `None` on an object that inherits its +file's root package: + +```python +author.package # -> None (declared no package of its own) +author.effective_package() # -> "myapp::library" +author.resolution_key() # -> "myapp::library::Author" +``` + +A `::`-relative package (`"package": "::parts"`) is expanded when the file is parsed, against +that file's root package, so `package` on such a node is already the full +`beta::other::parts`. + ### Run-time validation `run_validators` checks a data mapping against an entity's metadata, with no generated diff --git a/fixtures/conformance/loader-relative-package-multi-root/expected.json b/fixtures/conformance/loader-relative-package-multi-root/expected.json new file mode 100644 index 000000000..6dcdd0574 --- /dev/null +++ b/fixtures/conformance/loader-relative-package-multi-root/expected.json @@ -0,0 +1,62 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Store", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Gadget", + "package": "beta::other::parts", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "store_fk", + "@fields": [ + "storeId" + ], + "@references": "acme::app::Store" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/loader-relative-package-multi-root/input/meta.app.json b/fixtures/conformance/loader-relative-package-multi-root/input/meta.app.json new file mode 100644 index 000000000..4771a0398 --- /dev/null +++ b/fixtures/conformance/loader-relative-package-multi-root/input/meta.app.json @@ -0,0 +1,27 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Store", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/loader-relative-package-multi-root/input/meta.other.json b/fixtures/conformance/loader-relative-package-multi-root/input/meta.other.json new file mode 100644 index 000000000..9be21211d --- /dev/null +++ b/fixtures/conformance/loader-relative-package-multi-root/input/meta.other.json @@ -0,0 +1,42 @@ +{ + "metadata.root": { + "package": "beta::other", + "children": [ + { + "object.entity": { + "name": "Gadget", + "package": "::parts", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "store_fk", + "@fields": [ + "storeId" + ], + "@references": "acme::app::Store" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/README.md b/fixtures/field-lint-conformance/README.md index 36ff3d9d2..0a6ee435e 100644 --- a/fixtures/field-lint-conformance/README.md +++ b/fixtures/field-lint-conformance/README.md @@ -32,9 +32,8 @@ A runner does three things, in this order: `(code, path, message)`. `path` is the declaring object's resolution key (`::`), a dot, then the -identity name or the field name. In Python, for a multi-file collection whose roots declare -different packages, the address printed for a `::`-relative package is expanded against the -merged root's package and can differ from the other ports. +identity name or the field name. A `::`-relative package is expanded against the declaring +file's root package in every port (`reference-field-missing-multi-root-package`). ## The two halves read different things diff --git a/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/expected.json b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/expected.json new file mode 100644 index 000000000..60d4e4dec --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "beta::other::parts::Gadget.store_fk", + "message": "identity.reference \"store_fk\" lists \"storeIdd\" in @fields, but beta::other::parts::Gadget has no field of that name, inherited and overlaid fields included. Nothing checks this at load, so the reference is built on a field that does not exist. Rename the entry to an existing field, or declare the field." + } + ] +} diff --git a/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.app.json b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.app.json new file mode 100644 index 000000000..4771a0398 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.app.json @@ -0,0 +1,27 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Store", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.other.json b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.other.json new file mode 100644 index 000000000..79d8a40ae --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing-multi-root-package/input/meta.other.json @@ -0,0 +1,42 @@ +{ + "metadata.root": { + "package": "beta::other", + "children": [ + { + "object.entity": { + "name": "Gadget", + "package": "::parts", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "store_fk", + "@fields": [ + "storeIdd" + ], + "@references": "acme::app::Store" + } + } + ] + } + } + ] + } +} diff --git a/server/python/src/metaobjects/field_lint.py b/server/python/src/metaobjects/field_lint.py index 9d66c108c..2848f95a7 100644 --- a/server/python/src/metaobjects/field_lint.py +++ b/server/python/src/metaobjects/field_lint.py @@ -36,6 +36,7 @@ IDENTITY_SUBTYPE_REFERENCE, ) from metaobjects.meta.meta_data import MetaData +from metaobjects.parser import expand_package_for_path from metaobjects.shared.base_types import ( SUBTYPE_ROOT, TYPE_FIELD, @@ -78,15 +79,10 @@ def lint_reference_fields(root: MetaData) -> list[FieldLintFinding]: out: list[FieldLintFinding] = [] # OWN-ONLY (ADR-0039 sanctioned case): a root has no super, and its own children # are the declared objects. - root_pkg = root.package or "" for obj in root.own_children(): if obj.type != TYPE_OBJECT: continue - own_pkg = obj.package - if own_pkg and own_pkg.startswith(PACKAGE_SEP) and root_pkg.strip() != "": - address = f"{root_pkg}{own_pkg}{PACKAGE_SEP}{obj.name}" - else: - address = obj.resolution_key() + address = obj.resolution_key() # RESOLVING: the effective field set — a field inherited through ``extends`` or # added by an overlay file is a field the object has. fields = {c.name for c in obj.children() if c.type == TYPE_FIELD} @@ -185,8 +181,7 @@ def declared_duplicate_fields(content: str, format: str) -> list[FieldLintFindin # (a ``::``-prefixed one is relative to the root's), else the root's. raw_own_pkg = body.get(_KEY_PACKAGE) if isinstance(raw_own_pkg, str) and raw_own_pkg != "": - relative = root_pkg.strip() != "" and raw_own_pkg.startswith(PACKAGE_SEP) - pkg = root_pkg + raw_own_pkg if relative else raw_own_pkg + pkg = expand_package_for_path(root_pkg, raw_own_pkg) else: pkg = root_pkg address = f"{pkg}{PACKAGE_SEP}{name}" if pkg != "" else name diff --git a/server/python/src/metaobjects/meta/meta_data.py b/server/python/src/metaobjects/meta/meta_data.py index c8f2e7469..b4342bcac 100644 --- a/server/python/src/metaobjects/meta/meta_data.py +++ b/server/python/src/metaobjects/meta/meta_data.py @@ -95,6 +95,18 @@ def resolution_key(self) -> str: key. Folding the file-default package keeps the binding correct regardless of merge order. """ + pkg = self.effective_package() + if pkg: + return f"{pkg}{PACKAGE_SEP}{self.name}" + return self.name + + def effective_package(self) -> str: + """The package this node resolves under: :meth:`resolution_key` minus ``::``. + + ``""`` when there is none. Use this, not ``package``, to ask "which package is + this node in": ``package`` is ``None`` on an object that inherits its file's + root package. Mirrors C# ``NamingRefs.EffectivePackage``. + """ pkg = self.package or self.file_default_package if not pkg: node = self.parent @@ -103,9 +115,7 @@ def resolution_key(self) -> str: pkg = node.package break node = node.parent - if pkg: - return f"{pkg}{PACKAGE_SEP}{self.name}" - return self.name + return pkg or "" def _require_mutable(self) -> None: if self._frozen: diff --git a/server/python/src/metaobjects/parser.py b/server/python/src/metaobjects/parser.py index 44036de5b..5c74fdb26 100644 --- a/server/python/src/metaobjects/parser.py +++ b/server/python/src/metaobjects/parser.py @@ -18,7 +18,7 @@ TYPE_METADATA, TYPE_OBJECT, ) -from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP +from .shared.separators import ATTR_PREFIX, FUSED_KEY_SEP, PACKAGE_SEP from .shared.structural import ( KEY_ABSTRACT, KEY_CHILDREN, @@ -284,6 +284,18 @@ def _abstract_subtype_message(type_: str) -> str: ) +def expand_package_for_path(base_pkg: str, pkg_path: str) -> str: + """Expand a ``::``-relative package against *base_pkg*; anything else is as-is. + + ``("acme", "::parts")`` is ``"acme::parts"``. Mirrors TS ``expandPackageForPath``, + and is shared with the raw-file walk in ``field_lint`` so the two cannot disagree + on the address of a node that declares a relative package. + """ + if base_pkg.strip() == "" or not pkg_path.startswith(PACKAGE_SEP): + return pkg_path + return base_pkg + pkg_path + + def _build( wrapper: str, body: object, @@ -395,6 +407,11 @@ def _build( node.set_source(_current_envelope(source, builder, yaml_position)) pkg = body_dict.get(KEY_PACKAGE) + if isinstance(pkg, str): + # A ``::``-relative package is expanded HERE, against the declaring file's + # context package — never later against the merged root, whose package is + # the first file's. Mirrors TS ``applyReservedKeys``. + pkg = expand_package_for_path(ctx_pkg, pkg) # Capture the file-default package at PARSE time so cross-package # fully-qualified ``extends`` resolves over the MERGED tree (where per-file # root packages are no longer reachable via the parent chain). The node's diff --git a/server/python/tests/loader/test_relative_package_multi_root.py b/server/python/tests/loader/test_relative_package_multi_root.py new file mode 100644 index 000000000..988080124 --- /dev/null +++ b/server/python/tests/loader/test_relative_package_multi_root.py @@ -0,0 +1,45 @@ +"""A ``::``-relative package expands against the DECLARING file's root package. + +In a multi-file collection whose files declare different root packages, the merged +root carries the first file's package. Expanding ``::parts`` later, against that +merged root, gives the wrong address; the parser expands it at parse time instead, +the same as TypeScript, C# and Java. +""" +from __future__ import annotations + +from metaobjects import MetaDataLoader + +_APP = """{"metadata.root": {"package": "acme::app", "children": [ + {"object.entity": {"name": "Store", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}]}}]}}""" + +_OTHER = """{"metadata.root": {"package": "beta::other", "children": [ + {"object.entity": {"name": "Gadget", "package": "::parts", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}]}}, + {"object.entity": {"name": "Widget", "children": [ + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "pk", "@fields": ["id"]}}]}}]}}""" + + +def _load(tmp_path): # type: ignore[no-untyped-def] + (tmp_path / "meta.app.json").write_text(_APP, encoding="utf-8") + (tmp_path / "meta.other.json").write_text(_OTHER, encoding="utf-8") + result = MetaDataLoader.from_directory(str(tmp_path), strict=True) + assert [str(e) for e in result.errors] == [] + return {c.name: c for c in result.root.children()} + + +def test_relative_package_expands_against_declaring_file_root(tmp_path) -> None: # type: ignore[no-untyped-def] + gadget = _load(tmp_path)["Gadget"] + assert gadget.package == "beta::other::parts" + assert gadget.resolution_key() == "beta::other::parts::Gadget" + assert gadget.effective_package() == "beta::other::parts" + + +def test_effective_package_of_an_object_without_own_package(tmp_path) -> None: # type: ignore[no-untyped-def] + nodes = _load(tmp_path) + assert nodes["Widget"].package is None + assert nodes["Widget"].effective_package() == "beta::other" + assert nodes["Store"].effective_package() == "acme::app" diff --git a/server/python/tests/unit/test_resolution_key.py b/server/python/tests/unit/test_resolution_key.py index 79972f067..fcf7d3c79 100644 --- a/server/python/tests/unit/test_resolution_key.py +++ b/server/python/tests/unit/test_resolution_key.py @@ -69,3 +69,16 @@ def test_resolution_key_file_default_beats_ancestor_walk() -> None: def test_resolution_key_bare_name_when_no_package_anywhere() -> None: n = _Node("object", "entity", "Person") assert n.resolution_key() == "Person" + + +def test_effective_package_is_file_default_when_own_unset() -> None: + """``package`` is None on an object that inherits its file's root package; + ``effective_package()`` is the accessor that answers "which package".""" + n = _Node("object", "entity", "Person") + n.file_default_package = "com::example::om" + assert n.package is None + assert n.effective_package() == "com::example::om" + + +def test_effective_package_is_empty_without_any_package() -> None: + assert _Node("object", "entity", "Person").effective_package() == "" From 573d236a306036dd0ea46f8630cc012b631e9c16 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Mon, 5 Oct 2026 22:32:07 -0400 Subject: [PATCH 2/2] chore(site): refresh site payload for the new conformance fixture --- examples/showcase/site-payload.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 351cf1838..6bb2339bc 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.1" }, "counts": { - "fixtures": 363, + "fixtures": 364, "corpora": 26, "baseTypes": 17 },