Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | 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 | ✓ | — | — | — | ✓ |
Expand All @@ -49,7 +49,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | 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) | ✓ | ✓ | ✓ | ✓ |

Expand Down Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions docs/ports/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/showcase/site-payload.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"metamodel": "1.1"
},
"counts": {
"fixtures": 363,
"fixtures": 364,
"corpora": 26,
"baseTypes": 17
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
}
]
}
}
5 changes: 2 additions & 3 deletions fixtures/field-lint-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,8 @@ A runner does three things, in this order:
`(code, path, message)`.

`path` is the declaring object's resolution key (`<package>::<name>`), 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
}
]
}
}
11 changes: 3 additions & 8 deletions server/python/src/metaobjects/field_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading