From 96ca704c203b1348bce2023c7dcce7b2837dcb9a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 14:30:41 -0400 Subject: [PATCH 1/8] 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. --- AGENTS.md | 4 +- CHANGELOG.md | 12 + docs/CONFORMANCE.md | 8 +- docs/features/cli.md | 19 ++ fixtures/field-lint-conformance/README.md | 72 +++++ .../clean/expected.json | 3 + .../clean/input/meta.app.json | 61 ++++ .../expected.json | 3 + .../input/meta.app.json | 32 ++ .../input/meta.app.ui.json | 21 ++ .../expected.json | 9 + .../input/meta.app.json | 37 +++ .../duplicate-field-own-package/expected.json | 9 + .../input/meta.app.json | 38 +++ .../expected.json | 9 + .../input/meta.app.json | 37 +++ .../duplicate-field-three-times/expected.json | 9 + .../input/meta.app.json | 42 +++ .../duplicate-field-yaml/expected.json | 9 + .../duplicate-field-yaml/input/meta.app.yaml | 13 + .../expected.json | 3 + .../input/meta.app.json | 48 +++ .../expected.json | 9 + .../input/meta.app.json | 62 ++++ .../expected.json | 9 + .../input/meta.app.json | 83 +++++ .../expected.json | 3 + .../input/meta.app.db.json | 20 ++ .../input/meta.app.json | 56 ++++ .../expected.json | 3 + .../input/meta.app.json | 75 +++++ .../reference-field-missing/expected.json | 9 + .../input/meta.app.json | 61 ++++ .../FieldLintConformanceTests.cs | 59 ++++ .../VerifyFieldLintTests.cs | 76 +++++ server/csharp/MetaObjects.Cli/FieldLint.cs | 286 ++++++++++++++++++ server/csharp/MetaObjects.Cli/Program.cs | 13 +- .../java/com/metaobjects/mojo/FieldLint.java | 214 +++++++++++++ .../metaobjects/mojo/MetaDataVerifyMojo.java | 55 ++++ .../mojo/FieldLintConformanceTest.java | 93 ++++++ .../mojo/MetaDataVerifyFieldLintTest.java | 93 ++++++ server/python/src/metaobjects/cli.py | 74 +++++ server/python/src/metaobjects/field_lint.py | 214 +++++++++++++ .../codegen/test_cli_verify_field_lint.py | 100 ++++++ .../test_field_lint_conformance.py | 49 +++ .../packages/cli/src/commands/verify.ts | 57 +++- server/typescript/packages/cli/src/index.ts | 12 + .../typescript/packages/cli/src/lib/args.ts | 9 + .../packages/cli/src/lib/field-lint.ts | 122 ++++++++ .../cli/test/__snapshots__/cli.test.ts.snap | 3 + .../cli/test/field-lint-conformance.test.ts | 45 +++ .../cli/test/unit/args-verify.test.ts | 10 +- .../cli/test/verify-field-lint.test.ts | 126 ++++++++ .../packages/metadata/src/core/index.ts | 5 + .../src/loader/declared-duplicate-fields.ts | 114 +++++++ 55 files changed, 2705 insertions(+), 12 deletions(-) create mode 100644 fixtures/field-lint-conformance/README.md create mode 100644 fixtures/field-lint-conformance/clean/expected.json create mode 100644 fixtures/field-lint-conformance/clean/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-across-overlay-clean/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.ui.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-different-subtype/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-different-subtype/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-own-package/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-own-package/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-same-subtype/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-same-subtype/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-three-times/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-three-times/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-yaml/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-field-yaml/input/meta.app.yaml create mode 100644 fixtures/field-lint-conformance/duplicate-inherited-override-clean/expected.json create mode 100644 fixtures/field-lint-conformance/duplicate-inherited-override-clean/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-composite-one-missing/expected.json create mode 100644 fixtures/field-lint-conformance/reference-composite-one-missing/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-declared-on-base-reported-once/expected.json create mode 100644 fixtures/field-lint-conformance/reference-declared-on-base-reported-once/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-field-from-overlay-clean/expected.json create mode 100644 fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.db.json create mode 100644 fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-field-inherited-clean/expected.json create mode 100644 fixtures/field-lint-conformance/reference-field-inherited-clean/input/meta.app.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing/expected.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing/input/meta.app.json create mode 100644 server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs create mode 100644 server/csharp/MetaObjects.Cli.Tests/VerifyFieldLintTests.cs create mode 100644 server/csharp/MetaObjects.Cli/FieldLint.cs create mode 100644 server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FieldLint.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java create mode 100644 server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyFieldLintTest.java create mode 100644 server/python/src/metaobjects/field_lint.py create mode 100644 server/python/tests/codegen/test_cli_verify_field_lint.py create mode 100644 server/python/tests/conformance/test_field_lint_conformance.py create mode 100644 server/typescript/packages/cli/src/lib/field-lint.ts create mode 100644 server/typescript/packages/cli/test/field-lint-conformance.test.ts create mode 100644 server/typescript/packages/cli/test/verify-field-lint.test.ts create mode 100644 server/typescript/packages/metadata/src/loader/declared-duplicate-fields.ts diff --git a/AGENTS.md b/AGENTS.md index 4832e9738..67700d96f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,9 @@ 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. +||||||| parent of 77e6777d3 (feat(verify): warn about a dangling reference field and a duplicate field name, in every port) +- Metamodel: `fixtures/conformance/` (361 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 11136d113..7ce794df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index ec83f75ea..7b8d37c72 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -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 @@ -49,6 +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) | 13 | ✓ (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) | ✓ | ✓ | ✓ | ✓ | @@ -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), diff --git a/docs/features/cli.md b/docs/features/cli.md index 05b823536..0c3c1f8e3 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -121,6 +121,25 @@ 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). ### The prompt directory: `--prompts` everywhere (F101) diff --git a/fixtures/field-lint-conformance/README.md b/fixtures/field-lint-conformance/README.md new file mode 100644 index 000000000..2e8e290db --- /dev/null +++ b/fixtures/field-lint-conformance/README.md @@ -0,0 +1,72 @@ +# `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 (`::`), a dot, then the +identity name or the field name. + +## 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. diff --git a/fixtures/field-lint-conformance/clean/expected.json b/fixtures/field-lint-conformance/clean/expected.json new file mode 100644 index 000000000..2ef564861 --- /dev/null +++ b/fixtures/field-lint-conformance/clean/expected.json @@ -0,0 +1,3 @@ +{ + "findings": [] +} diff --git a/fixtures/field-lint-conformance/clean/input/meta.app.json b/fixtures/field-lint-conformance/clean/input/meta.app.json new file mode 100644 index 000000000..8cf39ef89 --- /dev/null +++ b/fixtures/field-lint-conformance/clean/input/meta.app.json @@ -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" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-across-overlay-clean/expected.json b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/expected.json new file mode 100644 index 000000000..2ef564861 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/expected.json @@ -0,0 +1,3 @@ +{ + "findings": [] +} diff --git a/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.json new file mode 100644 index 000000000..e60520cd9 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.json @@ -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" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.ui.json b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.ui.json new file mode 100644 index 000000000..ae423dadf --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-across-overlay-clean/input/meta.app.ui.json @@ -0,0 +1,21 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Item", + "overlay": true, + "children": [ + { + "field.string": { + "name": "label", + "@maxLength": 40 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-field-different-subtype/expected.json b/fixtures/field-lint-conformance/duplicate-field-different-subtype/expected.json new file mode 100644 index 000000000..a4fe17d78 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-different-subtype/expected.json @@ -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." + } + ] +} diff --git a/fixtures/field-lint-conformance/duplicate-field-different-subtype/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-field-different-subtype/input/meta.app.json new file mode 100644 index 000000000..88749480b --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-different-subtype/input/meta.app.json @@ -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" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-field-own-package/expected.json b/fixtures/field-lint-conformance/duplicate-field-own-package/expected.json new file mode 100644 index 000000000..2e6cbaa3c --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-own-package/expected.json @@ -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." + } + ] +} diff --git a/fixtures/field-lint-conformance/duplicate-field-own-package/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-field-own-package/input/meta.app.json new file mode 100644 index 000000000..ffe0f684b --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-own-package/input/meta.app.json @@ -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" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-field-same-subtype/expected.json b/fixtures/field-lint-conformance/duplicate-field-same-subtype/expected.json new file mode 100644 index 000000000..a4fe17d78 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-same-subtype/expected.json @@ -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." + } + ] +} diff --git a/fixtures/field-lint-conformance/duplicate-field-same-subtype/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-field-same-subtype/input/meta.app.json new file mode 100644 index 000000000..41f0feaa0 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-same-subtype/input/meta.app.json @@ -0,0 +1,37 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Item", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-field-three-times/expected.json b/fixtures/field-lint-conformance/duplicate-field-three-times/expected.json new file mode 100644 index 000000000..9a6250138 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-three-times/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_DUPLICATE_FIELD_NAME", + "path": "acme::app::Item.label", + "message": "acme::app::Item declares the field \"label\" 3 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." + } + ] +} diff --git a/fixtures/field-lint-conformance/duplicate-field-three-times/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-field-three-times/input/meta.app.json new file mode 100644 index 000000000..6273359c3 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-three-times/input/meta.app.json @@ -0,0 +1,42 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Item", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "field.string": { + "name": "label" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/duplicate-field-yaml/expected.json b/fixtures/field-lint-conformance/duplicate-field-yaml/expected.json new file mode 100644 index 000000000..a4fe17d78 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-yaml/expected.json @@ -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." + } + ] +} diff --git a/fixtures/field-lint-conformance/duplicate-field-yaml/input/meta.app.yaml b/fixtures/field-lint-conformance/duplicate-field-yaml/input/meta.app.yaml new file mode 100644 index 000000000..85d443996 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-field-yaml/input/meta.app.yaml @@ -0,0 +1,13 @@ +metadata: + package: acme::app + children: + - object.entity: + name: Item + children: + - field.long: id + - field.string: label + - field.string[]: + name: label + - identity.primary: + name: pk + fields: [id] diff --git a/fixtures/field-lint-conformance/duplicate-inherited-override-clean/expected.json b/fixtures/field-lint-conformance/duplicate-inherited-override-clean/expected.json new file mode 100644 index 000000000..2ef564861 --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-inherited-override-clean/expected.json @@ -0,0 +1,3 @@ +{ + "findings": [] +} diff --git a/fixtures/field-lint-conformance/duplicate-inherited-override-clean/input/meta.app.json b/fixtures/field-lint-conformance/duplicate-inherited-override-clean/input/meta.app.json new file mode 100644 index 000000000..cb0dc6bba --- /dev/null +++ b/fixtures/field-lint-conformance/duplicate-inherited-override-clean/input/meta.app.json @@ -0,0 +1,48 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Base", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "label", + "@maxLength": 10 + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "extends": "Base", + "children": [ + { + "field.string": { + "name": "label", + "@maxLength": 20 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-composite-one-missing/expected.json b/fixtures/field-lint-conformance/reference-composite-one-missing/expected.json new file mode 100644 index 000000000..848b0871f --- /dev/null +++ b/fixtures/field-lint-conformance/reference-composite-one-missing/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "acme::app::Item.owner_fk", + "message": "identity.reference \"owner_fk\" lists \"region\" in @fields, but acme::app::Item 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-composite-one-missing/input/meta.app.json b/fixtures/field-lint-conformance/reference-composite-one-missing/input/meta.app.json new file mode 100644 index 000000000..22cae4cc2 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-composite-one-missing/input/meta.app.json @@ -0,0 +1,62 @@ +{ + "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", + "region" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-declared-on-base-reported-once/expected.json b/fixtures/field-lint-conformance/reference-declared-on-base-reported-once/expected.json new file mode 100644 index 000000000..6cb7d3b89 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-declared-on-base-reported-once/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "acme::app::Owned.owner_fk", + "message": "identity.reference \"owner_fk\" lists \"ownerIdd\" in @fields, but acme::app::Owned 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-declared-on-base-reported-once/input/meta.app.json b/fixtures/field-lint-conformance/reference-declared-on-base-reported-once/input/meta.app.json new file mode 100644 index 000000000..78a716f59 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-declared-on-base-reported-once/input/meta.app.json @@ -0,0 +1,83 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Owned", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "extends": "Owned", + "children": [ + { + "field.string": { + "name": "label" + } + } + ] + } + }, + { + "object.entity": { + "name": "Crate", + "extends": "Owned", + "children": [ + { + "field.string": { + "name": "tag" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-field-from-overlay-clean/expected.json b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/expected.json new file mode 100644 index 000000000..2ef564861 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/expected.json @@ -0,0 +1,3 @@ +{ + "findings": [] +} diff --git a/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.db.json b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.db.json new file mode 100644 index 000000000..24cada513 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.db.json @@ -0,0 +1,20 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Item", + "overlay": true, + "children": [ + { + "field.long": { + "name": "ownerId" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.json b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.json new file mode 100644 index 000000000..fc8dfd872 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-from-overlay-clean/input/meta.app.json @@ -0,0 +1,56 @@ +{ + "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" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerId" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-field-inherited-clean/expected.json b/fixtures/field-lint-conformance/reference-field-inherited-clean/expected.json new file mode 100644 index 000000000..2ef564861 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-inherited-clean/expected.json @@ -0,0 +1,3 @@ +{ + "findings": [] +} diff --git a/fixtures/field-lint-conformance/reference-field-inherited-clean/input/meta.app.json b/fixtures/field-lint-conformance/reference-field-inherited-clean/input/meta.app.json new file mode 100644 index 000000000..cc8d3dd8c --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-inherited-clean/input/meta.app.json @@ -0,0 +1,75 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Owned", + "abstract": true, + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "extends": "Owned", + "children": [ + { + "field.string": { + "name": "label" + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerId" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/field-lint-conformance/reference-field-missing/expected.json b/fixtures/field-lint-conformance/reference-field-missing/expected.json new file mode 100644 index 000000000..963a686c0 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "acme::app::Item.owner_fk", + "message": "identity.reference \"owner_fk\" lists \"ownerIdd\" in @fields, but acme::app::Item 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/input/meta.app.json b/fixtures/field-lint-conformance/reference-field-missing/input/meta.app.json new file mode 100644 index 000000000..52509a3e1 --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing/input/meta.app.json @@ -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": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs b/server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs new file mode 100644 index 000000000..6e9612fa0 --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/FieldLintConformanceTests.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using MetaObjects.Cli; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Cli.Tests; + +/// +/// Cross-port field-lint conformance corpus — fixtures/field-lint-conformance/. See +/// that directory's README.md for the fixture format. Every case is LOADED strict +/// first, so "this loads with no error today" is proven by the fixture, and only then +/// linted. Mirrors server/typescript/packages/cli/test/field-lint-conformance.test.ts +/// exactly; a mismatch here is a bug in THIS port's lint, never in the fixture. +/// +public class FieldLintConformanceTests +{ + private static readonly string CorpusDir = ResolveCorpusDir(); + + private static string ResolveCorpusDir() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + var candidate = Path.Combine(dir.FullName, "fixtures", "field-lint-conformance"); + if (Directory.Exists(candidate)) return candidate; + dir = dir.Parent; + } + throw new DirectoryNotFoundException( + "Could not locate fixtures/field-lint-conformance/ by walking up from " + AppContext.BaseDirectory); + } + + public static IEnumerable Fixtures() => + Directory.GetDirectories(CorpusDir).OrderBy(d => d, StringComparer.Ordinal).Select(d => new object[] { Path.GetFileName(d) }); + + [Fact] + public void Discovers_the_corpus() => Assert.NotEmpty(Fixtures()); + + [Theory] + [MemberData(nameof(Fixtures))] + public void Fixture(string name) + { + var input = Path.Combine(CorpusDir, name, "input"); + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(CorpusDir, name, "expected.json"))); + var expected = doc.RootElement.GetProperty("findings").EnumerateArray() + .Select(f => (f.GetProperty("code").GetString()!, f.GetProperty("path").GetString()!, f.GetProperty("message").GetString()!)) + .Order().ToList(); + + var load = MetaDataLoader.FromDirectory(input, strict: true); + Assert.Empty(load.Errors.Select(e => e.Code + ": " + e.Message)); + + var files = Directory.GetFiles(input).OrderBy(f => f, StringComparer.Ordinal); + var actual = FieldLint.LintReferenceFields(load.Root) + .Concat(FieldLint.LintDuplicateFields(files)) + .Select(f => (f.Code, f.Path, f.Message)) + .Order().ToList(); + + Assert.Equal(expected, actual); + } +} diff --git a/server/csharp/MetaObjects.Cli.Tests/VerifyFieldLintTests.cs b/server/csharp/MetaObjects.Cli.Tests/VerifyFieldLintTests.cs new file mode 100644 index 000000000..a0bf9705c --- /dev/null +++ b/server/csharp/MetaObjects.Cli.Tests/VerifyFieldLintTests.cs @@ -0,0 +1,76 @@ +using Xunit; + +namespace MetaObjects.Cli.Tests; + +/// +/// dotnet meta verify — the field authoring lint, end to end: printed as its own +/// advisory section on stderr, never reaching the exit code, and muted by +/// --no-field-lint. Drives the BUILT CLI () so this proves +/// the wiring; the finding text is gated cross-port by . +/// +public sealed class VerifyFieldLintTests : IDisposable +{ + private readonly string _tmp = Directory.CreateTempSubdirectory("mo-field-lint-").FullName; + + public void Dispose() + { + try { Directory.Delete(_tmp, recursive: true); } catch { /* best effort */ } + } + + private (string Model, string Templates) Project(string referenceField, bool duplicateLabel) + { + const string label = """{ "field.string": { "name": "label" } },"""; + var model = Path.Combine(_tmp, "model"); + Directory.CreateDirectory(model); + File.WriteAllText(Path.Combine(model, "meta.app.json"), $$""" + { "metadata.root": { "package": "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" } }, + {{label}} + {{(duplicateLabel ? label : "")}} + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "owner_fk", "@fields": ["{{referenceField}}"], "@references": "Owner" } } ] } } + ] } } + """); + var templates = Path.Combine(_tmp, "templates"); + Directory.CreateDirectory(templates); + return (model, templates); + } + + [Fact] + public void Findings_are_advisory_and_do_not_change_the_exit_code() + { + var (model, templates) = Project("ownerIdd", duplicateLabel: true); + var (exitCode, stdout, stderr) = CliProcess.Run(_tmp, "verify", model, "--templates", "--prompts", templates); + + Assert.True(exitCode == 0, $"exit={exitCode}\nstdout={stdout}\nstderr={stderr}"); + Assert.Contains("dotnet meta verify — fields: 2 authoring warning(s) (advisory — does not fail the build):", stderr); + Assert.Contains("WARN_REFERENCE_FIELD_NOT_FOUND [app::Item.owner_fk]", stderr); + Assert.Contains("WARN_DUPLICATE_FIELD_NAME [app::Item.label]", stderr); + } + + [Fact] + public void Clean_metadata_prints_no_section() + { + var (model, templates) = Project("ownerId", duplicateLabel: false); + var (exitCode, _, stderr) = CliProcess.Run(_tmp, "verify", model, "--templates", "--prompts", templates); + + Assert.Equal(0, exitCode); + Assert.DoesNotContain("fields:", stderr); + } + + [Fact] + public void No_field_lint_silences_it() + { + var (model, templates) = Project("ownerIdd", duplicateLabel: true); + var (exitCode, _, stderr) = CliProcess.Run(_tmp, "verify", model, "--templates", "--prompts", templates, "--no-field-lint"); + + Assert.Equal(0, exitCode); + Assert.DoesNotContain("WARN_REFERENCE_FIELD_NOT_FOUND", stderr); + Assert.DoesNotContain("WARN_DUPLICATE_FIELD_NAME", stderr); + } +} diff --git a/server/csharp/MetaObjects.Cli/FieldLint.cs b/server/csharp/MetaObjects.Cli/FieldLint.cs new file mode 100644 index 000000000..294799e76 --- /dev/null +++ b/server/csharp/MetaObjects.Cli/FieldLint.cs @@ -0,0 +1,286 @@ +// `dotnet meta verify` — the field AUTHORING lint. +// +// Two metadata mistakes about an object's FIELDS load with no error on every port: +// +// 1. An `identity.reference` whose `@fields` names a field the object does not have. +// The loader resolves `@references` (the target) and never looks at `@fields`, so a +// typo there produces a foreign key over a column nothing declares. +// 2. Two `field.*` children with the same `name` in one object's `children` list. The +// later declaration is folded into the first, and one of a different subtype is +// dropped, both silently. +// +// WHY THESE ARE WARNINGS AND NOT LOAD ERRORS. docs/compatibility-policy.md does not allow +// a new load error for metadata that loads today, so every finding here is a warning by +// construction: nothing in this file reaches an exit code. +// +// THE TWO HALVES READ DIFFERENT THINGS, and have to. The reference half reads the LOADED +// model, because "does this object have that field" is a question about the EFFECTIVE +// field set — inherited through `extends` and merged from overlay files. The duplicate +// half reads the RAW DOCUMENTS, because the merge has already erased the duplicate from +// the model by the time anything can ask. +// +// Mirrors the TS reference (server/typescript/packages/cli/src/lib/field-lint.ts + +// packages/metadata/src/loader/declared-duplicate-fields.ts). The codes, the message +// text and the fixtures are shared: fixtures/field-lint-conformance/. + +using System.Text.Encodings.Web; +using System.Text.Json; +using MetaObjects.Loader; +using MetaObjects.Meta; +using YamlDotNet.RepresentationModel; +using static MetaObjects.Core.Identity.IdentityConstants; +using static MetaObjects.Shared.BaseTypes; +using static MetaObjects.Shared.Structural; + +namespace MetaObjects.Cli; + +/// The advisory field lint dotnet meta verify runs on every invocation. +public static class FieldLint +{ + /// An identity.reference lists a field its object does not have. + public const string WARN_REFERENCE_FIELD_NOT_FOUND = "WARN_REFERENCE_FIELD_NOT_FOUND"; + /// One children list declares the same field name more than once. + public const string WARN_DUPLICATE_FIELD_NAME = "WARN_DUPLICATE_FIELD_NAME"; + + /// Opt-out environment variable, beside --no-field-lint (Node meta parity). + public const string EnvOptOut = "META_NO_FIELD_LINT"; + + private const string KeyName = "name"; + private const string KeyPackage = "package"; + private const string KeyChildren = "children"; + private const char TypeSubTypeSeparator = '.'; + /// The YAML authoring sugar for isArray: true — a suffix on the wrapper key. + private const string ArraySuffix = "[]"; + + /// One advisory finding: its code, the node's address, and the message. + public sealed record Finding(string Code, string Path, string Message); + + // The JSON string form, so the text is byte-identical to the other ports'. The relaxed + // encoder keeps `"` as `\"` and leaves non-ASCII alone, as JSON.stringify does. + private static readonly JsonSerializerOptions QuoteOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + private static string Quote(string value) => JsonSerializer.Serialize(value, QuoteOptions); + + /// Report every identity.reference whose @fields names a field its object lacks. + public static List LintReferenceFields(MetaData root) + { + var findings = new List(); + // OWN-ONLY (ADR-0039 sanctioned case): a root has no super, and its own children + // are the declared objects. + foreach (var obj in root.OwnChildren()) + { + if (obj.Type != TYPE_OBJECT) continue; + var address = obj.ResolutionKey(); + // RESOLVING: the effective field set — a field inherited through `extends` or + // added by an overlay file is a field the object has. + var fields = obj.Children().Where(c => c.Type == TYPE_FIELD).Select(c => c.Name).ToHashSet(StringComparer.Ordinal); + // OWN-ONLY (ADR-0039 sanctioned case): report each DECLARATION once, on the + // object that declares it. The resolving Children() would repeat an inherited + // reference on every subtype. + foreach (var identity in obj.OwnChildren()) + { + if (identity.Type != TYPE_IDENTITY || identity.SubType != IDENTITY_SUBTYPE_REFERENCE) continue; + // RESOLVING: `@fields` may itself be inherited through the identity's `extends`. + foreach (var name in ListedFields(identity.Attr(IDENTITY_ATTR_FIELDS))) + { + if (fields.Contains(name)) continue; + findings.Add(new Finding( + WARN_REFERENCE_FIELD_NOT_FOUND, + $"{address}.{identity.Name}", + $"identity.reference {Quote(identity.Name)} lists {Quote(name)} in @fields, but " + + $"{address} 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.")); + } + } + } + return findings; + } + + private static IEnumerable ListedFields(object? listed) => listed switch + { + string single => [single], + IEnumerable many => many, + IEnumerable many => many.OfType(), + _ => [], + }; + + /// + /// Structurally scan one document's raw content and report every field name a root-level + /// object declares more than once in its own children list. + /// + /// + /// Scope is ONE children list: a field redeclared by an overlay file, or by a subtype + /// overriding an inherited field, is not in one list and is not a finding. Malformed + /// shapes return an empty list; a syntax error throws, as the parser's own would. + /// + public static List DeclaredDuplicateFields(string content, MetaDataFormat format) + { + var findings = new List(); + var normalized = content.Length > 0 && content[0] == '' ? content[1..] : content; + var parsed = format == MetaDataFormat.Yaml ? ParseYaml(normalized) : ParseJson(normalized); + if (parsed is not List> document) return findings; + + // JSON fuses the subtype onto the root key; sigil-free YAML may write the bare type. + var rootBody = Get(document, $"{TYPE_METADATA}{TypeSubTypeSeparator}{SUBTYPE_ROOT}") ?? Get(document, TYPE_METADATA); + if (rootBody is not List> root) return findings; + var rootPkg = Get(root, KeyPackage) as string ?? ""; + if (Get(root, KeyChildren) is not List children) return findings; + + foreach (var child in children) + { + if (child is not List> wrapper) continue; + foreach (var (wrapperKey, bodyValue) in wrapper) + { + if (WrapperType(wrapperKey) != TYPE_OBJECT) continue; + if (bodyValue is not List> body) continue; + var name = DeclaredName(body); + if (name is null || Get(body, KeyChildren) is not List members) continue; + + // Insertion-ordered, so findings come out in document order. + var order = new List(); + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var member in members) + { + if (member is not List> memberWrapper) continue; + foreach (var (memberKey, memberBody) in memberWrapper) + { + if (WrapperType(memberKey) != TYPE_FIELD) continue; + var fieldName = DeclaredName(memberBody); + if (fieldName is null) continue; + if (!counts.TryGetValue(fieldName, out var seen)) order.Add(fieldName); + counts[fieldName] = seen + 1; + } + } + + // The resolution key the parser gives a root-level node: its own `package` + // (a `::`-prefixed one is relative to the root's), else the root's. + var ownPkg = Get(body, KeyPackage) as string; + var pkg = string.IsNullOrEmpty(ownPkg) + ? rootPkg + : rootPkg.Trim() != "" && ownPkg.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal) + ? rootPkg + ownPkg + : ownPkg; + var address = pkg != "" ? $"{pkg}{PACKAGE_SEPARATOR}{name}" : name; + foreach (var fieldName in order) + { + var count = counts[fieldName]; + if (count < 2) continue; + findings.Add(new Finding( + WARN_DUPLICATE_FIELD_NAME, + $"{address}.{fieldName}", + $"{address} declares the field {Quote(fieldName)} {count} 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.")); + } + } + } + return findings; + } + + /// + /// Run over each metadata file. Unreadable or + /// unparsable files are skipped — the loader reports those itself. + /// + public static List LintDuplicateFields(IEnumerable files) + { + var findings = new List(); + foreach (var path in files) + { + try + { + var source = new FileSource(path); + findings.AddRange(DeclaredDuplicateFields(source.Read(), source.Format)); + } + catch (Exception) + { + // An advisory scan never breaks verify. + } + } + return findings; + } + + /// + /// Load the metadata verify was pointed at, lint it, and print the findings to + /// . Warnings ONLY: this never throws and never changes the + /// exit code. Loads LENIENT so the findings are the same with and without --lax; + /// a load failure is the gate's to report, so it stays silent here. + /// + public static void RunAdvisory(string metadataDir, IReadOnlyList? metadataFiles, IReadOnlyList? libraries, TextWriter stderr) + { + List findings; + try + { + var load = metadataFiles is { } resolved + ? MetaDataLoader.FromUris(resolved.Select(f => new Uri(f)).ToList(), libraries, false) + : MetaDataLoader.FromDirectory(metadataDir, libraries, strict: false); + if (load.Errors.Count > 0) return; + var files = metadataFiles ?? new DirectorySource(metadataDir).Expand().Select(f => f.FilePath).ToList(); + findings = [.. LintReferenceFields(load.Root), .. LintDuplicateFields(files)]; + } + catch (Exception) + { + return; + } + if (findings.Count == 0) return; + stderr.WriteLine($"dotnet meta verify — fields: {findings.Count} authoring warning(s) (advisory — does not fail the build):"); + foreach (var f in findings) stderr.WriteLine($" {f.Code} [{f.Path}]: {f.Message}"); + } + + // -- raw document model --------------------------------------------------- + // A mapping is an ORDERED list of pairs, a sequence a List, a scalar a string. + + private static object? Get(List> mapping, string key) + { + foreach (var (k, v) in mapping) + if (k == key) return v; + return null; + } + + /// The TYPE segment of a wrapper key: field.string[] and bare field are both field. + private static string WrapperType(string key) + { + var dot = key.IndexOf(TypeSubTypeSeparator); + var head = dot < 0 ? key : key[..dot]; + return head.EndsWith(ArraySuffix, StringComparison.Ordinal) ? head[..^ArraySuffix.Length] : head; + } + + /// A node's declared name: the body's name, or the body itself when YAML wrote a scalar. + private static string? DeclaredName(object? body) + { + var name = body is List> mapping ? Get(mapping, KeyName) : body; + return name is string s && s != "" ? s : null; + } + + private static object? ParseJson(string text) + { + using var doc = JsonDocument.Parse(text); + return FromJson(doc.RootElement); + } + + private static object? FromJson(JsonElement element) => element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject() + .Select(p => new KeyValuePair(p.Name, FromJson(p.Value))).ToList(), + JsonValueKind.Array => element.EnumerateArray().Select(FromJson).ToList(), + JsonValueKind.String => element.GetString(), + _ => null, + }; + + private static object? ParseYaml(string text) + { + var stream = new YamlStream(); + stream.Load(new StringReader(text)); + return stream.Documents.Count == 0 ? null : FromYaml(stream.Documents[0].RootNode); + } + + private static object? FromYaml(YamlNode node) => node switch + { + YamlMappingNode mapping => mapping.Children + .Where(p => p.Key is YamlScalarNode) + .Select(p => new KeyValuePair(((YamlScalarNode)p.Key).Value ?? "", FromYaml(p.Value))).ToList(), + YamlSequenceNode sequence => sequence.Children.Select(FromYaml).ToList(), + YamlScalarNode scalar => scalar.Value, + _ => null, + }; +} diff --git a/server/csharp/MetaObjects.Cli/Program.cs b/server/csharp/MetaObjects.Cli/Program.cs index 47e7c116a..eea1dd603 100644 --- a/server/csharp/MetaObjects.Cli/Program.cs +++ b/server/csharp/MetaObjects.Cli/Program.cs @@ -457,7 +457,7 @@ static int RunVerify(string[] rest) string? generatorsCsv = null; string? templateRoot = null; string? columnNamingRaw = null; - bool templates = false, codegen = false, db = false, lax = false; + bool templates = false, codegen = false, db = false, lax = false, noFieldLint = false; for (int i = 0; i < rest.Length; i++) { @@ -482,6 +482,9 @@ static int RunVerify(string[] rest) // --lax (#96 / ADR-0023): restore the legacy open-attr load. verify is // strict-by-default — an undeclared/typo'd own @attr is ERR_UNKNOWN_ATTR. else if (a == "--lax") lax = true; + // Mutes the advisory field AUTHORING lint (FieldLint) — never a gate, so this + // changes what is printed and nothing else. META_NO_FIELD_LINT=1 does the same. + else if (a == "--no-field-lint") noFieldLint = true; else if (a == "--out" && i + 1 < rest.Length) outDir = rest[++i]; else if (a == "--namespace" && i + 1 < rest.Length) { ns = rest[++i]; nsExplicit = true; } else if (a == "--generators" && i + 1 < rest.Length) generatorsCsv = rest[++i]; @@ -495,7 +498,7 @@ static int RunVerify(string[] rest) else if (a.StartsWith('-')) { Console.Error.WriteLine($"dotnet meta verify: unknown option \"{a}\""); - Console.Error.WriteLine("usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax]"); + Console.Error.WriteLine("usage: dotnet meta verify [--templates [--prompts ]] [--codegen --out [--namespace ] [--column-naming literal|snake_case|kebab-case]] [--db] [--lax] [--no-field-lint]"); return 2; } else if (metadataDir is null) metadataDir = a; @@ -636,6 +639,12 @@ static int RunVerify(string[] rest) if (result.DbRejectionMessage is not null) Console.Error.WriteLine($" {result.DbRejectionMessage}"); + // The field AUTHORING lint — a reference identity over a field the object lacks, a + // field name declared twice in one children list. Both load clean on every port. Runs + // on every `verify`, whichever gates were selected; warnings ONLY, never the exit code. + if (!noFieldLint && Environment.GetEnvironmentVariable(FieldLint.EnvOptOut) != "1") + FieldLint.RunAdvisory(resolvedMeta.Directory, resolvedMeta.Files, resolvedMeta.Libraries, Console.Error); + // The handed-off codegen gate already printed its own verdict (inherited console); // fold its exit code into the aggregate the same way every other subverb does — max, // non-zero on any drift. diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FieldLint.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FieldLint.java new file mode 100644 index 000000000..a669f9919 --- /dev/null +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/FieldLint.java @@ -0,0 +1,214 @@ +package com.metaobjects.mojo; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.metaobjects.field.MetaField; +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * {@code metaobjects:verify} — the field AUTHORING lint. + * + *

