Skip to content

Commit 5153aa9

Browse files
dmealingclaude
andauthored
feat(verify): warn about dangling reference fields and duplicate field names (#404)
* feat(verify): warn about a dangling reference field and a duplicate field name, in every port Two metadata mistakes about an object's fields load with no error on every port: - an identity.reference whose @fields names a field the object does not have (WARN_REFERENCE_FIELD_NOT_FOUND); - a field name declared more than once in one object's children list (WARN_DUPLICATE_FIELD_NAME). The compatibility policy does not allow a new load error for metadata that loads today, so both are advisory verify warnings. They never change the exit code, and nothing that loaded before stops loading. The lint runs on every verify in all four CLIs: Node `meta verify` (a `fields` section in the structured payload), `dotnet meta verify`, `mvn metaobjects:verify` (which is also the Kotlin path) and `metaobjects verify`. It is muted by --no-field-lint (-Dmeta.verify.noFieldLint=true in Maven) or META_NO_FIELD_LINT=1. Inheritance and overlays: - The reference half reads the loaded model, so a field inherited through `extends` or added by an overlay file counts as present. A reference is reported once, on the object that declares it. - 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 model keeps no trace of it; the Python loader keeps both nodes. One scan of the document gives every port the same answer. Scope is one children list: a subtype overriding an inherited field and an overlay redeclaring a field are not findings. The codes, node addresses and message text are shared through the new fixtures/field-lint-conformance corpus (13 cases), which each port's runner loads strict first, proving every condition loads clean today. The earlier whitespace, overlay and deprecation lints remain Node-only; this is the first authoring lint the other three CLIs run. No loader is changed. * chore(site): refresh site payload for the 26th shared corpus * no-mistakes(review): expand Python relative-package reference-lint address; add gating fixture * no-mistakes(review): document Python multi-root-package lint address corner * no-mistakes(document): Documentation for verify field-lint warnings complete and consistent across all sources. * chore: remove gate scratch directories committed by the pipeline's document step .tmp-java-drive/ and server/typescript/.tmp-drive/ are throwaway projects the validation run used to drive the CLIs by hand. They are not part of the change. * chore: ignore gate scratch directories; drop a stray conflict marker from AGENTS.md .tmp-java-drive/ and .tmp-drive/ are throwaway projects the validation gate builds to drive the CLIs by hand; ignoring them keeps a later run from committing them again. * chore: remove stray root-level .tmp-drive scratch directory Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ecb776e commit 5153aa9

59 files changed

Lines changed: 2790 additions & 13 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ tools/prerelease/registry.env
7878
hs_err_pid*.log
7979
replay_pid*.log
8080
core.[0-9]*
81+
82+
# Throwaway projects the validation gate builds to drive the CLIs by hand.
83+
.tmp-java-drive/
84+
.tmp-drive/

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
7575
- **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.
7676

7777
**Cross-port conformance corpora** (every port runs the shared corpus):
78-
- 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.
78+
- 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.
7979
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
8080
- 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".)
8181
- 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.

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ it until 1.1 ships._
2828
validation".
2929
- **TypeScript: `runValidators` is exported from `@metaobjectsdev/runtime-ts`**, with
3030
`RunValidatorsOpts`. It was reachable only through `ObjectManager.validate()` before.
31+
- **`verify` warns about two field mistakes the loader accepts, in every port.** An
32+
`identity.reference` whose `@fields` names a field the object does not have gets
33+
`WARN_REFERENCE_FIELD_NOT_FOUND`. A field name declared more than once in one object's
34+
`children` list gets `WARN_DUPLICATE_FIELD_NAME`. Both load with no error today and still
35+
do: these are advisory warnings, they never change the exit code, and nothing that loaded
36+
before stops loading. A field inherited through `extends` or added by an overlay file
37+
counts as present, and neither a subtype overriding an inherited field nor an overlay
38+
redeclaring one is a duplicate. The lint runs on every `meta verify`, `dotnet meta verify`,
39+
`mvn metaobjects:verify` and `metaobjects verify`, with the same codes and message text,
40+
gated by the new `fixtures/field-lint-conformance/` corpus. Mute it with `--no-field-lint`
41+
(`-Dmeta.verify.noFieldLint=true` in Maven) or `META_NO_FIELD_LINT=1`. In the Node `meta`
42+
it is the `fields` section of `--format json|toon`.
3143

3244
- **Metamodel 1.1: the reporting vocabulary (FR-044), loader-validated in all five ports.**
3345
Registered: `dimension.attribute`, `dimension.time` (`@grains`: `hour, day, week, month,

‎docs/CONFORMANCE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Conformance coverage
22

3-
The MetaObjects standard ships **25 shared conformance corpora** under
3+
The MetaObjects standard ships **26 shared conformance corpora** under
44
[`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to
55
it* and asserts the same expected behaviour against the same fixtures. **This page
66
is the inverse index**: fixture → feature doc + per-port pass status, and it is the
@@ -49,6 +49,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
4949
| [`fixtures/agent-context-conformance/`](../fixtures/agent-context-conformance/) | 4 | ✓ (the emitter is TS-owned) | — | — | — | — |
5050
| [`fixtures/metamodel-docs/`](../fixtures/metamodel-docs/) | 1 | ✓ (docs emit is TS-owned) | — | — | — | — |
5151
| [`fixtures/fmt-conformance/`](../fixtures/fmt-conformance/) (#304 — `meta fmt`) | 12 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
52+
| [`fixtures/field-lint-conformance/`](../fixtures/field-lint-conformance/) (the `verify` field authoring lint) | 14 | ✓ (reference) | ✓ | inherits via Java | ✓ | ✓ |
5253
| [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ |
5354
| [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary is inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ |
5455

@@ -71,8 +72,9 @@ the corpora above do two different jobs. Only the first is a promise to adopters
7172
Mustache engine), `template-output-render-conformance/`, `output-prompt-conformance/`,
7273
`extract-conformance/`, `verify-conformance/`, `verify-strict-conformance/`,
7374
`persistence-conformance/` (runtime reads and writes, and the TS-owned migration
74-
scenarios), `agent-context-conformance/`, `metamodel-docs/` and `fmt-conformance/`
75-
(the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core).
75+
scenarios), `agent-context-conformance/`, `metamodel-docs/`, `fmt-conformance/`
76+
(the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core)
77+
and `field-lint-conformance/` (the advisory field lint every port's `verify` prints).
7678
A red cell here is a MetaObjects bug.
7779
- **Template quality checks — not a promise.** The generated lane of
7880
`api-contract-conformance/`, `generator-registry-conformance/` (stable generator names),

‎docs/features/cli.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,28 @@ Rules of the contract:
121121
`deprecated` inherited through the TARGET's own `extends` chain still counts. Advisory
122122
only, appears as `deprecations` in `--format json|toon`, and is muted with
123123
`--no-deprecation-lint` or `META_NO_DEPRECATION_LINT=1`.
124+
- **The field authoring lint runs on every `verify`, in every port.** It reports two
125+
mistakes that load with no error. `WARN_REFERENCE_FIELD_NOT_FOUND` flags an
126+
`identity.reference` whose `@fields` names a field the object does not have; a field
127+
inherited through `extends` or added by an overlay file counts as present.
128+
`WARN_DUPLICATE_FIELD_NAME` flags a field name declared more than once in one object's
129+
`children` list; a subtype overriding an inherited field and an overlay redeclaring a
130+
field are not findings. This lint is advisory only and never changes the exit code. In
131+
the Node `meta` it appears as `fields` in `--format json|toon`. The other ports print it
132+
as text on stderr (Maven logs it as warnings). Mute it per port:
133+
134+
| CLI | Flag | Environment |
135+
|---|---|---|
136+
| Node `meta verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
137+
| `dotnet meta verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
138+
| `mvn metaobjects:verify` | `-Dmeta.verify.noFieldLint=true` | `META_NO_FIELD_LINT=1` |
139+
| `metaobjects verify` | `--no-field-lint` | `META_NO_FIELD_LINT=1` |
140+
141+
The codes and message text are identical in every port, gated by
142+
[`fixtures/field-lint-conformance/`](../../fixtures/field-lint-conformance/README.md). In
143+
Python, for a multi-file collection whose roots declare different packages, the address
144+
printed for a `::`-relative package is expanded against the merged root's package and can
145+
differ from the other ports.
124146

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

‎examples/showcase/site-payload.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"counts": {
1010
"fixtures": 363,
11-
"corpora": 25,
11+
"corpora": 26,
1212
"baseTypes": 17
1313
},
1414
"snippets": {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# `verify` field-lint conformance corpus
2+
3+
Every port's `verify` command prints an advisory **field authoring lint**. It reports two
4+
metadata mistakes that load with no error:
5+
6+
| Code | What it reports |
7+
|---|---|
8+
| `WARN_REFERENCE_FIELD_NOT_FOUND` | An `identity.reference` whose `@fields` names a field its object does not have. |
9+
| `WARN_DUPLICATE_FIELD_NAME` | A field name declared more than once in one object's `children` list. |
10+
11+
Both are warnings and never a load error: the compatibility policy
12+
(`docs/compatibility-policy.md`) does not allow a new load error for metadata that loads
13+
today. Neither reaches an exit code.
14+
15+
This corpus is the shared source of truth for the codes, the node addresses and the message
16+
text. Each port's own test suite runs it, so the ports cannot drift.
17+
18+
## Fixture format
19+
20+
Each case is a directory:
21+
22+
- `input/` holds one or more metadata documents (`.json` or `.yaml`).
23+
- `expected.json` holds `{ "findings": [{ "code", "path", "message" }] }`.
24+
25+
A runner does three things, in this order:
26+
27+
1. Load `input/` with the port's loader, strict, and assert **no load errors**. This is
28+
what proves each condition loads today.
29+
2. Run the reference half over the loaded model and the duplicate half over the raw files
30+
in `input/`.
31+
3. Compare the findings with `expected.json` as an unordered set of
32+
`(code, path, message)`.
33+
34+
`path` is the declaring object's resolution key (`<package>::<name>`), a dot, then the
35+
identity name or the field name. In Python, for a multi-file collection whose roots declare
36+
different packages, the address printed for a `::`-relative package is expanded against the
37+
merged root's package and can differ from the other ports.
38+
39+
## The two halves read different things
40+
41+
**The reference half reads the loaded model.** Whether an object has a field is a question
42+
about its *effective* field set, so the lint counts:
43+
44+
- a field **inherited** through `extends` (`reference-field-inherited-clean`);
45+
- a field added by an **overlay** file (`reference-field-from-overlay-clean`).
46+
47+
A reference is reported once, on the object that declares it, and is checked against that
48+
object's effective fields. An inherited reference is not repeated on every subtype
49+
(`reference-declared-on-base-reported-once`).
50+
51+
**The duplicate half reads the raw documents.** The TypeScript, C# and Java loaders fold a
52+
repeated field into the first declaration and drop one of a different subtype, so their
53+
loaded model keeps no trace of the duplicate. The Python loader keeps both nodes. One scan
54+
of the document gives every port the same answer. The scan covers root-level objects, and
55+
accepts the YAML authoring sugar (a bare `field` key, a scalar body, a `[]` key suffix).
56+
57+
The scope is **one `children` list**. These are not findings:
58+
59+
- a subtype redeclaring an inherited field. That is an override
60+
(`duplicate-inherited-override-clean`).
61+
- an overlay file redeclaring a field of its base. That is the overlay merge
62+
(`duplicate-across-overlay-clean`).
63+
64+
## Who asserts it
65+
66+
| Port | Runner |
67+
|---|---|
68+
| TypeScript (reference) | `server/typescript/packages/cli/test/field-lint-conformance.test.ts` |
69+
| C# | `server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs` |
70+
| Java / Kotlin | `server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java` |
71+
| Python | `server/python/tests/conformance/test_field_lint_conformance.py` |
72+
73+
Kotlin has no CLI of its own. Its codegen runs through the Maven `metaobjects:verify` goal,
74+
so the Java runner covers it.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"findings": []
3+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::app",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Owner",
8+
"children": [
9+
{
10+
"field.long": {
11+
"name": "id"
12+
}
13+
},
14+
{
15+
"identity.primary": {
16+
"name": "pk",
17+
"@fields": [
18+
"id"
19+
]
20+
}
21+
}
22+
]
23+
}
24+
},
25+
{
26+
"object.entity": {
27+
"name": "Item",
28+
"children": [
29+
{
30+
"field.long": {
31+
"name": "id"
32+
}
33+
},
34+
{
35+
"field.long": {
36+
"name": "ownerId"
37+
}
38+
},
39+
{
40+
"identity.primary": {
41+
"name": "pk",
42+
"@fields": [
43+
"id"
44+
]
45+
}
46+
},
47+
{
48+
"identity.reference": {
49+
"name": "owner_fk",
50+
"@fields": [
51+
"ownerId"
52+
],
53+
"@references": "Owner"
54+
}
55+
}
56+
]
57+
}
58+
}
59+
]
60+
}
61+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"findings": []
3+
}

0 commit comments

Comments
 (0)