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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@ tools/prerelease/registry.env
hs_err_pid*.log
replay_pid*.log
core.[0-9]*

# Throwaway projects the validation gate builds to drive the CLIs by hand.
.tmp-java-drive/
.tmp-drive/
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; 25 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/` (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.
- 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ it until 1.1 ships._
validation".
- **TypeScript: `runValidators` is exported from `@metaobjectsdev/runtime-ts`**, with
`RunValidatorsOpts`. It was reachable only through `ObjectManager.validate()` before.
- **`verify` warns about two field mistakes the loader accepts, in every port.** An
`identity.reference` whose `@fields` names a field the object does not have gets
`WARN_REFERENCE_FIELD_NOT_FOUND`. A field name declared more than once in one object's
`children` list gets `WARN_DUPLICATE_FIELD_NAME`. Both load with no error today and still
do: these are advisory warnings, they never change the exit code, and nothing that loaded
before stops loading. A field inherited through `extends` or added by an overlay file
counts as present, and neither a subtype overriding an inherited field nor an overlay
redeclaring one is a duplicate. The lint runs on every `meta verify`, `dotnet meta verify`,
`mvn metaobjects:verify` and `metaobjects verify`, with the same codes and message text,
gated by the new `fixtures/field-lint-conformance/` corpus. Mute it with `--no-field-lint`
(`-Dmeta.verify.noFieldLint=true` in Maven) or `META_NO_FIELD_LINT=1`. In the Node `meta`
it is the `fields` section of `--format json|toon`.

- **Metamodel 1.1: the reporting vocabulary (FR-044), loader-validated in all five ports.**
Registered: `dimension.attribute`, `dimension.time` (`@grains`: `hour, day, week, month,
Expand Down
8 changes: 5 additions & 3 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Conformance coverage