Two metadata mistakes about an object's FIELDS load with no error on every port:

+ *
    + *
  1. An {@code identity.reference} whose {@code @fields} names a field the object does + * not have. The loader resolves {@code @references} (the target) and never looks at + * {@code @fields}, so a typo there produces a foreign key over a column nothing + * declares.
  2. + *
  3. Two {@code field.*} children with the same {@code name} in one object's + * {@code children} list. The later declaration is folded into the first, and one of + * a different subtype is dropped, both silently.
  4. + *
+ * + *

Why these are warnings and not load errors. {@code docs/compatibility-policy.md} + * does not allow a new load error for metadata that loads today, so every finding here is a + * warning by construction: nothing in this class fails a build.

+ * + *

The two halves read different things, and have to. The reference half reads the + * LOADED model, because "does this object have that field" is a question about the EFFECTIVE + * field set — inherited through {@code extends} and merged from overlay files. The duplicate + * half reads the RAW DOCUMENTS, because the merge has already erased the duplicate from the + * model by the time anything can ask.

+ * + *

Mirrors the TS reference ({@code packages/cli/src/lib/field-lint.ts} + + * {@code packages/metadata/src/loader/declared-duplicate-fields.ts}). The codes, the message + * text and the fixtures are shared: {@code fixtures/field-lint-conformance/}. Kotlin runs + * through this same goal.

