Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
52a1884
feat(codegen): orphan sweep in every port, JVM hash manifest, per-gen…
dmealing Oct 17, 2026
f6ecf59
no-mistakes(review): Add JVM manifest owners and fix nested-dir and v…
dmealing Oct 17, 2026
df84816
no-mistakes(review): Key JVM manifest owners on generator and outputDir
dmealing Oct 17, 2026
0ce0535
no-mistakes(review): Limit JVM shared-path error to differing owned e…
dmealing Oct 17, 2026
4a35aaa
no-mistakes(review): Count profile-declared generators as configured …
dmealing Oct 17, 2026
b7d74b3
no-mistakes(review): Read profile generators via Maven; ports keep JV…
dmealing Oct 17, 2026
4151ce4
feat(metadata): references resolve once, after the tree, in every por…
dmealing Oct 17, 2026
51a9047
no-mistakes(review): Align gen and verify sweeps; union JVM profile o…
dmealing Oct 17, 2026
9725f00
no-mistakes(review): Memoize JVM configured owners and compute them l…
dmealing Oct 17, 2026
60f459d
no-mistakes(review): Qualify per-port manifest generator records in d…
dmealing Oct 17, 2026
923ff8d
no-mistakes(review): Sweep shared JVM generator pairs only in last ex…
dmealing Oct 17, 2026
09150b3
no-mistakes(review): Refuse one generator/outputDir in two JVM execut…
dmealing Oct 17, 2026
9d1cad4
no-mistakes(review): Exclude phase-none executions from JVM duplicate…
dmealing Oct 17, 2026
3312a07
no-mistakes(review): Keep phase-none executions' generators configure…
dmealing Oct 17, 2026
02b6a34
no-mistakes(review): Resolve inherited refs by bound target; fix Pyth…
dmealing Oct 17, 2026
d647ef5
no-mistakes(review): Apply JVM handEdits overwrite verdict over all c…
dmealing Oct 17, 2026
fb73c91
no-mistakes(review): Bind post-load references by bound target across…
dmealing Oct 17, 2026
1dd50cc
no-mistakes(review): Make public payload resolvers exact canonical lo…
dmealing Oct 17, 2026
b4fa1b4
Merge branch 'fm/mo-1-1-0-rc6-fixes' into fm/mo-loader-resolve-once-b…
dmealing Oct 21, 2026
938c6d3
chore(showcase): refresh site payload fixture count (395)
dmealing Oct 21, 2026
2af9d5f
fix(java,kotlin): keep pre-1.1.0 helper signatures so ejected generat…
dmealing Oct 21, 2026
826a65d
no-mistakes(review): apply reviewer inline fixes
dmealing Oct 21, 2026
59f78bc
no-mistakes(document): Fixed stale template-header overwrite claims i…
dmealing Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
- **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.

**Cross-port conformance corpora** (every port runs the shared corpus):
- Metamodel: `fixtures/conformance/` (384 fixtures; 29 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/` (395 fixtures; 29 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 FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). 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. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived.
Expand Down
184 changes: 183 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ metaobjects/
├── CLAUDE.md # project instructions for Claude
├── spec/ # canonical metamodel docs, ADRs, roadmap
├── fixtures/ # 25 cross-language conformance corpora — the oracle
│ ├── conformance/ # metamodel (loader + serializer + navigation), 384 fixtures
│ ├── conformance/ # metamodel (loader + serializer + navigation), 395 fixtures
│ ├── yaml-conformance/ # YAML authoring desugar
│ ├── render-conformance/ # FR-004 byte-identical render oracle
│ ├── verify-conformance/ # FR-004 template-drift gate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ The full hunt for each axis of Phase 1b (Partial / Deep path). Each axis runs in
taxonomy impurity (entity over read-only primary source; read model that should be
`object.projection`; `value` carrying identity/source, ADR-0028); copy-pasted base-field
blocks — or the same-named field re-declared across objects (drift signature 10) — instead of
abstract + `extends`; `@`-prefixed YAML keys (ADR-0006); relative refs
in committed canonical JSON (ADR-0032); DB-type-as-logical-subtype (ADR-0013); per-port
abstract + `extends`; `@`-prefixed YAML keys (ADR-0006); DB-type-as-logical-subtype (ADR-0013); per-port
migration engine where schema is Node-`meta`-owned (ADR-0015).

- [ ] **H2. Wrong native type — `field.<x>` + `@dbColumnType` that hides the real type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ Resolution facts:
extends Auditable`) — each `extends` is a super-*reference*, so resolution walks the
full chain (it does not flatten inherited members onto the child; see ADR-0039).
- **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`);
same-package refs use the bare name.
same-package refs use the bare name. A bare name looks in the declaring file's own
package, then at the root, and never binds an object in another package. `::Name`
names a root-level object even when the package has one of the same name; `..::Name`
names one in the parent package. The forms mean the same in YAML and JSON (ADR-0059),
and a loaded or exported model carries every reference fully qualified.
- An unresolved reference fails with `ERR_UNRESOLVED_SUPER`.

`abstract` and `extends` are **structural keys** (bare, no `@`).
Expand Down
60 changes: 35 additions & 25 deletions agent-context/skills/metaobjects-codegen/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,38 +160,48 @@ You run a `gen` step. The runner:
2. Resolves output targets and precomputes shared render state.
3. Runs each configured **generator** — most emit one file per entity; some emit a
single shared file (a barrel, a DB-context, an app-config).
4. Decides whether it may overwrite a file — and **the rule differs by port**:
- **TypeScript, C#, Python** — by a committed **hash manifest**
(`.metaobjects/.gen-state/.hashes.json`). If a file still hashes to what the
generator recorded writing, it is safe to overwrite; if it was edited, or there is
no record of it, the write is **refused by name**. TypeScript additionally
three-way-merges against a snapshot when one is present locally.
- **Java, Kotlin** — by a bare **`GENERATED`** token in the file's header comment
(`GeneratedFileWriter.GENERATED_MARKER`). The token is `GENERATED`, **not**
`@generated`: the matcher allows only whitespace between the comment punctuation
and the token, so an `@`-prefixed tag does not match it. Remove that token and
regeneration never touches the file again.

**A refusal FAILS the run — exit 1 on every port** (TypeScript always did; Python and
C# warned and exited 0 until 1.0, so a `gen` wired into CI was green while codegen
refused to write). The one-time fix for a project that predates the committed manifest
4. Decides whether it may overwrite a file, by a committed **hash manifest**
(`.metaobjects/.gen-state/.hashes.json`) on every port. If a file still hashes to what
the generator recorded writing, it is safe to overwrite; if it was edited, or there is
no record of it, the write is **refused by name**. TypeScript additionally
three-way-merges against a snapshot when one is present locally. **Commit the
manifest.** Java and Kotlin have used it since 8.1.0: there, a file with no record that
carries the bare **`GENERATED`** header token (not `@generated`) is adopted as generated
output, which is how the first run after upgrading from 8.0.x records everything.

**A refusal FAILS the run — exit 1 on TypeScript, C# and Python** (Python and C# warned
and exited 0 until 1.0, so a `gen` wired into CI was green while codegen refused to
write). On Java and Kotlin a refusal is a warning and `mvn metaobjects:verify` is the
gate that fails. The one-time fix for a project that predates the committed manifest
is **`gen --baseline=adopt`**: it records the files you have as the baseline and writes
nothing, so there is finally a `.hashes.json` to commit — then `gen` again, and the
regeneration lands as its own diff. Adopting DECLARES those files to be generated
output, so an edit already inside one is part of the baseline and that regeneration
replaces it; commit before you run it. (TypeScript additionally has
`--baseline=fresh`: write fresh output now and discard the edits.)

**The two rules protect a hand edit in opposite ways, so do not carry a habit across
ports.** On TypeScript, C# and Python, *editing the content* is what takes ownership —
that is what breaks the hash — and deleting the header changes nothing except your
ability to tell what generated the file. On Java and Kotlin the reverse holds: editing
a file protects it not at all, because the `GENERATED` token is still there and the
next run overwrites the edit; only removing that token does.

(The header text differs per port and none of it is the write decision on the
hash-manifest ports: TypeScript and Python emit `@generated by …`, C# emits
`<auto-generated/>`, Java and Kotlin emit the `GENERATED` token above.)
**Output nobody should edit: set `handEdits: overwrite` on its generator entry.** The
default, `keep`, refuses an edited file. `overwrite` replaces it with fresh output, names
it, and re-baselines it; `verify --codegen` then reports the edit as drift that `gen`
fixes. A file the manifest has no record of is refused either way. TypeScript
`{ ...entityFile(), handEdits: "overwrite" }`; Python `{ name: entity, handEdits: overwrite }`
in `metaobjects.config.yaml`; C# `new EntityGenerator().WithHandEdits(HandEdits.Overwrite)`;
Java and Kotlin `<handEdits>overwrite</handEdits>` on the `<generator>`.

**A full `gen` deletes generated files it no longer emits** (a renamed or deleted object,
a page that moved package). Only files the manifest records, only when untouched, and
never on a run narrowed by `--generators` or an entity filter. An edited one is refused by
name, and `verify --codegen` says to delete it: regeneration cannot follow a rename, so
move the edits into the new file first if you want them. Details:
`docs/features/own-your-codegen.md`, "Files that are no longer generated".

On every port *editing the content* is what takes ownership — that is what breaks the
hash — and deleting the header is just another edit. (Through Maven 8.0.x, Java and
Kotlin did the reverse: an edit was overwritten and only removing the `GENERATED` token
protected a file. Do not carry that habit forward.)

(The header text differs per port and is not the write decision: TypeScript and Python
emit `@generated by …`, C# emits `<auto-generated/>`, Java and Kotlin emit `GENERATED`.)

The output is normal idiomatic code in your language — you import it and use it
like any hand-written module.
Expand Down
9 changes: 5 additions & 4 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor

| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
|---|---|---|---|---|---|---|
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 384 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 395 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 |
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
Expand All @@ -38,7 +38,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 5 shared inputs; `shared-provided-enum` also carries the enum-type decision table every port answers | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 6 shared inputs; `shared-provided-enum` carries the enum-type decision table and `cross-package-binding` the reference-binding decision table (ADR-0059) every port answers | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/template-codegen-conformance/`](../fixtures/template-codegen-conformance/) | 3 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/template-output-render-conformance/`](../fixtures/template-output-render-conformance/) | 5 | ✓ | ✓ | ✓ | ✓ | ✓ |
| [`fixtures/generator-registry-conformance/`](../fixtures/generator-registry-conformance/) | 1 canonical manifest | ✓ | ✓ | ✓ | ✓ | ✓ |
Expand Down Expand Up @@ -267,12 +267,13 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.

## Fixture-to-doc mapping

### `fixtures/conformance/` — metamodel loader + canonical serializer (384)
### `fixtures/conformance/` — metamodel loader + canonical serializer (395)

| Fixture prefix | Feature doc |
|---|---|
| `loader-basic-*`, `error-parse-*` | [features/loaders.md](features/loaders.md) |
| `extends-*`, `error-extends-*` | [features/entities.md](features/entities.md) (inheritance) |
| `ref-*`, `error-ref-*`, `package-default-per-file`, `serialize-multi-package-document` | [features/abstracts-and-inheritance.md](features/abstracts-and-inheritance.md) (how a reference resolves: bare, `::`, `..::`, per-file package; ADR-0059) |
| `identity-*`, `subtype-entity-*`, `subtype-value-*` | [features/entities.md](features/entities.md) (identity / entity vs value) |
| `attr-*`, `error-attr-*`, `error-reserved-word-as-attr` | [features/entities.md](features/entities.md) (attributes) |
| `doc-common-attrs-*` | [features/entities.md](features/entities.md) (documentation common attrs) |
Expand Down Expand Up @@ -541,7 +542,7 @@ files into a real Cube.

## Orphaned fixtures (tested but not yet documented)

The fixtures in the nine corpora mapped above (metamodel 384 + yaml 16 + verify 37
The fixtures in the nine corpora mapped above (metamodel 395 + yaml 16 + verify 37
+ render 16 + persistence 54 + api-contract 91 + source-resolution 25 + scope 10 +
dependency 23) each map to a feature doc, and so do the two requirement corpora and the
`cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining
Expand Down
7 changes: 6 additions & 1 deletion docs/features/abstracts-and-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ This means:
attrs.
- **Cross-package references** use fully-qualified names:
`extends: "shared::auditable"`. Same-package references can use the bare
name.
name: a bare name binds the declaring file's own package, then the root, and
never an object in another package. `::Name` reaches a root-level object even
when the package declares the same name, and `..::Name` names a sibling in the
parent package. These forms mean the same in YAML and canonical JSON
([ADR-0059](../../spec/decisions/ADR-0059-references-resolve-once-after-the-tree.md)).
Exported JSON writes every reference fully qualified.

If `extends:` references a name that doesn't resolve, the loader emits
`ERR_UNRESOLVED_SUPER` with the unresolved reference and the source location.
Expand Down
18 changes: 18 additions & 0 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ one-time fix.
| `--baseline=adopt` | TS · Python · C# | record the files you HAVE as the merge base and **write nothing** |
| `--baseline=fresh` | TS only | overwrite the refused files from fresh output and re-baseline (**discards** hand edits). A local snapshot body for a path the manifest has no record of is ignored, not merged against, so stale content from an earlier checkout is not kept as a hand edit |

`--baseline` answers for one run. To decide once, per generator, set `handEdits` on its
entry instead:

| setting | ports | what it does |
|---|---|---|
| `handEdits: keep` | all | the default: an edited generated file is refused, and so is an edited file no generator emits any more |
| `handEdits: overwrite` | all | replace that generator's edited files with fresh output and re-baseline them; the orphan sweep deletes an edited file no generator emits any more when every generator writing into its directory overwrites. Each discarded file is named. A file the manifest has no record of is still refused |

| | `--baseline=fresh` | `handEdits: overwrite` |
|---|---|---|
| scope | one run, every generator | every run, one generator |
| a file with no manifest record | overwritten | refused |
| an edited orphan | untouched | deleted (when the whole directory overwrites) |
| `verify --codegen` | not affected | reports the edit as drift that `gen` fixes |

Syntax per port, and the orphan rule both follow:
[own-your-codegen](own-your-codegen.md#handedits-keep-or-discard-edits-to-one-generators-files).

**`adopt` exists because the refusal's own remedy was unreachable.** It told you to commit
`.hashes.json` — but nothing writes a manifest until a `gen` succeeds, and a run where
every file refuses writes none, so there was nothing to commit and the next run refused
Expand Down
Loading
Loading