The MetaObjects standard ships **25 shared conformance corpora** under
The MetaObjects standard ships **26 shared conformance corpora** under
[`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to
it* and asserts the same expected behaviour against the same fixtures. **This page
is the inverse index**: fixture → feature doc + per-port pass status, and it is the
Expand Down Expand Up @@ -49,6 +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/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 All @@ -71,8 +72,9 @@ the corpora above do two different jobs. Only the first is a promise to adopters
Mustache engine), `template-output-render-conformance/`, `output-prompt-conformance/`,
`extract-conformance/`, `verify-conformance/`, `verify-strict-conformance/`,
`persistence-conformance/` (runtime reads and writes, and the TS-owned migration
scenarios), `agent-context-conformance/`, `metamodel-docs/` and `fmt-conformance/`
(the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core).
scenarios), `agent-context-conformance/`, `metamodel-docs/`, `fmt-conformance/`
(the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core)
and `field-lint-conformance/` (the advisory field lint every port's `verify` prints).
A red cell here is a MetaObjects bug.
- **Template quality checks — not a promise.** The generated lane of
`api-contract-conformance/`, `generator-registry-conformance/` (stable generator names),
Expand Down
22 changes: 22 additions & 0 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,28 @@ Rules of the contract:
`deprecated` inherited through the TARGET's own `extends` chain still counts. Advisory
only, appears as `deprecations` in `--format json|toon`, and is muted with
`--no-deprecation-lint` or `META_NO_DEPRECATION_LINT=1`.
- **The field authoring lint runs on every `verify`, in every port.** It reports two
mistakes that load with no error. `WARN_REFERENCE_FIELD_NOT_FOUND` flags an
`identity.reference` whose `@fields` names a field the object does not have; a field
inherited through `extends` or added by an overlay file counts as present.
`WARN_DUPLICATE_FIELD_NAME` flags a field name declared more than once in one object's
`children` list; a subtype overriding an inherited field and an overlay redeclaring a
field are not findings. This lint is advisory only and never changes the exit code. In
the Node `meta` it appears as `fields` in `--format json|toon`. The other ports print it
as text on stderr (Maven logs it as warnings). Mute it per port:

| CLI | Flag | Environment |
|---|---|---|
| Node `meta verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
| `dotnet meta verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
| `mvn metaobjects:verify` | `-Dmeta.verify.noFieldLint=true` | `META_NO_FIELD_LINT=1` |
| `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.

### The prompt directory: `--prompts` everywhere (F101)

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 @@ -8,7 +8,7 @@
},
"counts": {
"fixtures": 363,
"corpora": 25,
"corpora": 26,
"baseTypes": 17
},
"snippets": {
Expand Down
74 changes: 74 additions & 0 deletions fixtures/field-lint-conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `verify` field-lint conformance corpus

Every port's `verify` command prints an advisory **field authoring lint**. It reports two
metadata mistakes that load with no error:

| Code | What it reports |
|---|---|
| `WARN_REFERENCE_FIELD_NOT_FOUND` | An `identity.reference` whose `@fields` names a field its object does not have. |
| `WARN_DUPLICATE_FIELD_NAME` | A field name declared more than once in one object's `children` list. |

Both are warnings and never a load error: the compatibility policy
(`docs/compatibility-policy.md`) does not allow a new load error for metadata that loads
today. Neither reaches an exit code.

This corpus is the shared source of truth for the codes, the node addresses and the message
text. Each port's own test suite runs it, so the ports cannot drift.

## Fixture format

Each case is a directory:

- `input/` holds one or more metadata documents (`.json` or `.yaml`).
- `expected.json` holds `{ "findings": [{ "code", "path", "message" }] }`.

A runner does three things, in this order:

1. Load `input/` with the port's loader, strict, and assert **no load errors**. This is
what proves each condition loads today.
2. Run the reference half over the loaded model and the duplicate half over the raw files
in `input/`.
3. Compare the findings with `expected.json` as an unordered set of
`(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.

## The two halves read different things

**The reference half reads the loaded model.** Whether an object has a field is a question
about its *effective* field set, so the lint counts:

- a field **inherited** through `extends` (`reference-field-inherited-clean`);
- a field added by an **overlay** file (`reference-field-from-overlay-clean`).

A reference is reported once, on the object that declares it, and is checked against that
object's effective fields. An inherited reference is not repeated on every subtype
(`reference-declared-on-base-reported-once`).

**The duplicate half reads the raw documents.** The TypeScript, C# and Java loaders fold a
repeated field into the first declaration and drop one of a different subtype, so their
loaded model keeps no trace of the duplicate. The Python loader keeps both nodes. One scan
of the document gives every port the same answer. The scan covers root-level objects, and
accepts the YAML authoring sugar (a bare `field` key, a scalar body, a `[]` key suffix).

The scope is **one `children` list**. These are not findings:

- a subtype redeclaring an inherited field. That is an override
(`duplicate-inherited-override-clean`).
- an overlay file redeclaring a field of its base. That is the overlay merge
(`duplicate-across-overlay-clean`).

## Who asserts it

| Port | Runner |
|---|---|
| TypeScript (reference) | `server/typescript/packages/cli/test/field-lint-conformance.test.ts` |
| C# | `server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs` |
| Java / Kotlin | `server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java` |
| Python | `server/python/tests/conformance/test_field_lint_conformance.py` |

Kotlin has no CLI of its own. Its codegen runs through the Maven `metaobjects:verify` goal,
so the Java runner covers it.
3 changes: 3 additions & 0 deletions fixtures/field-lint-conformance/clean/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"findings": []
}
61 changes: 61 additions & 0 deletions fixtures/field-lint-conformance/clean/input/meta.app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.entity": {
"name": "Owner",
"children": [
{
"field.long": {
"name": "id"
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
},
{
"object.entity": {
"name": "Item",
"children": [
{
"field.long": {
"name": "id"
}
},
{
"field.long": {
"name": "ownerId"
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
},
{
"identity.reference": {
"name": "owner_fk",
"@fields": [
"ownerId"
],
"@references": "Owner"
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"findings": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.entity": {
"name": "Item",
"children": [
{
"field.long": {
"name": "id"
}
},
{
"field.string": {
"name": "label"
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.entity": {
"name": "Item",
"overlay": true,
"children": [
{
"field.string": {
"name": "label",
"@maxLength": 40
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"findings": [
{
"code": "WARN_DUPLICATE_FIELD_NAME",
"path": "acme::app::Item.label",
"message": "acme::app::Item declares the field \"label\" 2 times in one children list. Nothing reports this at load, and only the first declaration is certain to take effect. Remove or rename the duplicate."
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.entity": {
"name": "Item",
"children": [
{
"field.long": {
"name": "id"
}
},
{
"field.string": {
"name": "label"
}
},
{
"field.int": {
"name": "label"
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"findings": [
{
"code": "WARN_DUPLICATE_FIELD_NAME",
"path": "acme::app::stock::Item.label",
"message": "acme::app::stock::Item declares the field \"label\" 2 times in one children list. Nothing reports this at load, and only the first declaration is certain to take effect. Remove or rename the duplicate."
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"metadata.root": {
"package": "acme::app",
"children": [
{
"object.entity": {
"name": "Item",
"package": "::stock",
"children": [
{
"field.long": {
"name": "id"
}
},
{
"field.string": {
"name": "label"
}
},
{
"field.string": {
"name": "label"
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
}
]
}
}
Loading
Loading