+ */ +public final class FieldLint { + + /** An {@code identity.reference} lists a field its object does not have. */ + public static final String WARN_REFERENCE_FIELD_NOT_FOUND = "WARN_REFERENCE_FIELD_NOT_FOUND"; + /** One {@code children} list declares the same field name more than once. */ + public static final String WARN_DUPLICATE_FIELD_NAME = "WARN_DUPLICATE_FIELD_NAME"; + + /** Opt-out environment variable, beside {@code -Dmeta.verify.noFieldLint} (Node {@code meta} parity). */ + public static final String ENV_OPT_OUT = "META_NO_FIELD_LINT"; + + private static final String KEY_NAME = "name"; + private static final String KEY_PACKAGE = "package"; + private static final String KEY_CHILDREN = "children"; + private static final String ROOT_KEY_BARE = "metadata"; + private static final String ROOT_KEY_FUSED = "metadata.root"; + private static final String TYPE_OBJECT = MetaObject.TYPE_OBJECT; + private static final String TYPE_FIELD = MetaField.TYPE_FIELD; + private static final String PACKAGE_SEPARATOR = "::"; + private static final char TYPE_SUBTYPE_SEPARATOR = '.'; + /** The YAML authoring sugar for {@code isArray: true} — a suffix on the wrapper key. */ + private static final String ARRAY_SUFFIX = "[]"; + + // The JSON string form, so the text is byte-identical to the other ports'. + private static final Gson QUOTER = new GsonBuilder().disableHtmlEscaping().create(); + + /** One advisory finding: its code, the node's address, and the message. */ + public record Finding(String code, String path, String message) {} + + private FieldLint() {} + + private static String quote(String value) { + return QUOTER.toJson(value); + } + + /** Report every {@code identity.reference} whose {@code @fields} names a field its object lacks. */ + public static List lintReferenceFields(MetaDataLoader loader) { + List findings = new ArrayList<>(); + // ADR-0039: own — the root's own children in declaration order (a root has no super). + for (MetaObject object : loader.getRoot().getChildren(MetaObject.class, false)) { + String address = object.getName(); + // RESOLVING: the effective field set — a field inherited through `extends` or + // added by an overlay file is a field the object has. + Set fields = new HashSet<>(); + for (MetaField field : object.getMetaFields()) fields.add(field.getName()); + // ADR-0039: own (sanctioned case) — report each DECLARATION once, on the object + // that declares it. The resolving read would repeat an inherited reference on + // every subtype. + for (MetaIdentity identity : object.getChildren(MetaIdentity.class, false)) { + if (!identity.isReference()) continue; + // RESOLVING: getFields() reads @fields through the identity's own `extends`. + for (String name : identity.getFields()) { + if (fields.contains(name)) continue; + findings.add(new Finding( + WARN_REFERENCE_FIELD_NOT_FOUND, + address + "." + identity.getName(), + "identity.reference " + quote(identity.getName()) + " lists " + quote(name) + + " in @fields, but " + address + " 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.")); + } + } + } + return findings; + } + + /** + * Structurally scan one document's raw content and report every field name a root-level + * object declares more than once in its own {@code children} list. + * + *

Scope is ONE children list: a field redeclared by an overlay file, or by a subtype + * overriding an inherited field, is not in one list and is not a finding. Malformed + * shapes return an empty list; a syntax error throws, as the parser's own would.

+ * + * @param yaml {@code true} for a YAML document, {@code false} for JSON + */ + public static List declaredDuplicateFields(String content, boolean yaml) { + List findings = new ArrayList<>(); + String normalized = !content.isEmpty() && content.charAt(0) == '' ? content.substring(1) : content; + Object parsed = yaml + ? new Yaml(new SafeConstructor(new LoaderOptions())).load(normalized) + : QUOTER.fromJson(normalized, Object.class); + if (!(parsed instanceof Map document)) return findings; + + // JSON fuses the subtype onto the root key; sigil-free YAML may write the bare type. + Object rootBody = document.containsKey(ROOT_KEY_FUSED) ? document.get(ROOT_KEY_FUSED) : document.get(ROOT_KEY_BARE); + if (!(rootBody instanceof Map root)) return findings; + String rootPkg = root.get(KEY_PACKAGE) instanceof String s ? s : ""; + if (!(root.get(KEY_CHILDREN) instanceof List children)) return findings; + + for (Object child : children) { + if (!(child instanceof Map wrapper)) continue; + for (Map.Entry entry : wrapper.entrySet()) { + if (!(entry.getKey() instanceof String wrapperKey) || !TYPE_OBJECT.equals(wrapperType(wrapperKey))) continue; + if (!(entry.getValue() instanceof Map body)) continue; + String name = declaredName(body); + if (name == null || !(body.get(KEY_CHILDREN) instanceof List members)) continue; + + // Insertion-ordered, so findings come out in document order. + Map counts = new LinkedHashMap<>(); + for (Object member : members) { + if (!(member instanceof Map memberWrapper)) continue; + for (Map.Entry memberEntry : memberWrapper.entrySet()) { + if (!(memberEntry.getKey() instanceof String memberKey) || !TYPE_FIELD.equals(wrapperType(memberKey))) continue; + String fieldName = declaredName(memberEntry.getValue()); + if (fieldName != null) counts.merge(fieldName, 1, Integer::sum); + } + } + + // The resolution key the parser gives a root-level node: its own `package` + // (a `::`-prefixed one is relative to the root's), else the root's. + String pkg = rootPkg; + if (body.get(KEY_PACKAGE) instanceof String ownPkg && !ownPkg.isEmpty()) { + boolean relative = !rootPkg.trim().isEmpty() && ownPkg.startsWith(PACKAGE_SEPARATOR); + pkg = relative ? rootPkg + ownPkg : ownPkg; + } + String address = pkg.isEmpty() ? name : pkg + PACKAGE_SEPARATOR + name; + for (Map.Entry count : counts.entrySet()) { + if (count.getValue() < 2) continue; + findings.add(new Finding( + WARN_DUPLICATE_FIELD_NAME, + address + "." + count.getKey(), + address + " declares the field " + quote(count.getKey()) + " " + count.getValue() + + " 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.")); + } + } + } + return findings; + } + + /** + * Run {@link #declaredDuplicateFields} over each metadata file. Unreadable or unparsable + * files are skipped — the loader reports those itself. + */ + public static List lintDuplicateFields(List files) { + List findings = new ArrayList<>(); + for (Path file : files) { + try { + String lower = file.getFileName().toString().toLowerCase(Locale.ROOT); + boolean yaml = lower.endsWith(".yaml") || lower.endsWith(".yml"); + findings.addAll(declaredDuplicateFields(Files.readString(file, StandardCharsets.UTF_8), yaml)); + } catch (Exception e) { + // An advisory scan never breaks verify. + } + } + return findings; + } + + /** The TYPE segment of a wrapper key: {@code field.string[]} and bare {@code field} are both {@code field}. */ + private static String wrapperType(String key) { + int dot = key.indexOf(TYPE_SUBTYPE_SEPARATOR); + String head = dot < 0 ? key : key.substring(0, dot); + return head.endsWith(ARRAY_SUFFIX) ? head.substring(0, head.length() - ARRAY_SUFFIX.length()) : head; + } + + /** A node's declared name: the body's {@code name}, or the body itself when YAML wrote a scalar. */ + private static String declaredName(Object body) { + Object name = body instanceof Map mapping ? mapping.get(KEY_NAME) : body; + return name instanceof String s && !s.isEmpty() ? s : null; + } +} diff --git a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java index f7f814999..795ea45ae 100644 --- a/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java +++ b/server/java/maven-plugin/src/main/java/com/metaobjects/mojo/MetaDataVerifyMojo.java @@ -105,6 +105,18 @@ public class MetaDataVerifyMojo extends AbstractMetaDataMojo { public void setTemplateRoot(String templateRoot) { this.templateRoot = templateRoot; } public String getTemplateRoot() { return templateRoot; } + /** + * Mute the advisory field AUTHORING lint ({@link FieldLint}) — a reference identity + * over a field the object lacks, and a field name declared twice in one children + * list. Never a gate: it only prints warnings, so this changes what is logged and + * nothing else. {@code META_NO_FIELD_LINT=1} does the same. + */ + @Parameter(property = "meta.verify.noFieldLint", defaultValue = "false") + private boolean noFieldLint = false; + + public void setNoFieldLint(boolean noFieldLint) { this.noFieldLint = noFieldLint; } + public boolean isNoFieldLint() { return noFieldLint; } + @Override public void execute() throws MojoExecutionException, MojoFailureException { // #233: warm the global registry singletons before verify builds its loader, @@ -133,6 +145,47 @@ public void execute() throws MojoExecutionException, MojoFailureException { verifyCodegen(); } + // ------------------------------------------------------------------------ + // the field authoring lint — advisory, every mode + // ------------------------------------------------------------------------ + + /** + * Runs on every {@code verify}, whichever mode was selected, as soon as the metadata + * has loaded — so its warnings are printed even when the gate then fails the build. + * Warnings ONLY: this never throws and never changes the build result. + */ + private void runFieldLintAdvisory(MetaDataLoader loader) { + if (noFieldLint || "1".equals(System.getenv(FieldLint.ENV_OPT_OUT))) return; + List findings = new ArrayList<>(); + try { + findings.addAll(FieldLint.lintReferenceFields(loader)); + findings.addAll(FieldLint.lintDuplicateFields(sourceFiles(loader))); + } catch (RuntimeException e) { + return; // an advisory scan never breaks verify + } + if (findings.isEmpty()) return; + getLog().warn("metaobjects:verify — fields: " + findings.size() + + " authoring warning(s) (advisory — does not fail the build):"); + for (FieldLint.Finding f : findings) { + getLog().warn(" " + f.code() + " [" + f.path() + "]: " + f.message()); + } + } + + /** The on-disk metadata files this loader read — the documents the duplicate scan reads raw. */ + private static List sourceFiles(MetaDataLoader loader) { + List files = new ArrayList<>(); + if (loader.getSourceURIs() == null) return files; + for (java.net.URI uri : loader.getSourceURIs()) { + com.metaobjects.loader.uri.URIModel model = com.metaobjects.loader.uri.URIHelper.toURIModel(uri); + if (!com.metaobjects.loader.uri.URIHelper.URI_SOURCE_FILE.equals(model.getUriSourceType())) continue; + // A relative carries its as a URI argument — resolve it the + // way URIHelper's own stream opener does, or the scan reads nothing. + String sourceDir = model.getUriArg(com.metaobjects.loader.uri.URIHelper.URI_ARG_SOURCEDIR); + files.add(sourceDir != null ? Paths.get(sourceDir, model.getUriSource()) : Paths.get(model.getUriSource())); + } + return files; + } + // ------------------------------------------------------------------------ // mode=templates — template/prompt {{field}}<->payload drift (ADR-0021 D2) // ------------------------------------------------------------------------ @@ -146,6 +199,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti ClassLoader projectClassLoader = createProjectClassLoader(); MetaDataLoader loader = createLoader(projectClassLoader); + runFieldLintAdvisory(loader); TemplateVerify.Outcome outcome = TemplateVerify.run(loader, Paths.get(templateRoot)); @@ -178,6 +232,7 @@ private void verifyTemplates() throws MojoExecutionException, MojoFailureExcepti private void verifyCodegen() throws MojoExecutionException, MojoFailureException { ClassLoader projectClassLoader = createProjectClassLoader(); MetaDataLoader loader = createLoader(projectClassLoader); + runFieldLintAdvisory(loader); // Per-generator: resolve its committed (real) outputDir from the merged args, then // stage an arg-override so the regenerate writes to a temp dir instead. Keep the diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java new file mode 100644 index 000000000..c0bc15f1e --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/FieldLintConformanceTest.java @@ -0,0 +1,93 @@ +package com.metaobjects.mojo; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Cross-port field-lint conformance corpus — {@code fixtures/field-lint-conformance/}. See + * that directory's README.md for the fixture format. Every case is LOADED first, so "this + * loads with no error today" is proven by the fixture, and only then linted. Mirrors the + * TypeScript reference ({@code field-lint-conformance.test.ts}) and the C#/Python ports + * exactly; a mismatch here is a bug in THIS port's lint, never in the fixture. + */ +@RunWith(Parameterized.class) +public class FieldLintConformanceTest { + + private static final Path CORPUS = locateCorpus(); + + private static Path locateCorpus() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve("fixtures/field-lint-conformance"); + if (Files.isDirectory(candidate)) return candidate; + dir = dir.getParent(); + } + throw new AssertionError("Could not locate fixtures/field-lint-conformance/ by walking up from " + + Paths.get("").toAbsolutePath()); + } + + @Parameterized.Parameters(name = "{0}") + public static Collection fixtures() throws IOException { + List params = new ArrayList<>(); + try (Stream dirs = Files.list(CORPUS)) { + List sorted = dirs.filter(Files::isDirectory).sorted(Comparator.comparing(Path::toString)).toList(); + for (Path d : sorted) params.add(new Object[]{d.getFileName().toString(), d}); + } + return params; + } + + private final String name; + private final Path dir; + + public FieldLintConformanceTest(String name, Path dir) { + this.name = name; + this.dir = dir; + } + + @Test + public void fixture() throws IOException { + Path input = dir.resolve("input"); + MetaDataLoader loader = MetaDataLoader.fromDirectory("field-lint-" + name, input); + assertTrue(name + ": expected a clean load, got: " + loader.getErrors(), loader.getErrors().isEmpty()); + + List files; + try (Stream listed = Files.list(input)) { + files = listed.sorted(Comparator.comparing(Path::toString)).toList(); + } + List actual = new ArrayList<>(); + for (FieldLint.Finding f : FieldLint.lintReferenceFields(loader)) actual.add(row(f.code(), f.path(), f.message())); + for (FieldLint.Finding f : FieldLint.lintDuplicateFields(files)) actual.add(row(f.code(), f.path(), f.message())); + + List expected = new ArrayList<>(); + JsonObject doc = JsonParser.parseString(Files.readString(dir.resolve("expected.json"), StandardCharsets.UTF_8)).getAsJsonObject(); + for (JsonElement e : doc.getAsJsonArray("findings")) { + JsonObject f = e.getAsJsonObject(); + expected.add(row(f.get("code").getAsString(), f.get("path").getAsString(), f.get("message").getAsString())); + } + + assertEquals(name, expected.stream().sorted().toList(), actual.stream().sorted().toList()); + } + + private static String row(String code, String path, String message) { + return code + " [" + path + "]: " + message; + } +} diff --git a/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyFieldLintTest.java b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyFieldLintTest.java new file mode 100644 index 000000000..387abcc16 --- /dev/null +++ b/server/java/maven-plugin/src/test/java/com/metaobjects/mojo/MetaDataVerifyFieldLintTest.java @@ -0,0 +1,93 @@ +package com.metaobjects.mojo; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * {@code metaobjects:verify} — the field authoring lint, end to end: logged as warnings in + * its own section, never failing the build, and muted by {@code meta.verify.noFieldLint}. + * The finding text is gated cross-port by {@link FieldLintConformanceTest}; this proves + * the goal runs the lint and that a finding does not throw. + */ +public class MetaDataVerifyFieldLintTest { + + /** Captures the lint's {@code warn} lines so the test can read what the goal logged. */ + private static final class CapturingLog extends SystemStreamLog { + final List warnings = new ArrayList<>(); + + @Override + public void warn(CharSequence content) { + // Only the lint's own lines: the goal also warns about things a bare, + // unconfigured mojo lacks (a MojoExecution phase), which are not under test. + String line = content.toString(); + if (line.contains("— fields:") || line.startsWith(" WARN_")) warnings.add(line); + } + } + + private static Path corpusCase(String name) { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null) { + Path candidate = dir.resolve("fixtures/field-lint-conformance").resolve(name).resolve("input"); + if (Files.isDirectory(candidate)) return candidate; + dir = dir.getParent(); + } + throw new AssertionError("Could not locate fixtures/field-lint-conformance/" + name); + } + + private static CapturingLog verify(String corpusCase, boolean noFieldLint) throws Exception { + MetaDataVerifyMojo mojo = new MetaDataVerifyMojo(); + LoaderParam loader = LoaderParam.builder("verify-field-lint-test") + .withClassname("com.metaobjects.loader.MetaDataLoader") + .withSourceDir(corpusCase(corpusCase).toString()) + .withSource("meta.app.json") + .build(); + mojo.setLoader(loader); + mojo.setGenerators(Collections.emptyList()); + mojo.setGlobals(Collections.emptyMap()); + // The templates gate over a model with no templates is clean, so anything that + // throws here would be the lint failing the build. + mojo.setMode("templates"); + mojo.setTemplateRoot(Files.createTempDirectory("verify-field-lint").toString()); + mojo.setNoFieldLint(noFieldLint); + CapturingLog log = new CapturingLog(); + mojo.setLog(log); + mojo.execute(); + return log; + } + + @Test + public void aMissingReferenceFieldIsAWarningAndTheBuildPasses() throws Exception { + CapturingLog log = verify("reference-field-missing", false); + assertEquals("metaobjects:verify — fields: 1 authoring warning(s) (advisory — does not fail the build):", + log.warnings.get(0)); + assertTrue(log.warnings.get(1), log.warnings.get(1).startsWith( + " WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::Item.owner_fk]: ")); + } + + @Test + public void aDuplicateFieldIsReportedFromTheRawDocument() throws Exception { + CapturingLog log = verify("duplicate-field-same-subtype", false); + assertTrue(log.warnings.toString(), log.warnings.stream().anyMatch( + w -> w.startsWith(" WARN_DUPLICATE_FIELD_NAME [acme::app::Item.label]: "))); + } + + @Test + public void cleanMetadataLogsNoSection() throws Exception { + assertTrue(verify("clean", false).warnings.isEmpty()); + } + + @Test + public void noFieldLintSilencesIt() throws Exception { + assertTrue(verify("reference-field-missing", true).warnings.isEmpty()); + } +} diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index e5cbe39ef..d3403307e 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -65,6 +65,7 @@ refuse_unowned_packages, ) from metaobjects.config.neutral_config import read_neutral_config +from metaobjects.field_lint import FieldLintFinding, lint_duplicate_fields, lint_reference_fields from metaobjects.loader.meta_data_loader import LoadResult from metaobjects.loader.sources import FileSource from metaobjects.meta.core.object.meta_object import MetaObject @@ -2294,6 +2295,69 @@ def _verify_db(_args: argparse.Namespace) -> int: return 2 +#: Opt-out for the advisory field lint, beside ``--no-field-lint`` (Node `meta` parity). +FIELD_LINT_ENV = "META_NO_FIELD_LINT" + + +def _field_lint_findings(args: argparse.Namespace) -> "list[FieldLintFinding] | None": + """Load the metadata ``verify`` was pointed at and run the field lint over it. + + Returns ``None`` when there is nothing to lint: the metadata could not be located + or did not load. Every such failure is already reported by the gate that ran, with + its own message and exit code, so this stays silent. + + Loads LENIENT, deliberately: the lint must report the same findings under + ``--lax`` as without it, and an unknown attr is the strict gate's finding, not + this one's. + """ + from metaobjects.loader.sources import DirectorySource + + providers, _errors = _resolve_providers(getattr(args, "provider", None)) + if args.metadata_dir is not None: + root, _ = _load_root(args.metadata_dir, providers=providers) + files = [source.path for source in DirectorySource(args.metadata_dir).expand()] + else: + config_path = _find_config(args) + config = load_project_config(config_path) if config_path is not None else None + libraries: "list[str] | None" = None + if config is not None: + # Quiet on purpose: a bad provider is the gate's error to print, once. + providers, _errors = _resolve_providers(config.providers) + libraries = config.libraries + start = project_root_for(config.metadata_dir()) + else: + start = Path.cwd() + collection = resolve_metadata_location(config=config, root=start) + root, _ = _load_root_from_collection(collection, providers=providers, libraries=libraries) + # The project's OWN files — a dependency artifact is not the adopter's to edit. + files = list(collection.own_files) + if root is None: + return None + return [*lint_reference_fields(root), *lint_duplicate_fields(files)] + + +def _run_field_lint_advisory(args: argparse.Namespace) -> None: + """The field AUTHORING lint (see :mod:`metaobjects.field_lint`) — runs on every + ``verify``, whichever gates were selected. Warnings ONLY: it prints to stderr and + never changes the exit code. Muted by ``--no-field-lint`` or ``META_NO_FIELD_LINT=1``. + """ + if getattr(args, "no_field_lint", False) or os.environ.get(FIELD_LINT_ENV) == "1": + return + try: + findings = _field_lint_findings(args) + except Exception: # noqa: BLE001 — an advisory scan never breaks verify + return + if not findings: + return + print( + f"metaobjects verify — fields: {len(findings)} authoring warning(s) " + "(advisory — does not fail the build):", + file=sys.stderr, + ) + for finding in findings: + print(f" {finding.code} [{finding.path}]: {finding.message}", file=sys.stderr) + + def _cmd_verify(args: argparse.Namespace) -> int: """Subverb dispatch (ADR-0021 D2). Run each requested mode; aggregate exit = max (non-zero if ANY mode drifts). Bare ``verify`` (no subverb) keeps the @@ -2322,6 +2386,7 @@ def _cmd_verify(args: argparse.Namespace) -> int: exit_code = max(exit_code, _verify_codegen(args)) if run_templates: exit_code = max(exit_code, _verify_templates(args)) + _run_field_lint_advisory(args) return exit_code @@ -2579,6 +2644,15 @@ def _build_parser() -> argparse.ArgumentParser: default=None, help="comma-separated entity allowlist for --codegen drift (match `gen --entities`)", ) + verify.add_argument( + "--no-field-lint", + action="store_true", + help=( + "suppress the advisory field AUTHORING lint (a reference identity over a " + "missing field; a duplicate field name) — never a gate, it cannot fail the " + "build. META_NO_FIELD_LINT=1 does the same." + ), + ) verify.add_argument( "--lax", action="store_true", diff --git a/server/python/src/metaobjects/field_lint.py b/server/python/src/metaobjects/field_lint.py new file mode 100644 index 000000000..6e26dc5c3 --- /dev/null +++ b/server/python/src/metaobjects/field_lint.py @@ -0,0 +1,214 @@ +"""``metaobjects verify`` — the field AUTHORING lint. + +Two metadata mistakes about an object's FIELDS load with no error on every port: + +1. An ``identity.reference`` whose ``@fields`` names a field the object does not + have. The loader resolves ``@references`` (the target) and never looks at + ``@fields``, so a typo there produces a foreign key over a column nothing declares. +2. Two ``field.*`` children with the same ``name`` in one object's ``children`` list. + +WHY THESE ARE WARNINGS AND NOT LOAD ERRORS. ``docs/compatibility-policy.md`` does not +allow a new load error for metadata that loads today, so every finding here is a +warning by construction: nothing in this module reaches an exit code. + +THE TWO HALVES READ DIFFERENT THINGS, and have to. The reference half reads the LOADED +model, because "does this object have that field" is a question about the EFFECTIVE +field set — inherited through ``extends`` and merged from overlay files. The duplicate +half reads the RAW DOCUMENTS: the TypeScript, C# and Java loaders fold a repeated +field into the first declaration, so their model keeps no trace of it, and one scan of +the document gives every port the same answer. + +Mirrors the TS reference (``packages/cli/src/lib/field-lint.ts`` + +``packages/metadata/src/loader/declared-duplicate-fields.ts``). The codes, the message +text and the fixtures are shared: ``fixtures/field-lint-conformance/``. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from metaobjects.loader.sources import FileSource +from metaobjects.meta.core.identity.identity_constants import ( + IDENTITY_ATTR_FIELDS, + IDENTITY_SUBTYPE_REFERENCE, +) +from metaobjects.meta.meta_data import MetaData +from metaobjects.shared.base_types import ( + SUBTYPE_ROOT, + TYPE_FIELD, + TYPE_IDENTITY, + TYPE_METADATA, + TYPE_OBJECT, +) +from metaobjects.shared.separators import PACKAGE_SEP +from metaobjects.source.yaml_positions import parse_yaml_with_positions + +#: An ``identity.reference`` lists a field its object does not have. +WARN_REFERENCE_FIELD_NOT_FOUND = "WARN_REFERENCE_FIELD_NOT_FOUND" +#: One ``children`` list declares the same field name more than once. +WARN_DUPLICATE_FIELD_NAME = "WARN_DUPLICATE_FIELD_NAME" + +_KEY_NAME = "name" +_KEY_PACKAGE = "package" +_KEY_CHILDREN = "children" +_TYPE_SUBTYPE_SEP = "." +#: The YAML authoring sugar for ``isArray: true`` — a suffix on the wrapper key. +_ARRAY_SUFFIX = "[]" + + +@dataclass(frozen=True) +class FieldLintFinding: + """One advisory finding: its code, the node's address, and the message.""" + + code: str + path: str + message: str + + +def _quote(value: str) -> str: + # The JSON string form, so the text is byte-identical to the other ports'. + return json.dumps(value, ensure_ascii=False) + + +def lint_reference_fields(root: MetaData) -> list[FieldLintFinding]: + """Report every ``identity.reference`` whose ``@fields`` names a field its object lacks.""" + out: list[FieldLintFinding] = [] + # OWN-ONLY (ADR-0039 sanctioned case): a root has no super, and its own children + # are the declared objects. + for obj in root.own_children(): + if obj.type != TYPE_OBJECT: + continue + 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} + # OWN-ONLY (ADR-0039 sanctioned case): report each DECLARATION once, on the + # object that declares it. The resolving ``children()`` would repeat an + # inherited reference on every subtype. + for identity in obj.own_children(): + if identity.type != TYPE_IDENTITY or identity.sub_type != IDENTITY_SUBTYPE_REFERENCE: + continue + # RESOLVING: ``@fields`` may itself be inherited through the identity's + # ``extends`` (``attrs()`` resolves; ``attr()`` is own-only in this port). + listed = identity.attrs().get(IDENTITY_ATTR_FIELDS) + names = listed if isinstance(listed, list) else [listed] if isinstance(listed, str) else [] + for name in names: + if not isinstance(name, str) or name in fields: + continue + out.append( + FieldLintFinding( + WARN_REFERENCE_FIELD_NOT_FOUND, + f"{address}.{identity.name}", + f"identity.reference {_quote(identity.name)} lists {_quote(name)} in @fields, " + f"but {address} 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.", + ) + ) + return out + + +def _wrapper_type(key: str) -> str: + """The TYPE segment of a wrapper key: ``field.string[]`` and bare ``field`` are both ``field``.""" + head = key.split(_TYPE_SUBTYPE_SEP, 1)[0] + return head[: -len(_ARRAY_SUFFIX)] if head.endswith(_ARRAY_SUFFIX) else head + + +def _declared_name(body: object) -> str | None: + """A node's declared name: the body's ``name``, or the body itself when YAML wrote a scalar.""" + name = body.get(_KEY_NAME) if isinstance(body, dict) else body + return name if isinstance(name, str) and name != "" else None + + +def declared_duplicate_fields(content: str, format: str) -> list[FieldLintFinding]: + """Structurally scan one document's raw content and report every field name a + root-level object declares more than once in its own ``children`` list. + + Scope is ONE children list: a field redeclared by an overlay file, or by a subtype + overriding an inherited field, is not in one list and is not a finding. Malformed + shapes return ``[]``; a syntax error raises, as the parser's own would. + """ + normalized = content[1:] if content.startswith("") else content + if format == "json": + parsed = json.loads(normalized) + elif format == "yaml": + parsed = parse_yaml_with_positions(normalized) + else: + return [] + if not isinstance(parsed, dict): + return [] + # JSON fuses the subtype onto the root key; sigil-free YAML may write the bare type. + root_body = parsed.get(f"{TYPE_METADATA}{_TYPE_SUBTYPE_SEP}{SUBTYPE_ROOT}", parsed.get(TYPE_METADATA)) + if not isinstance(root_body, dict): + return [] + raw_root_pkg = root_body.get(_KEY_PACKAGE) + root_pkg = raw_root_pkg if isinstance(raw_root_pkg, str) else "" + children = root_body.get(_KEY_CHILDREN) + if not isinstance(children, list): + return [] + + out: list[FieldLintFinding] = [] + for child in children: + if not isinstance(child, dict): + continue + for wrapper_key, body in child.items(): + if not isinstance(wrapper_key, str) or _wrapper_type(wrapper_key) != TYPE_OBJECT: + continue + if not isinstance(body, dict): + continue + name = _declared_name(body) + members = body.get(_KEY_CHILDREN) + if name is None or not isinstance(members, list): + continue + + counts: dict[str, int] = {} + for member in members: + if not isinstance(member, dict): + continue + for member_key, member_body in member.items(): + if not isinstance(member_key, str) or _wrapper_type(member_key) != TYPE_FIELD: + continue + field_name = _declared_name(member_body) + if field_name is not None: + counts[field_name] = counts.get(field_name, 0) + 1 + + # The resolution key the parser gives a root-level node: its own ``package`` + # (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 + else: + pkg = root_pkg + address = f"{pkg}{PACKAGE_SEP}{name}" if pkg != "" else name + for field_name, count in counts.items(): + if count > 1: + out.append( + FieldLintFinding( + WARN_DUPLICATE_FIELD_NAME, + f"{address}.{field_name}", + f"{address} declares the field {_quote(field_name)} {count} 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.", + ) + ) + return out + + +def lint_duplicate_fields(files: Iterable[Path | str]) -> list[FieldLintFinding]: + """Run :func:`declared_duplicate_fields` over each metadata file. + + Unreadable or unparsable files are skipped — the loader reports those itself. + """ + out: list[FieldLintFinding] = [] + for path in files: + try: + source = FileSource(path) + out.extend(declared_duplicate_fields(source.read(), source.format)) + except Exception: # noqa: BLE001 — an advisory scan never breaks verify + continue + return out diff --git a/server/python/tests/codegen/test_cli_verify_field_lint.py b/server/python/tests/codegen/test_cli_verify_field_lint.py new file mode 100644 index 000000000..57859faf0 --- /dev/null +++ b/server/python/tests/codegen/test_cli_verify_field_lint.py @@ -0,0 +1,100 @@ +"""``metaobjects verify`` — the field authoring lint, end to end. + +It prints its own advisory section on stderr, never reaches the exit code, and is +muted by ``--no-field-lint`` / ``META_NO_FIELD_LINT=1``. The finding text itself is +gated cross-port by ``tests/conformance/test_field_lint_conformance.py``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.cli import main +from tests.codegen.gen_suite import GEN_SUITE + + +def _meta_dir(tmp_path: Path, reference_field: str, duplicate_label: bool) -> str: + label = {"field.string": {"name": "label"}} + pk = {"identity.primary": {"name": "pk", "@fields": ["id"]}} + doc = { + "metadata.root": { + "package": "app", + "children": [ + {"object.entity": {"name": "Owner", "children": [{"field.long": {"name": "id"}}, pk]}}, + { + "object.entity": { + "name": "Item", + "children": [ + {"field.long": {"name": "id"}}, + {"field.long": {"name": "ownerId"}}, + label, + *([label] if duplicate_label else []), + pk, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [reference_field], + "@references": "Owner", + } + }, + ], + } + }, + ], + } + } + d = tmp_path / "meta" + d.mkdir() + (d / "meta.app.json").write_text(json.dumps(doc)) + return str(d) + + +def _verify(tmp_path: Path, meta_dir: str, *extra: str) -> int: + out = tmp_path / "out" + assert main(["gen", "--generators", GEN_SUITE, meta_dir, "--out", str(out)]) == 0 + return main(["verify", "--codegen", "--generators", GEN_SUITE, meta_dir, "--out", str(out), *extra]) + + +def test_findings_are_advisory_and_do_not_change_the_exit_code( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + meta_dir = _meta_dir(tmp_path, "ownerIdd", duplicate_label=False) + capsys.readouterr() + assert _verify(tmp_path, meta_dir) == 0 + err = capsys.readouterr().err + assert "metaobjects verify — fields: 1 authoring warning(s) (advisory — does not fail the build):" in err + assert "WARN_REFERENCE_FIELD_NOT_FOUND [app::Item.owner_fk]" in err + + +def test_duplicate_field_is_reported_from_the_raw_document( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + meta_dir = _meta_dir(tmp_path, "ownerId", duplicate_label=True) + capsys.readouterr() + assert _verify(tmp_path, meta_dir) == 0 + err = capsys.readouterr().err + assert "WARN_DUPLICATE_FIELD_NAME [app::Item.label]" in err + + +def test_clean_metadata_prints_no_section(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + meta_dir = _meta_dir(tmp_path, "ownerId", duplicate_label=False) + assert _verify(tmp_path, meta_dir) == 0 + assert "fields:" not in capsys.readouterr().err + + +def test_no_field_lint_flag_silences_it(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + meta_dir = _meta_dir(tmp_path, "ownerIdd", duplicate_label=False) + assert _verify(tmp_path, meta_dir, "--no-field-lint") == 0 + assert "WARN_REFERENCE_FIELD_NOT_FOUND" not in capsys.readouterr().err + + +def test_env_var_silences_it( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("META_NO_FIELD_LINT", "1") + meta_dir = _meta_dir(tmp_path, "ownerIdd", duplicate_label=False) + assert _verify(tmp_path, meta_dir) == 0 + assert "WARN_REFERENCE_FIELD_NOT_FOUND" not in capsys.readouterr().err diff --git a/server/python/tests/conformance/test_field_lint_conformance.py b/server/python/tests/conformance/test_field_lint_conformance.py new file mode 100644 index 000000000..964a3b805 --- /dev/null +++ b/server/python/tests/conformance/test_field_lint_conformance.py @@ -0,0 +1,49 @@ +"""Cross-port field-lint conformance corpus — fixtures/field-lint-conformance/. + +See that directory's README.md for the fixture format. Every case is LOADED strict +first, so "this loads with no error today" is proven by the fixture, and only then +linted. Mirrors the TS reference (field-lint-conformance.test.ts) and the C#/Java +ports exactly; a mismatch here is a bug in THIS port's lint, never in the fixture. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects import MetaDataLoader +from metaobjects.field_lint import lint_duplicate_fields, lint_reference_fields + + +def _corpus_root() -> Path: + here = Path(__file__).resolve() + for parent in [here, *here.parents]: + candidate = parent / "fixtures" / "field-lint-conformance" + if candidate.is_dir(): + return candidate + raise RuntimeError("could not locate fixtures/field-lint-conformance from " + str(here)) + + +CORPUS = _corpus_root() +FIXTURES = sorted(p.name for p in CORPUS.iterdir() if p.is_dir()) + + +def test_found_fixtures() -> None: + assert FIXTURES, "fixtures/field-lint-conformance/ discovered no fixture directories" + + +@pytest.mark.parametrize("name", FIXTURES) +def test_fixture(name: str) -> None: + input_dir = CORPUS / name / "input" + expected = json.loads((CORPUS / name / "expected.json").read_text(encoding="utf-8")) + + result = MetaDataLoader.from_directory(str(input_dir), strict=True) + assert [str(e) for e in result.errors] == [] + + findings = [ + *lint_reference_fields(result.root), + *lint_duplicate_fields(sorted(input_dir.iterdir())), + ] + actual = sorted((f.code, f.path, f.message) for f in findings) + assert actual == sorted((f["code"], f["path"], f["message"]) for f in expected["findings"]) diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index b065e3705..c3c2cdee7 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -41,6 +41,7 @@ import { lintRequirements } from "../lib/requirement-lint.js"; import { lintOverlays } from "../lib/overlay-lint.js"; import { lintNodeNames } from "../lib/name-lint.js"; import { lintDeprecatedReferences } from "../lib/deprecation-lint.js"; +import { lintDuplicateFields, lintReferenceFields } from "../lib/field-lint.js"; import { FileSource } from "@metaobjectsdev/metadata/core"; import { resolveD1Config, resolveMigrateConfig } from "../lib/config.js"; import { @@ -360,6 +361,8 @@ export async function verifyCommand( // #305 — the deprecated-reference authoring lint. Its own section, same reason. let deprecationSection: AdvisorySection = skippedSection("the deprecated-reference lint did not run"); + let fieldSection: AdvisorySection = + skippedSection("the field lint did not run"); // The ledger counts `meta verify` prints on every run. Undefined for a project // declaring no requirement.* node at all (opt-in by declaration) — the payload // then omits the block rather than reporting zeroes that would read as an empty @@ -405,6 +408,11 @@ export async function verifyCommand( // warnings ONLY, never changes the exit code. runDeprecationLintAdvisory(); + // A reference identity over a field the object lacks, and a field name declared + // twice in one children list — both load clean on every port. Runs on every + // `meta verify`; warnings ONLY, never changes the exit code. + await runFieldLintAdvisory(); + // Advisory verify-as-teacher pass: surface hand-rolled work the metadata could // model. Warnings ONLY — never changes the exit code (bias to under-flagging). // Suppressed with --no-antipatterns or META_NO_ANTIPATTERNS=1 for the rare @@ -455,6 +463,7 @@ export async function verifyCommand( overlays: overlaySection, names: nameSection, deprecations: deprecationSection, + fields: fieldSection, }), fmt, ); @@ -852,6 +861,41 @@ export async function verifyCommand( } } + // -- field authoring lint (advisory) ---------------------------------------- + // Reads the LOADED model for the reference half and the project's OWN raw files + // for the duplicate half (see field-lint.ts) — a dependency artifact is not the + // adopter's to edit. Its own section, its own cap, and a skip that carries its + // reason rather than looking like a clean scan. + async function runFieldLintAdvisory(): Promise { + if (flags.noFieldLint) { + fieldSection = skippedSection("suppressed by --no-field-lint"); + return; + } + if (process.env.META_NO_FIELD_LINT === "1") { + fieldSection = skippedSection("suppressed by META_NO_FIELD_LINT=1"); + return; + } + let findings: Diagnostic[]; + try { + findings = [ + ...lintReferenceFields(root), + ...(await lintDuplicateFields(collection.ownFiles, (path) => new FileSource(path))), + ]; + } catch (err) { + // Never let an advisory scan break verify — and never report it as clean. + fieldSection = skippedSection(`the field lint failed: ${describeError(err)}`); + return; + } + fieldSection = ranSection(findings.map((d) => toDiagnosticRow(d, "lint"))); + if (findings.length > 0) { + log.warn( + `meta verify — fields: ${findings.length} authoring warning(s) ` + + `(advisory — does not fail the build):`, + ); + warnCapped(findings.map(formatDiagnostic), flags.limit, { structured }); + } + } + // -- deprecated-reference authoring lint (advisory) ------------------------- // #305 — reads the LOADED model (see deprecation-lint.ts). Its own section, // its own cap, and a skip that carries its reason rather than looking clean. @@ -1890,6 +1934,7 @@ function buildVerifyPayload(input: { overlays: AdvisorySection; names: AdvisorySection; deprecations: AdvisorySection; + fields: AdvisorySection; }): Record { const ran = input.gates.filter((g) => g.ran); const failed = ran.filter((g) => !g.ok); @@ -1915,6 +1960,9 @@ function buildVerifyPayload(input: { if (input.deprecations.status === "ran" && input.deprecations.total > 0) { parts.push(`${input.deprecations.total} deprecated-reference finding(s)`); } + if (input.fields.status === "ran" && input.fields.total > 0) { + parts.push(`${input.fields.total} field authoring finding(s)`); + } const help: string[] = []; if (failed.length > 0) { @@ -1940,13 +1988,19 @@ function buildVerifyPayload(input: { `${input.deprecations.total} reference(s) to a deprecated node — see deprecations.rows[]; each names the deprecated target and, where declared, its replacement`, ); } + if (input.fields.total > 0) { + help.push( + `${input.fields.total} field authoring finding(s) — see fields.rows[]; each names a reference identity listing a field its object lacks, or a field name declared twice in one children list`, + ); + } if ( failed.length === 0 && input.antiPatterns.total === 0 && input.requirements.total === 0 && input.overlays.total === 0 && input.names.total === 0 && - input.deprecations.total === 0 + input.deprecations.total === 0 && + input.fields.total === 0 ) { help.push("no drift and nothing advisory to answer — nothing to do"); } @@ -1961,6 +2015,7 @@ function buildVerifyPayload(input: { overlays: input.overlays, names: input.names, deprecations: input.deprecations, + fields: input.fields, ...(input.requirementCounts !== undefined ? { requirementCounts: input.requirementCounts } : {}), // The honest boundary. Everything named here is REACHABLE — it is printed as // text on stderr — but it is not in this document, and a reader must not have diff --git a/server/typescript/packages/cli/src/index.ts b/server/typescript/packages/cli/src/index.ts index 0d09a562e..7bc7f6517 100644 --- a/server/typescript/packages/cli/src/index.ts +++ b/server/typescript/packages/cli/src/index.ts @@ -147,6 +147,9 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif in a name; never a gate — this lint can't fail the build) --no-deprecation-lint Suppress the advisory deprecated-reference AUTHORING lint (never a gate — this lint can't fail the build) + --no-field-lint Suppress the advisory field AUTHORING lint (a reference + identity over a missing field; a duplicate field name — + never a gate; this lint can't fail the build) --limit How many advisory lines TEXT output prints per section before it truncates (default 20). Never applies to --format toon/json, which carry every finding and every diagnostic. @@ -344,6 +347,9 @@ FLAGS: a name) — never a gate; this lint can't fail the build --no-deprecation-lint Suppress the advisory deprecated-reference AUTHORING lint — never a gate; this lint can't fail the build + --no-field-lint Suppress the advisory field AUTHORING lint (a reference + identity over a missing field; a duplicate field name) — + never a gate; this lint can't fail the build --limit Advisory lines TEXT output prints PER SECTION before truncating (default 20; per-section so the authoring lint can never push the gate's own warnings off the end) @@ -389,6 +395,12 @@ the deprecated node also carries @replacedBy, the finding names the replacement. A node referencing itself (a recursive FK, a same-entity passthrough) is not a finding. Warnings only — it can never fail the build. Opt out with --no-deprecation-lint or META_NO_DEPRECATION_LINT=1. + +verify also prints an ADVISORY field authoring lint, in its own section. It reports +an identity.reference whose @fields names a field the object does not have +(inherited and overlaid fields count as present), and a field name declared more +than once in one object's children list. Both load with no error. Warnings only — +it can never fail the build. Opt out with --no-field-lint or META_NO_FIELD_LINT=1. `, export: `meta export — flatten loaded metadata to one canonical JSON artifact diff --git a/server/typescript/packages/cli/src/lib/args.ts b/server/typescript/packages/cli/src/lib/args.ts index a5f3e3dab..5b33f547c 100644 --- a/server/typescript/packages/cli/src/lib/args.ts +++ b/server/typescript/packages/cli/src/lib/args.ts @@ -405,6 +405,13 @@ export interface VerifyFlags { * and can never fail the build. */ noDeprecationLint: boolean; + /** + * Suppress the advisory FIELD authoring lint — a reference identity whose + * `@fields` names a field the object lacks, and a field name declared twice in + * one children list. Advisory only, like its siblings: it has no gate half and + * can never fail the build. + */ + noFieldLint: boolean; /** * ADR-0023 strict-attr load opt-OUT (#96). `verify` is strict-by-default — an * undeclared/typo'd own `@attr` fails verify (ERR_UNKNOWN_ATTR). `--lax` @@ -448,6 +455,7 @@ export const VERIFY_OPTIONS = { "no-overlay-lint": { type: "boolean", default: false }, "no-name-lint": { type: "boolean", default: false }, "no-deprecation-lint": { type: "boolean", default: false }, + "no-field-lint": { type: "boolean", default: false }, lax: { type: "boolean", default: false }, "d1": { type: "string" }, "remote": { type: "boolean", default: false }, @@ -534,6 +542,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags { noOverlayLint: !!values["no-overlay-lint"], noNameLint: !!values["no-name-lint"], noDeprecationLint: !!values["no-deprecation-lint"], + noFieldLint: !!values["no-field-lint"], lax: !!values.lax, d1: values.d1 as string | undefined, remote: !!values.remote, diff --git a/server/typescript/packages/cli/src/lib/field-lint.ts b/server/typescript/packages/cli/src/lib/field-lint.ts new file mode 100644 index 000000000..f19644c44 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/field-lint.ts @@ -0,0 +1,122 @@ +// `meta verify` — the field AUTHORING lint. +// +// Two metadata mistakes about an object's FIELDS load with no error on every port: +// +// 1. An `identity.reference` whose `@fields` names a field the object does not have. +// The loader resolves `@references` (the target) and never looks at `@fields`, so a +// typo there produces a foreign key over a column nothing declares. +// 2. Two `field.*` children with the same `name` in one object's `children` list. The +// later declaration is folded into the first, and one of a different subtype is +// dropped, both silently. +// +// WHY THESE ARE WARNINGS AND NOT LOAD ERRORS. docs/compatibility-policy.md does not allow +// a new load error for metadata that loads today — name-lint.ts's header has the same +// ruling for whitespace names. So, like every sibling lint in this directory: +// +// EVERY FINDING IS A WARNING, BY CONSTRUCTION. Nothing here reaches the exit code. +// +// THE TWO HALVES READ DIFFERENT THINGS, and have to. The reference half reads the LOADED +// model, because "does this object have that field" is a question about the EFFECTIVE +// field set — inherited through `extends` and merged from overlay files. The duplicate +// half reads the RAW DOCUMENTS, because the merge has already erased the duplicate from +// the model by the time anything can ask (overlay-lint.ts reads files for the same reason). +// +// The codes, the message text and the fixtures are shared with the C#, Java and Python +// CLIs: fixtures/field-lint-conformance/. + +import { + IDENTITY_ATTR_FIELDS, + IDENTITY_SUBTYPE_REFERENCE, + TYPE_FIELD, + TYPE_IDENTITY, + TYPE_OBJECT, + type MetaData, + type MetaDataSource, +} from "@metaobjectsdev/metadata"; +import { declaredDuplicateFields, type DeclaredDuplicateField } from "@metaobjectsdev/metadata/core"; +import type { Diagnostic } from "./requirement-check.js"; + +/** An `identity.reference` lists a field its object does not have. */ +export const WARN_REFERENCE_FIELD_NOT_FOUND = "WARN_REFERENCE_FIELD_NOT_FOUND"; +/** One `children` list declares the same field name more than once. */ +export const WARN_DUPLICATE_FIELD_NAME = "WARN_DUPLICATE_FIELD_NAME"; + +/** Resolve one metadata file path to the source the duplicate scan reads it through. */ +export type ReadSource = (path: string) => MetaDataSource; + +function warn(path: string, code: string, message: string): Diagnostic { + return { severity: "warn", code, path, message }; +} + +/** + * Report every `identity.reference` whose `@fields` names a field its object lacks. + * Returns `[]` for a model where every listed field exists. + */ +export function lintReferenceFields(root: MetaData): Diagnostic[] { + const out: Diagnostic[] = []; + // OWN-ONLY (ADR-0039 sanctioned case): a root has no super, and its own children are + // the declared objects. + for (const object of root.ownChildren()) { + if (object.type !== TYPE_OBJECT) continue; + const address = object.resolutionKey(); + // RESOLVING: the effective field set — a field inherited through `extends` or added + // by an overlay file is a field the object has. + const fields = new Set(object.children().filter((c) => c.type === TYPE_FIELD).map((c) => c.name)); + // OWN-ONLY (ADR-0039 sanctioned case): report each DECLARATION once, on the object + // that declares it. The resolving `children()` would repeat an inherited reference + // on every subtype. + for (const identity of object.ownChildren()) { + if (identity.type !== TYPE_IDENTITY || identity.subType !== IDENTITY_SUBTYPE_REFERENCE) continue; + // RESOLVING: `@fields` may itself be inherited through the identity's `extends`. + const listed = identity.attr(IDENTITY_ATTR_FIELDS); + const names = Array.isArray(listed) ? listed : typeof listed === "string" ? [listed] : []; + for (const name of names) { + if (typeof name !== "string" || fields.has(name)) continue; + out.push(warn(`${address}.${identity.name}`, WARN_REFERENCE_FIELD_NOT_FOUND, referenceFieldMessage(identity.name, name, address))); + } + } + } + return out; +} + +function referenceFieldMessage(identity: string, field: string, object: string): string { + return ( + `identity.reference ${JSON.stringify(identity)} lists ${JSON.stringify(field)} in @fields, but ` + + `${object} 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.` + ); +} + +/** One raw-scan finding as a diagnostic. Exported so the scan and its text are testable + * without a filesystem. */ +export function duplicateFieldDiagnostic(dup: DeclaredDuplicateField): Diagnostic { + return warn( + `${dup.object}.${dup.field}`, + WARN_DUPLICATE_FIELD_NAME, + `${dup.object} declares the field ${JSON.stringify(dup.field)} ${dup.count} 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.`, + ); +} + +/** + * Report every field name declared more than once in one object's `children` list, + * scanning each of `files` as raw content. + * + * Unreadable or unparsable files are skipped — the loader reports those itself. + */ +export async function lintDuplicateFields(files: readonly string[], readSource: ReadSource): Promise { + const out: Diagnostic[] = []; + for (const path of files) { + let declared: ReadonlyArray; + try { + const source = readSource(path); + declared = declaredDuplicateFields(await source.read(), source.format); + } catch { + continue; + } + out.push(...declared.map(duplicateFieldDiagnostic)); + } + return out; +} diff --git a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap index 7ad4ee0d6..9dd93b382 100644 --- a/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap +++ b/server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap @@ -127,6 +127,9 @@ VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drif in a name; never a gate — this lint can't fail the build) --no-deprecation-lint Suppress the advisory deprecated-reference AUTHORING lint (never a gate — this lint can't fail the build) + --no-field-lint Suppress the advisory field AUTHORING lint (a reference + identity over a missing field; a duplicate field name — + never a gate; this lint can't fail the build) --limit How many advisory lines TEXT output prints per section before it truncates (default 20). Never applies to --format toon/json, which carry every finding and every diagnostic. diff --git a/server/typescript/packages/cli/test/field-lint-conformance.test.ts b/server/typescript/packages/cli/test/field-lint-conformance.test.ts new file mode 100644 index 000000000..6d1e53c1f --- /dev/null +++ b/server/typescript/packages/cli/test/field-lint-conformance.test.ts @@ -0,0 +1,45 @@ +// Cross-port field-lint conformance corpus — fixtures/field-lint-conformance/. +// +// Every case is LOADED strict first, so "this loads with no error today" is proven by +// the fixture rather than asserted in prose, and only then linted. The C#, Java and +// Python runners assert the same `expected.json`, so the codes, addresses and message +// text cannot drift between the ports. +import { describe, test, expect } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { lintDuplicateFields, lintReferenceFields } from "../src/lib/field-lint.js"; + +const CORPUS_DIR = join(import.meta.dir, "../../../../../fixtures/field-lint-conformance"); + +interface Finding { code: string; path: string; message: string } + +const order = (a: Finding, b: Finding): number => + a.code.localeCompare(b.code) || a.path.localeCompare(b.path) || a.message.localeCompare(b.message); + +const cases = readdirSync(CORPUS_DIR).filter((n) => statSync(join(CORPUS_DIR, n)).isDirectory()).sort(); + +describe("field-lint conformance corpus", () => { + test("discovers the corpus", () => { + expect(cases.length).toBeGreaterThan(0); + }); + + for (const name of cases) { + test(name, async () => { + const input = join(CORPUS_DIR, name, "input"); + const expected = JSON.parse(readFileSync(join(CORPUS_DIR, name, "expected.json"), "utf8")) as { findings: Finding[] }; + + const result = await MetaDataLoader.fromDirectory(input, { strict: true }); + expect(result.errors.map(String)).toEqual([]); + + const files = readdirSync(input).sort().map((f) => join(input, f)); + const actual = [ + ...lintReferenceFields(result.root), + ...(await lintDuplicateFields(files, (path) => new FileSource(path))), + ].map(({ code, path, message }) => ({ code, path: path ?? "", message })); + + expect(actual.sort(order)).toEqual([...expected.findings].sort(order)); + }); + } +}); diff --git a/server/typescript/packages/cli/test/unit/args-verify.test.ts b/server/typescript/packages/cli/test/unit/args-verify.test.ts index 00275f9d7..f5cbd2cfb 100644 --- a/server/typescript/packages/cli/test/unit/args-verify.test.ts +++ b/server/typescript/packages/cli/test/unit/args-verify.test.ts @@ -6,7 +6,7 @@ describe("parseVerifyArgs", () => { test("defaults: prompts/db/dialect undefined, allow empty, skipSchema false, no explicit subverb", () => { expect(parseVerifyArgs([])).toEqual({ prompts: undefined, db: undefined, dialect: undefined, allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -14,7 +14,7 @@ describe("parseVerifyArgs", () => { test("--prompts is captured", () => { expect(parseVerifyArgs(["--prompts", "templates"])).toEqual({ prompts: "templates", db: undefined, dialect: undefined, allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -22,7 +22,7 @@ describe("parseVerifyArgs", () => { test("--db / --dialect / --skip-schema are captured", () => { expect(parseVerifyArgs(["--db", "file:x.db", "--dialect", "sqlite", "--skip-schema"])).toEqual({ prompts: undefined, db: "file:x.db", dialect: "sqlite", allow: [], skipSchema: true, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -32,7 +32,7 @@ describe("parseVerifyArgs", () => { test("--dialect d1 --d1 --remote are captured; --dialect d1 alone is an explicit subverb", () => { expect(parseVerifyArgs(["--dialect", "d1", "--d1", "DB", "--remote"])).toEqual({ prompts: undefined, db: undefined, dialect: "d1", allow: [], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: true, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: "DB", remote: true, limit: DEFAULT_ADVISORY_LIMIT, }); @@ -61,7 +61,7 @@ describe("parseVerifyArgs", () => { expect(parseVerifyArgs(["--allow", "drop-column,drop-table"])).toEqual({ prompts: undefined, db: undefined, dialect: undefined, allow: ["drop-column", "drop-table"], skipSchema: false, - templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, lax: false, + templates: false, codegen: false, forbidHandEdits: false, docs: false, deps: false, anyExplicit: false, noAntipatterns: false, noRequirementLint: false, noOverlayLint: false, noNameLint: false, noDeprecationLint: false, noFieldLint: false, lax: false, replay: false, replaySnapshot: false, d1: undefined, remote: false, limit: DEFAULT_ADVISORY_LIMIT, }); diff --git a/server/typescript/packages/cli/test/verify-field-lint.test.ts b/server/typescript/packages/cli/test/verify-field-lint.test.ts new file mode 100644 index 000000000..4f0fb9e7a --- /dev/null +++ b/server/typescript/packages/cli/test/verify-field-lint.test.ts @@ -0,0 +1,126 @@ +// `meta verify` — the field authoring lint, end to end: printed as its own advisory +// section, carried in full in the structured payload, never reaching the exit code, and +// muted by its own flag/env pair the way every sibling advisory is. +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "../src/index.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +/** A project whose `Item` references `Owner` through `referenceField`, and declares + * `label` once or twice. */ +function project(referenceField: string, duplicateLabel: boolean): string { + const root = mkdtempSync(join(tmpdir(), "vfl-")); + dirs.push(root); + mkdirSync(join(root, "metaobjects"), { recursive: true }); + mkdirSync(join(root, ".metaobjects"), { recursive: true }); + writeFileSync(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, sources: [] })); + const label = { "field.string": { name: "label" } }; + writeFileSync(join(root, "metaobjects", "meta.app.json"), JSON.stringify({ + "metadata.root": { + package: "app", + children: [ + { + "object.entity": { + name: "Owner", + children: [ + { "source.rdb": { "@table": "owners" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ], + }, + }, + { + "object.entity": { + name: "Item", + children: [ + { "source.rdb": { "@table": "items" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "ownerId" } }, + label, + ...(duplicateLabel ? [label] : []), + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + { "identity.reference": { name: "owner_fk", "@fields": [referenceField], "@references": "Owner" } }, + ], + }, + }, + ], + }, + })); + return root; +} + +let out: string[]; +let err: string[]; +let origLog: typeof console.log; +let origErr: typeof console.error; +beforeEach(() => { + out = []; + err = []; + origLog = console.log; + origErr = console.error; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); }; +}); +afterEach(() => { + console.log = origLog; + console.error = origErr; +}); + +describe("meta verify — the field authoring lint", () => { + test("both findings are advisory in text output, exit 0", async () => { + const exit = await run(["verify", "--format", "text", "--cwd", project("ownerIdd", true)]); + expect(exit).toBe(0); + const all = [...out, ...err].join("\n"); + expect(all).toContain("meta verify — fields: 2 authoring warning(s) (advisory — does not fail the build):"); + expect(all).toContain("WARN_REFERENCE_FIELD_NOT_FOUND [app::Item.owner_fk]"); + expect(all).toContain("WARN_DUPLICATE_FIELD_NAME [app::Item.label]"); + }); + + test("the structured payload carries the findings in their own `fields` section", async () => { + const exit = await run(["verify", "--format", "json", "--cwd", project("ownerIdd", true)]); + expect(exit).toBe(0); + const payload = JSON.parse(out.join("\n")) as { + fields: { status: string; total: number; rows: { code: string; path: string; source: string }[] }; + summary: string; + }; + expect(payload.fields.status).toBe("ran"); + expect(payload.fields.rows.map((r) => [r.code, r.path, r.source])).toEqual([ + ["WARN_REFERENCE_FIELD_NOT_FOUND", "app::Item.owner_fk", "lint"], + ["WARN_DUPLICATE_FIELD_NAME", "app::Item.label", "lint"], + ]); + expect(payload.summary).toContain("2 field authoring finding(s)"); + }); + + test("a clean model reports nothing and the section still says it ran", async () => { + const exit = await run(["verify", "--format", "json", "--cwd", project("ownerId", false)]); + expect(exit).toBe(0); + const payload = JSON.parse(out.join("\n")) as { fields: { status: string; total: number } }; + expect(payload.fields).toMatchObject({ status: "ran", total: 0 }); + }); + + test("--no-field-lint silences it", async () => { + const exit = await run(["verify", "--format", "text", "--cwd", project("ownerIdd", true), "--no-field-lint"]); + expect(exit).toBe(0); + const all = [...out, ...err].join("\n"); + expect(all).not.toContain("WARN_REFERENCE_FIELD_NOT_FOUND"); + expect(all).not.toContain("WARN_DUPLICATE_FIELD_NAME"); + }); + + test("META_NO_FIELD_LINT=1 silences it", async () => { + const prev = process.env.META_NO_FIELD_LINT; + process.env.META_NO_FIELD_LINT = "1"; + try { + expect(await run(["verify", "--format", "text", "--cwd", project("ownerIdd", true)])).toBe(0); + } finally { + if (prev === undefined) delete process.env.META_NO_FIELD_LINT; + else process.env.META_NO_FIELD_LINT = prev; + } + expect([...out, ...err].join("\n")).not.toContain("WARN_REFERENCE_FIELD_NOT_FOUND"); + }); +}); diff --git a/server/typescript/packages/metadata/src/core/index.ts b/server/typescript/packages/metadata/src/core/index.ts index 4bd2f511b..a273e07f5 100644 --- a/server/typescript/packages/metadata/src/core/index.ts +++ b/server/typescript/packages/metadata/src/core/index.ts @@ -16,3 +16,8 @@ export { UriSource } from "../loader/sources/uri-source.js"; export { parseYaml } from "./parser-yaml.js"; export { loadAndExportJson } from "./export-json.js"; export type { ExportResult } from "./export-json.js"; + +// The `meta verify` field lint's structural pre-parse walk. Lives here, not on the +// root entry, because it parses YAML. +export { declaredDuplicateFields } from "../loader/declared-duplicate-fields.js"; +export type { DeclaredDuplicateField } from "../loader/declared-duplicate-fields.js"; diff --git a/server/typescript/packages/metadata/src/loader/declared-duplicate-fields.ts b/server/typescript/packages/metadata/src/loader/declared-duplicate-fields.ts new file mode 100644 index 000000000..c0aa684cb --- /dev/null +++ b/server/typescript/packages/metadata/src/loader/declared-duplicate-fields.ts @@ -0,0 +1,114 @@ +// declaredDuplicateFields — structural pre-parse walk for the `meta verify` field lint. +// +// Two `field.*` children with the same `name` in ONE object's `children` list load +// with no error on every port, and the loaded model keeps no trace of it: the parser's +// merge rule folds the later declaration into the first (a different subtype is +// discarded outright). So the question "was this field declared twice?" can only be +// answered from the document, before the merge — the same reason +// `declaredTopLevelKeys` exists for the overlay lint. +// +// Scope, deliberately narrow: +// - ONE children list. The same field declared in a base file and again in an +// overlay file is the overlay merge working as designed, and a subtype +// redeclaring an inherited field is an override. Neither is in one list. +// - Root-level objects only, addressed by the resolution key the parser gives them. + +import { TYPE_FIELD, TYPE_METADATA, TYPE_OBJECT, SUBTYPE_ROOT } from "../shared/base-types.js"; +import { + PACKAGE_SEPARATOR, + RESERVED_KEY_CHILDREN, + RESERVED_KEY_NAME, + RESERVED_KEY_PACKAGE, + TYPE_SUBTYPE_SEPARATOR, +} from "../shared/structural.js"; +import { expandPackageForPath } from "../parser-core.js"; +import { parseYamlWithPositions } from "../core/yaml-positions-walker.js"; +import type { MetaDataFormat } from "./meta-data-source.js"; + +/** The YAML authoring sugar for `isArray: true` — a suffix on the wrapper key. */ +const ARRAY_SUFFIX = "[]"; + +/** One field name declared more than once in one object's `children` list. */ +export interface DeclaredDuplicateField { + /** The declaring object's resolution key (`::`, or the bare name). */ + object: string; + /** The repeated field name. */ + field: string; + /** How many times the list declares it (always 2 or more). */ + count: number; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** The TYPE segment of a wrapper key: `field.string[]` and the bare `field` are both `field`. */ +function wrapperType(key: string): string { + const dot = key.indexOf(TYPE_SUBTYPE_SEPARATOR); + const head = dot < 0 ? key : key.slice(0, dot); + return head.endsWith(ARRAY_SUFFIX) ? head.slice(0, -ARRAY_SUFFIX.length) : head; +} + +/** A node's declared name: the body's `name`, or the body itself when YAML wrote a scalar. */ +function declaredName(body: unknown): string | undefined { + const name = isRecord(body) ? body[RESERVED_KEY_NAME] : body; + return typeof name === "string" && name !== "" ? name : undefined; +} + +/** + * Structurally scan a source's raw content and report every field name a root-level + * object declares more than once in its own `children` list, in document order. + * + * Malformed shapes return `[]` rather than throwing — the real parse is where a + * structural error surfaces. A syntax error does throw, as `declaredTopLevelKeys` does. + */ +export function declaredDuplicateFields( + content: string, + format: MetaDataFormat, +): ReadonlyArray { + // Strip UTF-8 BOM if present (mirrors parseJson / parseYaml). + const normalized = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; + let parsed: unknown; + if (format === "json") parsed = JSON.parse(normalized); + else if (format === "yaml") parsed = parseYamlWithPositions(normalized).value; + else return []; + if (!isRecord(parsed)) return []; + + // JSON fuses the subtype onto the root key; sigil-free YAML may write the bare type. + const rootBody = parsed[`${TYPE_METADATA}${TYPE_SUBTYPE_SEPARATOR}${SUBTYPE_ROOT}`] ?? parsed[TYPE_METADATA]; + if (!isRecord(rootBody)) return []; + const rawRootPkg = rootBody[RESERVED_KEY_PACKAGE]; + const rootPkg = typeof rawRootPkg === "string" ? rawRootPkg : ""; + const children = rootBody[RESERVED_KEY_CHILDREN]; + if (!Array.isArray(children)) return []; + + const out: DeclaredDuplicateField[] = []; + for (const child of children) { + if (!isRecord(child)) continue; + for (const [wrapperKey, body] of Object.entries(child)) { + if (wrapperType(wrapperKey) !== TYPE_OBJECT || !isRecord(body)) continue; + const name = declaredName(body); + const members = body[RESERVED_KEY_CHILDREN]; + if (name === undefined || !Array.isArray(members)) continue; + + const counts = new Map(); + for (const member of members) { + if (!isRecord(member)) continue; + for (const [memberKey, memberBody] of Object.entries(member)) { + if (wrapperType(memberKey) !== TYPE_FIELD) continue; + const fieldName = declaredName(memberBody); + if (fieldName !== undefined) counts.set(fieldName, (counts.get(fieldName) ?? 0) + 1); + } + } + + const rawOwnPkg = body[RESERVED_KEY_PACKAGE]; + const pkg = + typeof rawOwnPkg === "string" && rawOwnPkg !== "" ? expandPackageForPath(rootPkg, rawOwnPkg) : rootPkg; + const object = pkg !== "" ? `${pkg}${PACKAGE_SEPARATOR}${name}` : name; + for (const [field, count] of counts) { + if (count > 1) out.push({ object, field, count }); + } + } + } + return out; +} From 582d7132e03b1eb9fa87d7afdcc4482e8d432794 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 15:00:18 -0400 Subject: [PATCH 2/8] chore(site): refresh site payload for the 26th shared corpus --- 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 ee3e186ce..351cf1838 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 363, - "corpora": 25, + "corpora": 26, "baseTypes": 17 }, "snippets": { From 7e89752868b086d0c9829334b06ed5b29ffd26cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 15:50:22 -0400 Subject: [PATCH 3/8] no-mistakes(review): expand Python relative-package reference-lint address; add gating fixture --- docs/CONFORMANCE.md | 2 +- .../expected.json | 9 +++ .../input/meta.app.json | 63 +++++++++++++++++++ server/python/src/metaobjects/field_lint.py | 7 ++- 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 fixtures/field-lint-conformance/reference-field-missing-own-package/expected.json create mode 100644 fixtures/field-lint-conformance/reference-field-missing-own-package/input/meta.app.json diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 7b8d37c72..94b182ede 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -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) | 13 | ✓ (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) | ✓ | ✓ | ✓ | ✓ | diff --git a/fixtures/field-lint-conformance/reference-field-missing-own-package/expected.json b/fixtures/field-lint-conformance/reference-field-missing-own-package/expected.json new file mode 100644 index 000000000..f00f5c63e --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing-own-package/expected.json @@ -0,0 +1,9 @@ +{ + "findings": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "acme::app::stock::Item.owner_fk", + "message": "identity.reference \"owner_fk\" lists \"ownerIdd\" in @fields, but acme::app::stock::Item 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-own-package/input/meta.app.json b/fixtures/field-lint-conformance/reference-field-missing-own-package/input/meta.app.json new file mode 100644 index 000000000..b27e4228e --- /dev/null +++ b/fixtures/field-lint-conformance/reference-field-missing-own-package/input/meta.app.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/server/python/src/metaobjects/field_lint.py b/server/python/src/metaobjects/field_lint.py index 6e26dc5c3..9d66c108c 100644 --- a/server/python/src/metaobjects/field_lint.py +++ b/server/python/src/metaobjects/field_lint.py @@ -78,10 +78,15 @@ 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 - address = obj.resolution_key() + 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() # 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} From a76a93660cf719eb75aa83b76d3cda64bc65d981 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 17:48:28 -0400 Subject: [PATCH 4/8] no-mistakes(review): document Python multi-root-package lint address corner --- docs/features/cli.md | 5 ++++- fixtures/field-lint-conformance/README.md | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/features/cli.md b/docs/features/cli.md index 0c3c1f8e3..5c99f44c5 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -139,7 +139,10 @@ 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). + [`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) diff --git a/fixtures/field-lint-conformance/README.md b/fixtures/field-lint-conformance/README.md index 2e8e290db..36ff3d9d2 100644 --- a/fixtures/field-lint-conformance/README.md +++ b/fixtures/field-lint-conformance/README.md @@ -32,7 +32,9 @@ 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. +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 From ab6f36cdcaf886970cd771960cad75054c513942 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 18:45:37 -0400 Subject: [PATCH 5/8] no-mistakes(document): Documentation for verify field-lint warnings complete and consistent across all sources. --- .tmp-drive/corpus/.metaobjects/config.json | 5 ++ .tmp-drive/corpus/err.txt | 5 ++ .tmp-drive/corpus/metaobjects/meta.app.json | 63 ++++++++++++++ .tmp-drive/corpus/out.json | 86 +++++++++++++++++++ .tmp-java-drive/metaobjects/meta.app.json | 63 ++++++++++++++ .tmp-java-drive/mvn-out.txt | 20 +++++ .tmp-java-drive/pom.xml | 24 ++++++ .../.tmp-drive/.metaobjects/config.json | 5 ++ server/typescript/.tmp-drive/corpus/err.txt | 2 + .../corpus/metaobjects/meta.app.json | 63 ++++++++++++++ server/typescript/.tmp-drive/corpus/out.txt | 1 + .../.tmp-drive/metaobjects/meta.drive.json | 36 ++++++++ 12 files changed, 373 insertions(+) create mode 100644 .tmp-drive/corpus/.metaobjects/config.json create mode 100644 .tmp-drive/corpus/err.txt create mode 100644 .tmp-drive/corpus/metaobjects/meta.app.json create mode 100644 .tmp-drive/corpus/out.json create mode 100644 .tmp-java-drive/metaobjects/meta.app.json create mode 100644 .tmp-java-drive/mvn-out.txt create mode 100644 .tmp-java-drive/pom.xml create mode 100644 server/typescript/.tmp-drive/.metaobjects/config.json create mode 100644 server/typescript/.tmp-drive/corpus/err.txt create mode 100644 server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json create mode 100644 server/typescript/.tmp-drive/corpus/out.txt create mode 100644 server/typescript/.tmp-drive/metaobjects/meta.drive.json diff --git a/.tmp-drive/corpus/.metaobjects/config.json b/.tmp-drive/corpus/.metaobjects/config.json new file mode 100644 index 000000000..c708038ae --- /dev/null +++ b/.tmp-drive/corpus/.metaobjects/config.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "sources": [], + "extract": {} +} diff --git a/.tmp-drive/corpus/err.txt b/.tmp-drive/corpus/err.txt new file mode 100644 index 000000000..fe49d5481 --- /dev/null +++ b/.tmp-drive/corpus/err.txt @@ -0,0 +1,5 @@ +meta: meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty). +meta: meta verify — no template.* nodes found; the template gate had nothing to check. +meta: meta verify — fields: 1 authoring warning(s) (advisory — does not fail the build): +meta: WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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. +meta: meta verify — not run: schema (--db ), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it diff --git a/.tmp-drive/corpus/metaobjects/meta.app.json b/.tmp-drive/corpus/metaobjects/meta.app.json new file mode 100644 index 000000000..b27e4228e --- /dev/null +++ b/.tmp-drive/corpus/metaobjects/meta.app.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/.tmp-drive/corpus/out.json b/.tmp-drive/corpus/out.json new file mode 100644 index 000000000..21450cf55 --- /dev/null +++ b/.tmp-drive/corpus/out.json @@ -0,0 +1,86 @@ +{ + "verify": [ + { + "gate": "templates", + "ran": true, + "ok": true + }, + { + "gate": "schema", + "ran": false, + "ok": true + }, + { + "gate": "codegen", + "ran": false, + "ok": true + }, + { + "gate": "docs", + "ran": false, + "ok": true + }, + { + "gate": "deps", + "ran": false, + "ok": true + }, + { + "gate": "requirements", + "ran": true, + "ok": true + }, + { + "gate": "replay", + "ran": false, + "ok": true + } + ], + "exitCode": 0, + "summary": "2 gate(s) ran, all clean; 1 field authoring finding(s)", + "help": [ + "1 field authoring finding(s) — see fields.rows[]; each names a reference identity listing a field its object lacks, or a field name declared twice in one children list" + ], + "antiPatterns": { + "status": "ran", + "total": 0, + "rows": [] + }, + "requirements": { + "status": "ran", + "total": 0, + "rows": [] + }, + "overlays": { + "status": "ran", + "total": 0, + "rows": [] + }, + "names": { + "status": "ran", + "total": 0, + "rows": [] + }, + "deprecations": { + "status": "ran", + "total": 0, + "rows": [] + }, + "fields": { + "status": "ran", + "total": 1, + "rows": [ + { + "code": "WARN_REFERENCE_FIELD_NOT_FOUND", + "path": "acme::app::stock::Item.owner_fk", + "severity": "warn", + "source": "lint", + "message": "identity.reference \"owner_fk\" lists \"ownerIdd\" in @fields, but acme::app::stock::Item 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." + } + ] + }, + "notRepresented": [ + "per-gate drift detail (which template variable drifted, which schema change, which generated file differs, which migration failed to replay) — printed as text on stderr; this payload carries each gate's pass/fail verdict", + "the loader's own warnings and the agent-context/manifest advisories — printed as text on stderr" + ] +} diff --git a/.tmp-java-drive/metaobjects/meta.app.json b/.tmp-java-drive/metaobjects/meta.app.json new file mode 100644 index 000000000..b27e4228e --- /dev/null +++ b/.tmp-java-drive/metaobjects/meta.app.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/.tmp-java-drive/mvn-out.txt b/.tmp-java-drive/mvn-out.txt new file mode 100644 index 000000000..8abdb25f3 --- /dev/null +++ b/.tmp-java-drive/mvn-out.txt @@ -0,0 +1,20 @@ +[INFO] Scanning for projects... +[INFO] +[INFO] -----------------------< drive:field-lint-drive >----------------------- +[INFO] Building field-lint-drive 1.0.0 +[INFO] from pom.xml +[INFO] --------------------------------[ jar ]--------------------------------- +[INFO] +[INFO] --- metaobjects:8.0.13:verify (default-cli) @ field-lint-drive --- +[INFO] OM managed types registered via provider +[INFO] MetaData Mojo > LifeCycle Phase: null +[INFO] MetaData Mojo > Create Loader: MetaDataLoader[manual:null] +[WARNING] metaobjects:verify — fields: 1 authoring warning(s) (advisory — does not fail the build): +[WARNING] WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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. +[INFO] MetaData Verify Mojo > No codegen drift detected across 0 generator(s) in 0 output director(y/ies). +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.436 s +[INFO] Finished at: 2026-10-04T18:15:29-04:00 +[INFO] ------------------------------------------------------------------------ diff --git a/.tmp-java-drive/pom.xml b/.tmp-java-drive/pom.xml new file mode 100644 index 000000000..60636b6bc --- /dev/null +++ b/.tmp-java-drive/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + drive + field-lint-drive + 1.0.0 + jar + + + + com.metaobjects + metaobjects-maven-plugin + 8.0.13 + + + ${project.basedir}/metaobjects + + + + + + diff --git a/server/typescript/.tmp-drive/.metaobjects/config.json b/server/typescript/.tmp-drive/.metaobjects/config.json new file mode 100644 index 000000000..c708038ae --- /dev/null +++ b/server/typescript/.tmp-drive/.metaobjects/config.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "sources": [], + "extract": {} +} diff --git a/server/typescript/.tmp-drive/corpus/err.txt b/server/typescript/.tmp-drive/corpus/err.txt new file mode 100644 index 000000000..7f76d2d63 --- /dev/null +++ b/server/typescript/.tmp-drive/corpus/err.txt @@ -0,0 +1,2 @@ +dotnet meta verify — fields: 1 authoring warning(s) (advisory — does not fail the build): + WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json b/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json new file mode 100644 index 000000000..b27e4228e --- /dev/null +++ b/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { + "object.entity": { + "name": "Owner", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Item", + "package": "::stock", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "ownerId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "owner_fk", + "@fields": [ + "ownerIdd" + ], + "@references": "Owner" + } + } + ] + } + } + ] + } +} diff --git a/server/typescript/.tmp-drive/corpus/out.txt b/server/typescript/.tmp-drive/corpus/out.txt new file mode 100644 index 000000000..4e7145a77 --- /dev/null +++ b/server/typescript/.tmp-drive/corpus/out.txt @@ -0,0 +1 @@ +dotnet meta verify --templates: OK diff --git a/server/typescript/.tmp-drive/metaobjects/meta.drive.json b/server/typescript/.tmp-drive/metaobjects/meta.drive.json new file mode 100644 index 000000000..a3e96d8d1 --- /dev/null +++ b/server/typescript/.tmp-drive/metaobjects/meta.drive.json @@ -0,0 +1,36 @@ +{ + "metadata.root": { + "package": "acme::app", + "children": [ + { "object.entity": { "name": "Subscriber", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "ownerId" } }, + { "field.string": { "name": "region" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Item", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "ownerId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "owner_fk", "@fields": ["ownerIdd"], "@references": "Subscriber" } } + ]}}, + { "object.entity": { "name": "Gadget", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "label" } }, + { "field.string": { "name": "label" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Holder", "package": "::parts", "children": [ + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } } + ]}}, + { "object.entity": { "name": "Widget", "package": "::parts", "children": [ + { "field.long": { "name": "id" } }, + { "field.string": { "name": "title" } }, + { "field.string": { "name": "title" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "identity.reference": { "name": "holder_fk", "@fields": ["holderIdd"], "@references": "Holder" } } + ]}} + ] + } +} From 3927b78eaafee8cea0b8d85202d4f854152e79b6 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 18:47:22 -0400 Subject: [PATCH 6/8] 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. --- .tmp-java-drive/metaobjects/meta.app.json | 63 ------------------- .tmp-java-drive/mvn-out.txt | 20 ------ .tmp-java-drive/pom.xml | 24 ------- .../.tmp-drive/.metaobjects/config.json | 5 -- server/typescript/.tmp-drive/corpus/err.txt | 2 - .../corpus/metaobjects/meta.app.json | 63 ------------------- server/typescript/.tmp-drive/corpus/out.txt | 1 - .../.tmp-drive/metaobjects/meta.drive.json | 36 ----------- 8 files changed, 214 deletions(-) delete mode 100644 .tmp-java-drive/metaobjects/meta.app.json delete mode 100644 .tmp-java-drive/mvn-out.txt delete mode 100644 .tmp-java-drive/pom.xml delete mode 100644 server/typescript/.tmp-drive/.metaobjects/config.json delete mode 100644 server/typescript/.tmp-drive/corpus/err.txt delete mode 100644 server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json delete mode 100644 server/typescript/.tmp-drive/corpus/out.txt delete mode 100644 server/typescript/.tmp-drive/metaobjects/meta.drive.json diff --git a/.tmp-java-drive/metaobjects/meta.app.json b/.tmp-java-drive/metaobjects/meta.app.json deleted file mode 100644 index b27e4228e..000000000 --- a/.tmp-java-drive/metaobjects/meta.app.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "metadata.root": { - "package": "acme::app", - "children": [ - { - "object.entity": { - "name": "Owner", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - } - ] - } - }, - { - "object.entity": { - "name": "Item", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "field.long": { - "name": "ownerId" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - }, - { - "identity.reference": { - "name": "owner_fk", - "@fields": [ - "ownerIdd" - ], - "@references": "Owner" - } - } - ] - } - } - ] - } -} diff --git a/.tmp-java-drive/mvn-out.txt b/.tmp-java-drive/mvn-out.txt deleted file mode 100644 index 8abdb25f3..000000000 --- a/.tmp-java-drive/mvn-out.txt +++ /dev/null @@ -1,20 +0,0 @@ -[INFO] Scanning for projects... -[INFO] -[INFO] -----------------------< drive:field-lint-drive >----------------------- -[INFO] Building field-lint-drive 1.0.0 -[INFO] from pom.xml -[INFO] --------------------------------[ jar ]--------------------------------- -[INFO] -[INFO] --- metaobjects:8.0.13:verify (default-cli) @ field-lint-drive --- -[INFO] OM managed types registered via provider -[INFO] MetaData Mojo > LifeCycle Phase: null -[INFO] MetaData Mojo > Create Loader: MetaDataLoader[manual:null] -[WARNING] metaobjects:verify — fields: 1 authoring warning(s) (advisory — does not fail the build): -[WARNING] WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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. -[INFO] MetaData Verify Mojo > No codegen drift detected across 0 generator(s) in 0 output director(y/ies). -[INFO] ------------------------------------------------------------------------ -[INFO] BUILD SUCCESS -[INFO] ------------------------------------------------------------------------ -[INFO] Total time: 0.436 s -[INFO] Finished at: 2026-10-04T18:15:29-04:00 -[INFO] ------------------------------------------------------------------------ diff --git a/.tmp-java-drive/pom.xml b/.tmp-java-drive/pom.xml deleted file mode 100644 index 60636b6bc..000000000 --- a/.tmp-java-drive/pom.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - 4.0.0 - drive - field-lint-drive - 1.0.0 - jar - - - - com.metaobjects - metaobjects-maven-plugin - 8.0.13 - - - ${project.basedir}/metaobjects - - - - - - diff --git a/server/typescript/.tmp-drive/.metaobjects/config.json b/server/typescript/.tmp-drive/.metaobjects/config.json deleted file mode 100644 index c708038ae..000000000 --- a/server/typescript/.tmp-drive/.metaobjects/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schema_version": 1, - "sources": [], - "extract": {} -} diff --git a/server/typescript/.tmp-drive/corpus/err.txt b/server/typescript/.tmp-drive/corpus/err.txt deleted file mode 100644 index 7f76d2d63..000000000 --- a/server/typescript/.tmp-drive/corpus/err.txt +++ /dev/null @@ -1,2 +0,0 @@ -dotnet meta verify — fields: 1 authoring warning(s) (advisory — does not fail the build): - WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json b/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json deleted file mode 100644 index b27e4228e..000000000 --- a/server/typescript/.tmp-drive/corpus/metaobjects/meta.app.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "metadata.root": { - "package": "acme::app", - "children": [ - { - "object.entity": { - "name": "Owner", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - } - ] - } - }, - { - "object.entity": { - "name": "Item", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "field.long": { - "name": "ownerId" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - }, - { - "identity.reference": { - "name": "owner_fk", - "@fields": [ - "ownerIdd" - ], - "@references": "Owner" - } - } - ] - } - } - ] - } -} diff --git a/server/typescript/.tmp-drive/corpus/out.txt b/server/typescript/.tmp-drive/corpus/out.txt deleted file mode 100644 index 4e7145a77..000000000 --- a/server/typescript/.tmp-drive/corpus/out.txt +++ /dev/null @@ -1 +0,0 @@ -dotnet meta verify --templates: OK diff --git a/server/typescript/.tmp-drive/metaobjects/meta.drive.json b/server/typescript/.tmp-drive/metaobjects/meta.drive.json deleted file mode 100644 index a3e96d8d1..000000000 --- a/server/typescript/.tmp-drive/metaobjects/meta.drive.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "metadata.root": { - "package": "acme::app", - "children": [ - { "object.entity": { "name": "Subscriber", "children": [ - { "field.long": { "name": "id" } }, - { "field.string": { "name": "ownerId" } }, - { "field.string": { "name": "region" } }, - { "identity.primary": { "name": "pk", "@fields": ["id"] } } - ]}}, - { "object.entity": { "name": "Item", "children": [ - { "field.long": { "name": "id" } }, - { "field.string": { "name": "ownerId" } }, - { "identity.primary": { "name": "pk", "@fields": ["id"] } }, - { "identity.reference": { "name": "owner_fk", "@fields": ["ownerIdd"], "@references": "Subscriber" } } - ]}}, - { "object.entity": { "name": "Gadget", "children": [ - { "field.long": { "name": "id" } }, - { "field.string": { "name": "label" } }, - { "field.string": { "name": "label" } }, - { "identity.primary": { "name": "pk", "@fields": ["id"] } } - ]}}, - { "object.entity": { "name": "Holder", "package": "::parts", "children": [ - { "field.long": { "name": "id" } }, - { "identity.primary": { "name": "pk", "@fields": ["id"] } } - ]}}, - { "object.entity": { "name": "Widget", "package": "::parts", "children": [ - { "field.long": { "name": "id" } }, - { "field.string": { "name": "title" } }, - { "field.string": { "name": "title" } }, - { "identity.primary": { "name": "pk", "@fields": ["id"] } }, - { "identity.reference": { "name": "holder_fk", "@fields": ["holderIdd"], "@references": "Holder" } } - ]}} - ] - } -} From f7e710095a9177911dfb02601791ae4cbee560ba Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 18:51:44 -0400 Subject: [PATCH 7/8] 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. --- .gitignore | 4 ++++ AGENTS.md | 2 -- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 6496f173e..7eb99fd71 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 67700d96f..3dfb9bf0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,6 @@ PyPI has had no product change since `0.25.0` — nothing is broken. **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. -||||||| parent of 77e6777d3 (feat(verify): warn about a dangling reference field and a duplicate field name, in every port) -- Metamodel: `fixtures/conformance/` (361 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. From dce5ffaad30efb0d4452d640e8baee0c3663f9d2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sun, 4 Oct 2026 20:18:04 -0400 Subject: [PATCH 8/8] chore: remove stray root-level .tmp-drive scratch directory Co-Authored-By: Claude Sonnet 5 --- .tmp-drive/corpus/.metaobjects/config.json | 5 -- .tmp-drive/corpus/err.txt | 5 -- .tmp-drive/corpus/metaobjects/meta.app.json | 63 --------------- .tmp-drive/corpus/out.json | 86 --------------------- 4 files changed, 159 deletions(-) delete mode 100644 .tmp-drive/corpus/.metaobjects/config.json delete mode 100644 .tmp-drive/corpus/err.txt delete mode 100644 .tmp-drive/corpus/metaobjects/meta.app.json delete mode 100644 .tmp-drive/corpus/out.json diff --git a/.tmp-drive/corpus/.metaobjects/config.json b/.tmp-drive/corpus/.metaobjects/config.json deleted file mode 100644 index c708038ae..000000000 --- a/.tmp-drive/corpus/.metaobjects/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "schema_version": 1, - "sources": [], - "extract": {} -} diff --git a/.tmp-drive/corpus/err.txt b/.tmp-drive/corpus/err.txt deleted file mode 100644 index fe49d5481..000000000 --- a/.tmp-drive/corpus/err.txt +++ /dev/null @@ -1,5 +0,0 @@ -meta: meta verify — running --templates (default). Explicit subverbs: --templates (prompt drift), --db/--dialect d1 (schema drift), --codegen (codegen drift), --docs (docs drift), --deps (dependency drift), --replay/--replay-snapshot (the committed migration chain replays from empty). -meta: meta verify — no template.* nodes found; the template gate had nothing to check. -meta: meta verify — fields: 1 authoring warning(s) (advisory — does not fail the build): -meta: WARN_REFERENCE_FIELD_NOT_FOUND [acme::app::stock::Item.owner_fk]: identity.reference "owner_fk" lists "ownerIdd" in @fields, but acme::app::stock::Item 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. -meta: meta verify — not run: schema (--db ), codegen (--codegen), docs (--docs), deps (--deps), replay (--replay) — a bare 'meta verify' runs only the template gate (plus the requirement ledger); name each gate to run it diff --git a/.tmp-drive/corpus/metaobjects/meta.app.json b/.tmp-drive/corpus/metaobjects/meta.app.json deleted file mode 100644 index b27e4228e..000000000 --- a/.tmp-drive/corpus/metaobjects/meta.app.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "metadata.root": { - "package": "acme::app", - "children": [ - { - "object.entity": { - "name": "Owner", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - } - ] - } - }, - { - "object.entity": { - "name": "Item", - "package": "::stock", - "children": [ - { - "field.long": { - "name": "id" - } - }, - { - "field.long": { - "name": "ownerId" - } - }, - { - "identity.primary": { - "name": "pk", - "@fields": [ - "id" - ] - } - }, - { - "identity.reference": { - "name": "owner_fk", - "@fields": [ - "ownerIdd" - ], - "@references": "Owner" - } - } - ] - } - } - ] - } -} diff --git a/.tmp-drive/corpus/out.json b/.tmp-drive/corpus/out.json deleted file mode 100644 index 21450cf55..000000000 --- a/.tmp-drive/corpus/out.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "verify": [ - { - "gate": "templates", - "ran": true, - "ok": true - }, - { - "gate": "schema", - "ran": false, - "ok": true - }, - { - "gate": "codegen", - "ran": false, - "ok": true - }, - { - "gate": "docs", - "ran": false, - "ok": true - }, - { - "gate": "deps", - "ran": false, - "ok": true - }, - { - "gate": "requirements", - "ran": true, - "ok": true - }, - { - "gate": "replay", - "ran": false, - "ok": true - } - ], - "exitCode": 0, - "summary": "2 gate(s) ran, all clean; 1 field authoring finding(s)", - "help": [ - "1 field authoring finding(s) — see fields.rows[]; each names a reference identity listing a field its object lacks, or a field name declared twice in one children list" - ], - "antiPatterns": { - "status": "ran", - "total": 0, - "rows": [] - }, - "requirements": { - "status": "ran", - "total": 0, - "rows": [] - }, - "overlays": { - "status": "ran", - "total": 0, - "rows": [] - }, - "names": { - "status": "ran", - "total": 0, - "rows": [] - }, - "deprecations": { - "status": "ran", - "total": 0, - "rows": [] - }, - "fields": { - "status": "ran", - "total": 1, - "rows": [ - { - "code": "WARN_REFERENCE_FIELD_NOT_FOUND", - "path": "acme::app::stock::Item.owner_fk", - "severity": "warn", - "source": "lint", - "message": "identity.reference \"owner_fk\" lists \"ownerIdd\" in @fields, but acme::app::stock::Item 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." - } - ] - }, - "notRepresented": [ - "per-gate drift detail (which template variable drifted, which schema change, which generated file differs, which migration failed to replay) — printed as text on stderr; this payload carries each gate's pass/fail verdict", - "the loader's own warnings and the agent-context/manifest advisories — printed as text on stderr" - ] -}