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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,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/` (413 fixtures; 32 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/` (413 fixtures; 34 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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ here.**

### Added

- **Jinja templates in Python and TypeScript, held to the same payload check (ADR-0061, closes #483).**
A `template.prompt` or `template.output` body may be a Jinja file —
`<group>/<source>.jinja`, `.j2` or `.jinja2` — instead of a `.mustache` one. Nothing in the
metadata changes: `@textRef` is the same `group/source`, and the engine comes from the file
(or from an explicit `engine` option on `render`, `verify` and `InMemoryProvider`).
Python renders with jinja2, an optional extra (`pip install "metaobjects[jinja]"`);
TypeScript renders with nunjucks, now a dependency of `@metaobjectsdev/render`. `verify`
(every CLI's template gate) and the render-helper generator check every payload reference a
Jinja body makes, walking the parsed template: paths, loop targets, filter arguments and
literal includes, imports and `extends`. A drift is `ERR_VAR_NOT_ON_PAYLOAD` as before. A
reference the check cannot settle statically is `ERR_TEMPLATE_NOT_STATIC`, and a body that
does not parse is `ERR_TEMPLATE_SYNTAX`. A generated helper over a Jinja body passes the
engine and the payload field tree to `render`, so the check runs again at runtime. Two new
corpora, `fixtures/jinja-render-conformance/` (15) and `fixtures/jinja-verify-conformance/`
(31), hold the two ports to identical output and findings. Java, Kotlin and C# render
Mustache only: their providers and `verify` now refuse a Jinja-only body with
`ERR_TEMPLATE_ENGINE_UNSUPPORTED` instead of reporting the ref as unresolved. Mustache stays
the default, generated Mustache helpers are byte-identical, and `metamodelVersion` does not
move.

- **Prisma as the ORM, declared once in the model (TypeScript, #479).** Set `orm: "prisma"` in
`metaobjects.config.ts` and wire the new `prisma-schema` generator (`prismaSchemaFile()`), and
`meta gen` writes `schema.prisma` from the model for the `postgresql`, `mysql` or `sqlite`
Expand Down
18 changes: 16 additions & 2 deletions agent-context/skills/metaobjects-prompts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ drift at build time instead of letting a prompt silently degrade.
`@textRef` is a 2-layer logical reference `group/source` (folder/file,
table/key, collection/document). The prompt text itself **never lives in
metadata** — at runtime a configured **provider** resolves the reference to the
actual Mustache text:
actual template text (Mustache in every port; Python and TypeScript also accept Jinja, see
below):

- a filesystem provider (L1 = folder, L2 = file) — the dev default:
`lobby/welcome` → `<root>/lobby/welcome.mustache`. It is `FilesystemProvider` in every
Expand All @@ -181,6 +182,18 @@ actual Mustache text:
Locale, A/B, dynamic, and evolutionary prompt variants all live behind the
provider seam without touching metadata.

**Jinja bodies (Python and TypeScript only, ADR-0061).** A body may be a Jinja file —
`lobby/welcome.jinja` (or `.j2` / `.jinja2`) — instead of `welcome.mustache`; the
`@textRef` does not change, and the metadata never names an engine. Python renders it with
jinja2 (the `metaobjects[jinja]` extra), TypeScript with nunjucks. The same `verify` gate
checks it against the payload, walking the parsed template, and reports what it cannot
settle statically (`user[key]`, a computed include name) as `ERR_TEMPLATE_NOT_STATIC`. Java,
Kotlin and C# refuse a Jinja body with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, so a model that
must render in those ports keeps Mustache. Prefer Mustache for new templates: it is the
cross-port engine, and its logic-less shape is what pushes conditionals into the payload
(next sections). Jinja is for adopting existing Jinja templates without rewriting them; per
port reference files say how.

## `render()` is deterministic + byte-stable

Rendering is a pure function: `(payload VO, resolved text) → string`. Same inputs,
Expand Down Expand Up @@ -244,7 +257,8 @@ against each value of the dimension and snapshot every variant.
## `verify` fails the build on prompt-drift

For every template, the verify step resolves the text, parses each `{{...}}`
reference, and checks it exists on the payload VO. If the text references
reference (Mustache, or a Jinja body in Python and TypeScript), and checks it exists on the
payload VO. If the text references
`{{authorName}}` but the payload only has `displayName`, **the build fails.** This
is the prompt-vs-payload drift gate — run it in CI. It walks both `template.prompt`
and `template.output` nodes the same way (both RENDER; only the direction of what comes
Expand Down
6 changes: 6 additions & 0 deletions agent-context/skills/metaobjects-prompts/references/csharp.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,9 @@ template's tokens against the payload field tree — each `{{...}}` that doesn't
yields a `VerifyError(Code, Path)` (empty list = no drift). Assert it is empty in an
xUnit test to fail the build on prompt/payload drift; `dotnet meta verify` additionally
catches a stale committed parser.

**Mustache only (ADR-0061).** Jinja bodies are a Python and TypeScript engine. A ref whose
only body is a `.jinja` / `.j2` / `.jinja2` file is refused here with
`ERR_TEMPLATE_ENGINE_UNSUPPORTED` (by the filesystem provider, and as a drift line by
`verify`), rather than reported as missing. Keep templates the C# port renders in
Mustache.
6 changes: 6 additions & 0 deletions agent-context/skills/metaobjects-prompts/references/java.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,9 @@ JUnit test in the Maven `test` phase to fail the build on prompt/payload drift.
`metaobjects:verify` codegen-drift goal additionally catches a stale committed
parser. The emitted parser imports Jackson (`com.fasterxml.jackson`), normally
already on a Spring Boot classpath.

**Mustache only (ADR-0061).** Jinja bodies are a Python and TypeScript engine. A ref whose
only body is a `.jinja` / `.j2` / `.jinja2` file is refused here with
`ERR_TEMPLATE_ENGINE_UNSUPPORTED` (by the filesystem provider, and as a drift line by
`verify`), rather than reported as missing. Keep templates the JVM ports render in
Mustache.
6 changes: 6 additions & 0 deletions agent-context/skills/metaobjects-prompts/references/kotlin.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,9 @@ resolve against the payload — empty list = no drift. Assert it is empty in a J
test in the Maven `test` phase to fail the build on prompt/payload drift. The
`metaobjects:verify` codegen-drift goal additionally catches a stale committed
parser.

**Mustache only (ADR-0061).** Jinja bodies are a Python and TypeScript engine. A ref whose
only body is a `.jinja` / `.j2` / `.jinja2` file is refused here with
`ERR_TEMPLATE_ENGINE_UNSUPPORTED` (by the filesystem provider, and as a drift line by
`verify`), rather than reported as missing. Keep templates the JVM ports render in
Mustache.
26 changes: 25 additions & 1 deletion agent-context/skills/metaobjects-prompts/references/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ silently drift.
- Recommended LLM caller (bring-your-own)
- Consumer dependency
- Drift gate
- Jinja templates

## Wire the generators

Expand Down Expand Up @@ -188,4 +189,27 @@ required_slots=None, required_tags=None) -> list[VerifyError]` walks a Mustache
template's tokens against the payload field tree — each `{{...}}` reference that
doesn't resolve yields a `VerifyError` (empty list = no drift). Assert it is empty in a
pytest test to fail the build on prompt/payload drift; `metaobjects verify`
additionally catches a stale committed parser.
additionally catches a stale committed parser. Pass `engine="jinja"` to check a Jinja body.

## Jinja templates

Existing Jinja prompt templates adopt the gate as they are (ADR-0061). Install the extra,
`pip install "metaobjects[jinja]"`, and keep the file as `<group>/<source>.jinja` (or `.j2`,
`.jinja2`) under the template root; `@textRef` stays `group/source`. `FilesystemProvider`
reads `.mustache` first, then the Jinja extensions, and `engine_of(ref)` reports which one it
found; `render` and `metaobjects verify` pick the engine from it. For an
`InMemoryProvider`, pass `InMemoryProvider(templates, engine="jinja")`, or pass `engine="jinja"`
to `RenderRequest` / `verify` directly.

- `verify` walks the parsed jinja2 template: paths per segment, `for` targets bound to element
fields, `set` / macro parameters as locals, filter arguments, and literal `include` /
`import` / `extends` through the provider. `ERR_TEMPLATE_NOT_STATIC` marks what it cannot
settle; `ERR_TEMPLATE_SYNTAX` a body that does not parse.
- The generated render helper over a Jinja body passes `engine="jinja"` and the payload field
tree (`verify=[PayloadField(...)]`) to `render`, so a body that drifted after the build is
refused at runtime with `RenderError("render verify failed: ERR_… (path)")`.
- A Pydantic payload renders as its JSON-mode dump, as with Mustache. Printed values follow
the Mustache renderer: `true` / `false`, a whole float as an integer, `None` as empty.
- To render the same in TypeScript (nunjucks), stay inside the shared vocabulary in
`fixtures/jinja-render-conformance/`: test a list with `| length`, avoid `{% with %}`,
Python methods and printing whole lists or dicts.
12 changes: 12 additions & 0 deletions agent-context/skills/metaobjects-prompts/references/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,18 @@ error in every port. The emitted parser imports `zod`;
it's usually already a dependency (Drizzle / `runtime-ts` lean on it), else
`npm i zod`.

**Jinja bodies (ADR-0061).** A body may be `<group>/<source>.jinja` (or `.j2`, `.jinja2`)
instead of `.mustache`; `@textRef` does not change. `@metaobjectsdev/render` renders it with
nunjucks (a dependency of the package, nothing to install) and `meta verify` checks it the same
way, walking the parsed template; it names the `.jinja` file and line on a drift.
`FilesystemProvider.engineOf(ref)` reports the engine of the file it found; for an
`InMemoryProvider` pass `new InMemoryProvider(map, { engine: "jinja" })`, or pass
`engine: "jinja"` to `render` / `verify`. A generated `render<Name>()` helper over a Jinja body
passes `engine: "jinja"`, so it renders as Jinja through any runtime provider, and the
runtime check runs on it as on a Mustache body. What the check cannot settle statically is
`ERR_TEMPLATE_NOT_STATIC`. nunjucks treats an empty list as true (jinja2 does not), so test a
list with `{% if items | length %}` when the same template must render in Python.

## See which fields a template consumes

List `docsFile()` in `generators` and run `meta gen` to emit the model surface to the
Expand Down
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading