diff --git a/AGENTS.md b/AGENTS.md index 9ea8ce48bd..f66787ae7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd8bbd3c9..dfeb4696eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — + `/.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` diff --git a/agent-context/skills/metaobjects-prompts/SKILL.md b/agent-context/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/agent-context/skills/metaobjects-prompts/SKILL.md +++ b/agent-context/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/agent-context/skills/metaobjects-prompts/references/csharp.md b/agent-context/skills/metaobjects-prompts/references/csharp.md index c0a2b8b76c..046ee3eb63 100644 --- a/agent-context/skills/metaobjects-prompts/references/csharp.md +++ b/agent-context/skills/metaobjects-prompts/references/csharp.md @@ -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. diff --git a/agent-context/skills/metaobjects-prompts/references/java.md b/agent-context/skills/metaobjects-prompts/references/java.md index 81612534a8..3f7feee4a1 100644 --- a/agent-context/skills/metaobjects-prompts/references/java.md +++ b/agent-context/skills/metaobjects-prompts/references/java.md @@ -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. diff --git a/agent-context/skills/metaobjects-prompts/references/kotlin.md b/agent-context/skills/metaobjects-prompts/references/kotlin.md index 45663093f4..4b166e7339 100644 --- a/agent-context/skills/metaobjects-prompts/references/kotlin.md +++ b/agent-context/skills/metaobjects-prompts/references/kotlin.md @@ -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. diff --git a/agent-context/skills/metaobjects-prompts/references/python.md b/agent-context/skills/metaobjects-prompts/references/python.md index e3e24ffecf..0a5271c633 100644 --- a/agent-context/skills/metaobjects-prompts/references/python.md +++ b/agent-context/skills/metaobjects-prompts/references/python.md @@ -18,6 +18,7 @@ silently drift. - Recommended LLM caller (bring-your-own) - Consumer dependency - Drift gate +- Jinja templates ## Wire the generators @@ -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 `/.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. diff --git a/agent-context/skills/metaobjects-prompts/references/typescript.md b/agent-context/skills/metaobjects-prompts/references/typescript.md index 89de514c21..92b5d47980 100644 --- a/agent-context/skills/metaobjects-prompts/references/typescript.md +++ b/agent-context/skills/metaobjects-prompts/references/typescript.md @@ -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 `/.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()` 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 diff --git a/bun.lock b/bun.lock index 090774d456..1fa3f2c880 100644 --- a/bun.lock +++ b/bun.lock @@ -369,9 +369,11 @@ "version": "1.1.4", "dependencies": { "mustache": "^4.2.0", + "nunjucks": "^3.2.4", }, "devDependencies": { "@types/mustache": "^4.2.5", + "@types/nunjucks": "^3.2.6", "bun-types": "latest", "typescript": "^5.6.0", }, @@ -905,6 +907,8 @@ "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], + "@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="], + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="], @@ -935,6 +939,8 @@ "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], @@ -961,6 +967,8 @@ "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -1397,6 +1405,8 @@ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1811,6 +1821,8 @@ "nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "nunjucks/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 76313a8017..87d16fdec3 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **32 shared conformance corpora** under +The MetaObjects standard ships **34 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 @@ -31,6 +31,8 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 37 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | | [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 16 | ✓ | ✓ | inherits via Java | ✓ | ✓ | +| [`fixtures/jinja-render-conformance/`](../fixtures/jinja-render-conformance/) (ADR-0061 — Jinja bodies render the same in jinja2 and nunjucks) | 15 | ✓ (nunjucks) | refuses Jinja (`ERR_TEMPLATE_ENGINE_UNSUPPORTED`) | refuses, via Java | refuses Jinja (`ERR_TEMPLATE_ENGINE_UNSUPPORTED`) | ✓ (jinja2) | +| [`fixtures/jinja-verify-conformance/`](../fixtures/jinja-verify-conformance/) (ADR-0061 — the payload check over a Jinja body) | 31 | ✓ | refuses Jinja | refuses, via Java | refuses Jinja | ✓ | | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | @@ -78,6 +80,8 @@ the corpora above do two different jobs. Only the first is a promise to adopters `object-model-conformance/`, `render-conformance/`, `template-codegen-conformance/` (the Mustache engine), `template-output-render-conformance/`, `output-prompt-conformance/`, `extract-conformance/`, `verify-conformance/`, `verify-strict-conformance/`, + `jinja-render-conformance/` and `jinja-verify-conformance/` (the Jinja engine and its + payload check, Python and TypeScript only, ADR-0061), `persistence-conformance/` (runtime reads and writes, and the TS-owned migration scenarios), `agent-context-conformance/`, `metamodel-docs/`, `fmt-conformance/` (the canonical serializer, surfaced per-file — ADR-0034's "canonical format" is core), @@ -324,6 +328,17 @@ lookup, parent-context fallthrough, falsy/empty-array section behavior, inverted sections, nested partials, standalone-tag whitespace stripping, raw-HTML bypass, trailing-newline preservation, unicode multibyte handling, and the `-last` list-separator accessor. +### `fixtures/jinja-render-conformance/` (15) and `fixtures/jinja-verify-conformance/` (31) + +Both → [features/templates-and-payloads.md](features/templates-and-payloads.md) (Jinja +templates section, [ADR-0061](../spec/decisions/ADR-0061-per-port-template-engines.md)). Run by +Python (jinja2) and TypeScript (nunjucks) only; Java, Kotlin and C# refuse a Jinja body with +`ERR_TEMPLATE_ENGINE_UNSUPPORTED` instead. The render corpus holds 13 output cases and 2 error +cases (an include cycle and an unresolved include), each with the same Mustache-path message. +The verify corpus reuses `verify-conformance/`'s `payload.json` field-tree shape; an +`ERR_TEMPLATE_SYNTAX` finding's path is the parser's own message, so runners compare it as +empty. + ### `fixtures/verify-conformance/` (37) All 37 fixtures → [features/migrations-and-drift.md](features/migrations-and-drift.md) diff --git a/docs/features/cli.md b/docs/features/cli.md index aded5baa1e..05a70e7a54 100644 --- a/docs/features/cli.md +++ b/docs/features/cli.md @@ -53,7 +53,7 @@ vocabulary everywhere, each port implementing the modes it supports. |---|---|---| | `verify --db` | **Schema drift** — does the live database (or snapshot) match the metadata? (migrate engine, ADR-0015) | yes | | `verify --codegen` | **Codegen drift** — regenerate from metadata into a temp dir and fail if it differs from the committed generated output. Catches "metadata changed but `meta gen` wasn't re-run". A hand-edited generated file is NOT drift — `meta gen` three-way-merges hand edits by design, so the gate compares the *generated contribution* against the committed `.gen-state/.hashes.json` rather than the file byte-for-byte. It still LISTS every generated file carrying a hand edit (count + paths, and how to see the edit) as a notice that does not change the exit code; `--forbid-hand-edits` makes any hand edit fail the build, for teams that want generated code untouchable (Node `meta` only). | no | -| `verify --templates` | **Template/prompt drift** — `Renderer.verify` checks each `template.*` node's `{{field}}` references against its payload VO (FR-004). | no | +| `verify --templates` | **Template/prompt drift** — `Renderer.verify` checks each `template.*` node's `{{field}}` references against its payload VO (FR-004). A Jinja body (Python and TypeScript, ADR-0061) is checked the same way. | no | | `verify --docs` | **Removed in 1.1.0** (ADR-0034 Amendment 9). It prints where the check went and exits 2: documentation is written by entries in `generators`, so `verify --codegen` checks it with the rest of the declared output. | — | | `verify --deps` | **Dependency drift** (FR-023) — re-resolves each declared dependency's `path` and compares its installed artifact's hash against `.metaobjects/deps.lock.json`, same comparison as `meta deps check`. Needs the publisher's `path` reachable, so it is never part of the bare-`verify` default. A `drifted` or `unresolved` dependency fails with `ERR_DEPENDENCY_UPSTREAM_DRIFT`. **Node `meta` only.** See [`metadata-dependencies.md`](metadata-dependencies.md). | no | diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index c3f6f03b97..a225b77afa 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -21,8 +21,10 @@ This buys four guarantees: The vocabulary is `template.*` (the renderable unit) over a declared payload shape — an `object.value`, or (since #210) a **sourceless** `object.projection`. -Mustache is the chosen template engine — it has the only published -cross-language spec + conformance suite. +Mustache is the default template engine in every port: it has the only published +cross-language spec + conformance suite. Python and TypeScript also render **Jinja** +bodies, chosen by the file's extension and held to the same payload check (see +[Jinja templates](#jinja-templates-python-and-typescript), ADR-0061). ## Two template subtypes — the axis is DIRECTION @@ -180,6 +182,61 @@ A consumer can ship their own provider (RDB / Neo4j / Qdrant) — the engine tak the `Provider` interface and delegates. Locale, A/B, dynamic, and evolutionary prompts all live behind the provider seam without touching metadata. +## Jinja templates (Python and TypeScript) + +A template body may be a Jinja file instead of a Mustache one: `prompts/lobby/welcome.jinja` +(or `.j2`, or `.jinja2`) beside, or instead of, `welcome.mustache`. Nothing in the metadata +changes: `@textRef` is still `lobby/welcome`, and the engine comes from the file +([ADR-0061](../../spec/decisions/ADR-0061-per-port-template-engines.md)). Python renders it +with jinja2 (`pip install "metaobjects[jinja]"`), TypeScript with nunjucks, which +`@metaobjectsdev/render` depends on. + +- **Lookup.** `FilesystemProvider` reads `.mustache` first, then `.jinja`, `.j2` and + `.jinja2`, and reports which engine the file it found is for. Given one explicit + extension, it reads only that one. An in-memory provider takes the engine as an option + (`new InMemoryProvider(map, { engine: "jinja" })`, `InMemoryProvider(map, engine="jinja")`), + and `render` and `verify` both accept an explicit `engine`. +- **The same check.** `verify`, and the render-helper generator at codegen time, check every + payload reference a Jinja body makes against the payload value object. They walk the + parsed template: `{{ user.name }}` is checked per segment, `{% for p in posts %}` binds `p` + to the element's fields, `set` / macro parameters / loop targets are locals, filter + arguments are checked (and so is an element attribute a filter names, as in + `map(attribute="title")`), and a literal `include` / `import` / `extends` is followed + through the provider. A drifted reference is `ERR_VAR_NOT_ON_PAYLOAD`, as for Mustache. +- **Nothing passes unchecked.** A reference the check cannot settle without running the + template (`user[key]`, `{% include name %}`) is `ERR_TEMPLATE_NOT_STATIC`. A body that does + not parse is `ERR_TEMPLATE_SYNTAX`. +- **At runtime too.** A generated render helper over a Jinja body passes the engine and the + payload field tree to `render`, so a body that drifted after the build is refused when it + is rendered. +- **Escaping.** `@format` drives escaping exactly as for Mustache: every `{{ … }}` value goes + through that format's escaper, and `| safe` bypasses it. What a macro, a `call` block or a + block `set` renders is already escaped, and printing it does not escape it again. +- **One engine per include chain.** A Mustache body includes Mustache partials, and a Jinja + body includes Jinja templates. A partial whose file is the other engine's is refused with + `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, in `verify` and at render time, rather than spliced in + as text the including engine would misread. +- **Java, Kotlin and C# do not render Jinja.** Their providers refuse a Jinja-only body with + `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, and their `verify` reports it on that template, so a + model that needs those ports keeps its bodies in Mustache. + +```jinja +{# prompts/lobby/welcome.jinja #} +Hi {{ displayName | title }}, you have {{ postCount }} posts. +{% for p in posts %}- {{ p.title }} +{% endfor %} +``` + +Python and TypeScript render the shared Jinja vocabulary identically +(`fixtures/jinja-render-conformance/`). Outside it the two engines differ: an empty list is +false in jinja2 and true in nunjucks (write `{% if items | length %}`), a printed list or dict +is engine-specific text, and Python-only syntax (`{% with %}`, `name.upper()`, `True`) does +not run in nunjucks. Looping over a null value renders nothing in nunjucks and fails the +render in jinja2 (guard it with `{% if items %}`). A string filter such as `upper` over +already-escaped text (a `{% filter upper %}` block in an html render) keeps it escaped once in +jinja2 and escapes it again in nunjucks. The Mustache-only accessors (`has`, `-first`, `-last`) are not +added for a Jinja body; use `{% if field %}` and `loop.first` / `loop.last`. + ## The rendered output For the `lobby/welcome` template: @@ -370,7 +427,8 @@ string output = Renderer.Render(new RenderRequest ### Python -`metaobjects.render` ships the Mustache engine + `Verify`. The Python loader +`metaobjects.render` ships the Mustache engine, Jinja through the `jinja` extra +([Jinja templates](#jinja-templates-python-and-typescript)), and `Verify`. The Python loader recognizes `template.*` + `origin.*`. The payload is the value object's own Pydantic `BaseModel`, emitted by the `entity` generator as `.py` (ADR-0056), so a consumer can render from that model or from a plain `dict` — the engine reads a model through its @@ -652,7 +710,9 @@ leaves a trace in the report that your own code would otherwise have to reconstr For every template, `verify` resolves the text, parses the `{{...}}` references, and checks each one exists on the payload VO. If a template references -`{{authorName}}` but the payload only has `displayName`, the build fails. +`{{authorName}}` but the payload only has `displayName`, the build fails. A Jinja +body (Python and TypeScript) is checked the same way; see +[Jinja templates](#jinja-templates-python-and-typescript). | Port | Command | |---|---| @@ -727,6 +787,11 @@ Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these via their respective conformance runners. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md) for the per-port pass/skip ledger. +**Jinja bodies (Python and TypeScript, ADR-0061)** + +- [`fixtures/jinja-render-conformance/`](../../fixtures/jinja-render-conformance/) — 15 cases: variables, loops, conditionals, `set` and macros, `include` and `import`, filters, html and json escaping (with and without `| escape`), whitespace control, `raw` and comments, plus an include cycle and an unresolved include. Byte-identical output from jinja2 and nunjucks. +- [`fixtures/jinja-verify-conformance/`](../../fixtures/jinja-verify-conformance/) — 31 cases: the payload check over the parsed template (paths, loop targets, locals, filters, includes, imports, `extends` blocks, `ERR_TEMPLATE_NOT_STATIC`, `ERR_TEMPLATE_SYNTAX`, required slots and tags). + ## See also - [entities.md](entities.md) — `object.value` / sourceless `object.projection` are the payload host types (#210) diff --git a/docs/llms/llms-full.txt b/docs/llms/llms-full.txt index 62ade34162..5a43b8f76e 100644 --- a/docs/llms/llms-full.txt +++ b/docs/llms/llms-full.txt @@ -82,7 +82,7 @@ Runtime use cases include CRUD scaffolding, validation rules, relationship trave Catch divergence between code and metadata. `verify` is one verb with three explicit subverbs (ADR-0021): - `verify --codegen` — regenerate from metadata into a temp dir and fail if it differs from the committed generated output (catches "metadata changed but codegen wasn't re-run" and hand-edited generated files). -- `verify --templates` — check each `template.*` node's `{{field}}` references against its payload VO (prompt-to-payload drift). +- `verify --templates` — check each `template.*` node's `{{field}}` references (Mustache, or Jinja in Python and TypeScript) against its payload VO (prompt-to-payload drift). - `verify --db` — live-DB schema drift; Node `meta` only (it is part of the schema toolchain, ADR-0015). Combinations aggregate (exit non-zero if any selected mode reports drift). Bare `verify` runs the port's documented back-compat default and advertises the explicit subverbs. @@ -91,7 +91,7 @@ Combinations aggregate (exit non-zero if any selected mode reports drift). Bare The prompt is code too — but most teams still ship it as a string assembled imperatively and scattered across services, with no declared shape, no tests, and no visibility into token cost. This pillar makes a prompt a declared, deterministic, testable artifact. -A prompt's input payload is declared as a typed projection — the same machinery used for DB views — so the data a prompt consumes has one declared shape and unused fields (payload bloat, token cost) show up as a diff. The prompt text is never inline: it is external and resolved by a runtime-configured provider (filesystem, RDB, or NoSQL), addressed by a backend- and locale-agnostic logical reference, so a rule block triplicated across call sites lives once. A logic-less Mustache engine renders `(payload + provider-resolved text) → final prompt string` deterministically: the same inputs always produce the same string, which makes prompts snapshot-testable in CI and keeps them byte-stable so a stray whitespace or key-order change can't silently break exact-prefix prompt caching (where the savings run up to ~90% of input cost). A build-time `verify --templates` step proves every prompt variable resolves to a real field on its payload, so a renamed field three services away breaks the build instead of quietly degrading output. +A prompt's input payload is declared as a typed projection — the same machinery used for DB views — so the data a prompt consumes has one declared shape and unused fields (payload bloat, token cost) show up as a diff. The prompt text is never inline: it is external and resolved by a runtime-configured provider (filesystem, RDB, or NoSQL), addressed by a backend- and locale-agnostic logical reference, so a rule block triplicated across call sites lives once. A logic-less Mustache engine renders `(payload + provider-resolved text) → final prompt string` deterministically: the same inputs always produce the same string, which makes prompts snapshot-testable in CI and keeps them byte-stable so a stray whitespace or key-order change can't silently break exact-prefix prompt caching (where the savings run up to ~90% of input cost). A build-time `verify --templates` step proves every prompt variable resolves to a real field on its payload, so a renamed field three services away breaks the build instead of quietly degrading output. Python and TypeScript also render Jinja bodies: a `.jinja`, `.j2` or `.jinja2` file in place of the `.mustache` one, checked against the same payload by the same step (ADR-0061). Java, Kotlin and C# render Mustache only and refuse a Jinja body with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`. Because the render is conformance-gated, the determinism guarantee holds in every language port — which is also what lets an eval harness (often Python) render exactly what a production service (often not) ships. The library-side building blocks all ship in all five ports today: render, payload-VO codegen, `verify`, `template.output` parser-on-receipt (FR-006), the output-format prompt fragment, and the tolerant `extract` parser (FR-010/FR-011). The one remaining library-side piece is MCP exposure of declared prompts/tools (see the roadmap). diff --git a/docs/llms/llms.txt b/docs/llms/llms.txt index c40809af78..3b15ef4701 100644 --- a/docs/llms/llms.txt +++ b/docs/llms/llms.txt @@ -53,7 +53,7 @@ MetaObjects pillars are capabilities of the same metadata spine, not separate pr - **Codegen** — reference generators you own (ejectable in every port) that emit per-language code from a single metadata model. Drizzle + Zod + Fastify (TypeScript), Spring REST + DTO + repository interfaces (the persistence impl is consumer-supplied) (Java via `codegen-spring`), KotlinPoet + Exposed + Spring (Kotlin via `codegen-kotlin`), EF Core + ASP.NET (C#), Pydantic + FastAPI (Python). Hand-edit-preserving regeneration via three-way merge is TypeScript-only; the other ports refuse an edited generated file by name and let you eject the generator to own it. Keep your ORM: contract-only output (TypeScript `runtime: false`: Zod and types; Java and Kotlin: records and data classes; C#: sourceless POCOs; Python: Pydantic) leaves your JPA or EF Core models untouched. Prisma on TypeScript goes further: `orm: "prisma"` generates `schema.prisma` from the model (Postgres, MySQL, SQLite), and `verify --codegen` and `verify --db` fail when Prisma and the model drift (`docs/recipes/prisma.md`). Includes M:N relationship codegen (FR-018) in all five ports — entity navigation, idiomatic ORM wiring, and REST traversal (`GET //{id}/`). - **Runtime metadata** — load metadata at runtime and drive behavior dynamically: CRUD, validation, relationships, dynamic admin UIs. (Typed tool payloads are declared today; MCP exposure of tools is on the roadmap.) Kysely (TS), a DB-API 2 driver (pg8000 / psycopg) via ObjectManager (Python), modernized JDBC + Spring-tx via OMDB (Java), Exposed (Kotlin); C# runtime metadata is on the roadmap (its EF Core output is generated code, not a metadata-driven runtime). Runtime queries return native in-process types (ADR-0019); wire canonicalization happens only at the serialization boundary. - **Drift detection** — catch divergence between generated code and metadata before it ships. `verify` is one verb with explicit subverbs (ADR-0021): `verify --codegen` (regen-and-diff against committed output), `verify --templates` (prompt `{{field}}` ↔ payload-VO drift), and `verify --db` (live-DB schema drift, Node `meta` only). Surfaces drift as build-time breakage rather than a production incident. -- **Prompt construction** — treat LLM prompts as governed metadata instead of strings scattered across services. A typed payload declared as a projection (so payload bloat and token cost are a diff, not a mystery), external provider-resolved prompt text, and a logic-less Mustache engine that renders deterministically: snapshot-testable in CI, byte-stable so an exact-prefix prompt-cache hit doesn't break on a stray whitespace, and drift-checked at build time so a renamed field can't silently degrade a prompt. Conformance-gated, so the guarantee holds in every language port. Render + payload-VO codegen + `verify` + `template.output` parser-on-receipt (FR-006) + the output-format prompt fragment & tolerant `extract` parser (FR-010/FR-011) ship in all five ports today. +- **Prompt construction** — treat LLM prompts as governed metadata instead of strings scattered across services. A typed payload declared as a projection (so payload bloat and token cost are a diff, not a mystery), external provider-resolved prompt text, and a logic-less Mustache engine (with Jinja as a second engine in Python and TypeScript, chosen by a `.jinja`/`.j2` file and held to the same payload check) that renders deterministically: snapshot-testable in CI, byte-stable so an exact-prefix prompt-cache hit doesn't break on a stray whitespace, and drift-checked at build time so a renamed field can't silently degrade a prompt. Conformance-gated, so the guarantee holds in every language port. Render + payload-VO codegen + `verify` + `template.output` parser-on-receipt (FR-006) + the output-format prompt fragment & tolerant `extract` parser (FR-010/FR-011) ship in all five ports today. - **Requirements and testing** — declare what the software is supposed to *do* in the same model as the entities, so a capability claim is checkable instead of prose. `requirement.functional` / `requirement.architectural` are registered vocabulary in all five ports, with the loader enforcing the closed `@status` enum, and `@implementedBy` is **resolved, not trusted**: it names a real member of the real model, so a claim whose implementation was renamed or deleted fails the build rather than going quietly stale. `meta verify` reports the ledger on every run — unresolved links, entities no claim covers, gaps recorded versus gaps nobody has ruled on — and lints the authoring; the documentation generators render it for humans and for agents (`requirementsFile()`, `agentDocsFile()` and their counterparts in the other ports). A live claim naming nothing is a warning; a green run proves the references resolve, never that an implementation is correct. **Every port ships a `requirement-tests` generator**, which scaffolds a test per claim carrying the statement and counterexample in (a stub you fill in for TypeScript, a test that calls a witness you own in the other four); the vocabulary and the `verify` checks are in every port too, and only the authoring lint is TypeScript-only. A project that declares no `requirement.*` nodes sees no change at all. - **Libraries** — reusable declared design you opt into by name (`"libraries": ["iam"]` in `.metaobjects/config.json`): entities, the requirements they promise and the generators they imply, as one drift-gated artifact. A library is layered and its core layer is inert: it declares no source, so it adds no tables until you also opt into its `/db` layer (`["iam", "iam/db"]`). `meta eject ` copies one into your repo to own. `ai` (the LLM-call trace envelope) is `stable`; `iam` (users, nestable groups, roles, grants) is `preview`. diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index b21923cdd9..bb5fe63429 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -790,7 +790,7 @@ generated code), and no `migrate` surface (schema is TypeScript-owned). | Relationships + FK | Yes (EF Core + Postgres FK clause) | | Source kinds (table / view / storedProc) | `table` + `view` fully shipped; `storedProc` / `tableFunction` / `materializedView` partial | | `field.currency` / `field.enum` / `field.object` + `@storage` | Yes (incl. EF Core `OwnsOne` for `flattened`; `OwnsMany(...).ToJson(...)` for `@isArray` array-of-VO jsonb) | -| Templates + render (FR-004) | Yes (`MetaObjects.Render`) | +| Templates + render (FR-004) | Yes (`MetaObjects.Render`; Mustache only — a Jinja body is refused with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, ADR-0061) | | Output parser codegen (FR-006) | Yes (`OutputParserGenerator` — `Parse`/`TryParse` BCL pattern) | | Payload-VO codegen | Yes — the payload IS the value object's own POCO from `EntityGenerator` (ADR-0056); no separate payload generator | | Declarative template-codegen | Yes — a `TemplateSpecGenerator` entry in `codegen/Generators.cs` (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with Python) | diff --git a/docs/ports/java.md b/docs/ports/java.md index b7ccb63af8..414d030a94 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -903,7 +903,7 @@ Node `meta` CLI. | Relationships + FK | Yes (via OMDB) | | Source kinds (table / view / storedProc) | Yes | | `field.currency` / `field.enum` / `field.object` + `@storage` | Yes | -| Templates + render (FR-004) | Yes (`metaobjects-render`) | +| Templates + render (FR-004) | Yes (`metaobjects-render`; Mustache only — a Jinja body is refused with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, ADR-0061) | | Payload-VO codegen | Yes — the payload IS the value object's own `record`, from `SpringValueObjectGenerator` (ADR-0056); no separate payload generator | | Output parser codegen (FR-006) | Yes — `SpringOutputParserGenerator` (in `metaobjects-codegen-spring`) — see usage below | | Migrations | TS-only (`@metaobjectsdev/cli migrate`) — the Java migration engine and the OMDB runtime auto-create path were both removed (ADR-0015); apply the TS-produced DDL to the database | diff --git a/docs/ports/kotlin.md b/docs/ports/kotlin.md index 668c7a468d..217ece0b44 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -877,7 +877,7 @@ the contract is universal. | Source kinds (table / view / storedProc) | Yes — storedProc has its own generator | | REST controllers (Spring `@RestController`) | Yes — `KotlinSpringControllerGenerator` per writable entity; cross-port API contract | | `field.currency` / `field.enum` / `field.object` + `@storage` | Yes (incl. `flattened` per-sub-field columns) | -| Templates + render (FR-004) | Yes (wraps the Java engine) | +| Templates + render (FR-004) | Yes (wraps the Java engine; Mustache only — a Jinja body is refused with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`, ADR-0061) | | Output parser codegen (FR-006) | Yes (`KotlinOutputParserGenerator` — Jackson + `Result` dual API) | | Payload-VO codegen | Yes — the payload IS the value object's own data class, from `KotlinEntityGenerator` (ADR-0056); no separate payload generator | | Migrations | Via the TS toolchain (`@metaobjectsdev/cli migrate`) | diff --git a/docs/ports/python.md b/docs/ports/python.md index 391f24da29..cd392e1d8b 100644 --- a/docs/ports/python.md +++ b/docs/ports/python.md @@ -708,6 +708,31 @@ out = render(RenderRequest( `@payloadRef`. The Python renderer is conformance-gated to render byte-identical output against the shared `fixtures/render-conformance/` corpus. +### Jinja templates + +A body may also be a Jinja file (ADR-0061): `prompts/lobby/welcome.jinja` (or `.j2`, +`.jinja2`) in place of `welcome.mustache`, with the same `ref`. Install the extra, +`pip install "metaobjects[jinja]"`; jinja2 is imported only when a Jinja body is rendered +or checked. `FilesystemProvider` reads `.mustache` first, then the Jinja extensions, and +`render` and `metaobjects verify` take the engine from the file it found +(`engine_of(ref)`). An explicit `RenderRequest(engine="jinja")`, `verify(..., engine="jinja")` +or `InMemoryProvider(templates, engine="jinja")` names it directly. + +```jinja +Welcome back, {{ displayName }}. You have written {{ postCount }} posts. +{% for p in posts %}- {{ p.title }} +{% endfor %} +``` + +`metaobjects verify` and the render-helper generator check a Jinja body against the payload +exactly as they check Mustache, walking the parsed template; `ERR_TEMPLATE_NOT_STATIC` marks +a reference they cannot settle without running it, and `ERR_TEMPLATE_SYNTAX` a body that does +not parse. A render helper over a Jinja body also passes the payload field tree to `render`, +so the check runs again at runtime. The output matches the TypeScript port (nunjucks) on the +shared `fixtures/jinja-render-conformance/` corpus; the findings match on +`fixtures/jinja-verify-conformance/`. The engine differences outside that corpus are listed in +`docs/features/templates-and-payloads.md`. + ## FR-006 — output parsing A template declares no payload type of its own (ADR-0056). Its request and response @@ -881,7 +906,7 @@ import lines are stable. | Relationships + FK | Loader-level yes; relationship-navigation codegen is on the roadmap | | Source kinds (table / view / storedProc) | Loader-level yes; codegen for non-`table` kinds is in progress | | `field.currency` / `field.enum` / `field.object` + `@storage` | Loader-level yes; codegen for `field.object` `flattened` storage is in progress | -| Templates + render (FR-004) | Yes (`metaobjects.render`) | +| Templates + render (FR-004) | Yes (`metaobjects.render`; Mustache, and Jinja with the `jinja` extra) | | Payload-VO codegen | Yes — the `entity` generator's model for each value object IS the payload type (ADR-0056); no separate payload generator | | Output parser codegen (FR-006) | Yes (`output_parser_generator` — Pydantic throw-only; imports the `@responseRef` value object's model) | | Declarative template-codegen | Yes — a target's `templateSpec` in `metaobjects.config.yaml` (scope perEntity/perPackage/perModel + outputPattern; the cross-port JSON contract shared with C#) | diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index 50ca13f601..5b08c688d4 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -770,6 +770,24 @@ Hono idioms rather than contract drift: Hono has no prefix-wrapping primitive at the verb level, and string concatenation produces the same URL grammar. +## Jinja templates + +`@metaobjectsdev/render` renders Mustache by default and Jinja through nunjucks (ADR-0061), +which the package depends on. A body is Jinja when its file is `/.jinja` (or +`.j2`, `.jinja2`); `@textRef` does not change. `FilesystemProvider` reads `.mustache` first, +then the Jinja extensions, and `engineOf(ref)` reports the engine of the file it found. For +an `InMemoryProvider`, pass `{ engine: "jinja" }` to its constructor, or pass +`engine: "jinja"` to `render` and `verify`. + +`meta verify --templates` and the `renderHelper()` generator check a Jinja +body against the payload as they check Mustache, walking the parsed template, and the drift +line names the `.jinja` file and line. `ERR_TEMPLATE_NOT_STATIC` marks a reference they +cannot settle without running the template; `ERR_TEMPLATE_SYNTAX` a body that does not parse. +A generated helper over a Jinja body passes `engine: "jinja"` to `render`, so it renders as +Jinja through any provider, and the runtime check runs on it. Output matches the Python port +(jinja2) on `fixtures/jinja-render-conformance/`; the engine differences outside it are in +`docs/features/templates-and-payloads.md`. + ## FR-006 — response parsing For every `template.prompt` declaring `@responseRef`, `outputParser()` (from @@ -910,7 +928,7 @@ their own pages: [C#](csharp.md), [Java](java.md#known-gaps), | Relationships + FK | Yes | | Source kinds (table / view / storedProc) | Yes | | `field.currency` / `field.enum` / `field.object` + `@storage` | Yes | -| Templates + render (FR-004) | Yes | +| Templates + render (FR-004) | Yes (Mustache, and Jinja via nunjucks: a `.jinja` / `.j2` / `.jinja2` body, ADR-0061) | | Output parser codegen (FR-006) | Yes (`outputParser()` — Zod dual API) | | Requirement gate + requirement tests | Yes (`meta verify`; `requirementTests()`, ejectable) | | Cube export (FR-044) | Yes (`cubeModel()`, ejectable; TypeScript only) | @@ -918,7 +936,7 @@ their own pages: [C#](csharp.md), [Java](java.md#known-gaps), | Migrations | `meta migrate` (Postgres / SQLite / D1) | | Drift verify | `meta verify` (`--codegen` generated code and docs, `--db` schema, `--templates` prompts) | | Documentation | Yes (`docsFile()`, `apiDocsFile()`, `requirementsFile()`, `agentDocsFile()`, `metamodelDocsFile()`, `docsSiteFile()`; generators like the rest) | -| Prompt-drift verify | Yes (`@metaobjectsdev/render`) | +| Prompt-drift verify | Yes (`@metaobjectsdev/render`; Mustache and Jinja bodies) | | Web client packages | Yes (`@metaobjectsdev/react`, `@metaobjectsdev/tanstack`) | ## Client-side diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 5adb5fd9d5..2e96740fde 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -8,7 +8,7 @@ }, "counts": { "fixtures": 413, - "corpora": 32, + "corpora": 34, "baseTypes": 17 }, "snippets": { diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md index 81612534a8..3f7feee4a1 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/java.md @@ -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. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/kotlin.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/kotlin.md index 45663093f4..4b166e7339 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/kotlin.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/kotlin.md @@ -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. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md index 81612534a8..3f7feee4a1 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-prompts/references/java.md @@ -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. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/references/python.md index e3e24ffecf..0a5271c633 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-prompts/references/python.md @@ -18,6 +18,7 @@ silently drift. - Recommended LLM caller (bring-your-own) - Consumer dependency - Drift gate +- Jinja templates ## Wire the generators @@ -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 `/.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. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/typescript.md index 89de514c21..92b5d47980 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-prompts/references/typescript.md @@ -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 `/.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()` 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 diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/SKILL.md index 011c28dc80..37377ba255 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/SKILL.md @@ -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` → `/lobby/welcome.mustache`. It is `FilesystemProvider` in every @@ -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, @@ -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 diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/references/typescript.md index 89de514c21..92b5d47980 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-prompts/references/typescript.md @@ -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 `/.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()` 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 diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index b27f9b91b5..f736a92d2e 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -47,6 +47,9 @@ "ERR_PARTIAL_UNRESOLVED": "Build-time verify: a template partial ({{> group/source}}) does not resolve in the configured provider.", "ERR_REQUIRED_SLOT_UNUSED": "Build-time verify (warning): a template's declared @requiredSlots slot is never referenced by the template text.", "ERR_OUTPUT_TAG_MISSING": "Build-time verify: a template declares @requiredTags but its text (including provider-resolved partials) omits a required output tag's opening () form. path = the tag name.", + "ERR_TEMPLATE_NOT_STATIC": "Build-time verify (ADR-0061, Jinja bodies, Python and TypeScript): a template reference the check cannot settle statically — a computed attribute or include name, a dynamic subscript, a call, a tag outside the supported set. Reported rather than passed; path = the expression or tag.", + "ERR_TEMPLATE_SYNTAX": "Build-time verify (ADR-0061, Jinja bodies, Python and TypeScript): the template body, or a template it includes or imports, does not parse. path = the parser's message, which is engine-specific.", + "ERR_TEMPLATE_ENGINE_UNSUPPORTED": "Build-time verify and render (ADR-0061): a template body is written for an engine this port does not ship — a Jinja body (.jinja / .j2 / .jinja2) in the Java, Kotlin or C# port, which render Mustache only. path = the template ref.", "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207). FR-044: a segment / measure.aggregate / object.report @filter names a field its entity (or @from) lacks or an op illegal for the field (S1); a relative-date value { \"now\": \"\" } appears outside the @filter of a segment, measure.aggregate or object.report (F1); or a relative-date value sits on a field that is not field.date/field.timestamp, under an op other than gt/gte/lt/lte, carries a value that is not an ISO-8601 duration, or has a startOf other than day, week or month, or a key besides now and startOf (F2).", "ERR_STORAGE_FLATTENED_ARRAY": "@storage \"flattened\" cannot be combined with isArray=true.", "ERR_STORAGE_WITHOUT_OBJECT_REF": "@storage was set on a field that has no @objectRef.", diff --git a/fixtures/jinja-render-conformance/jinja-render-conditionals/expected.txt b/fixtures/jinja-render-conformance/jinja-render-conditionals/expected.txt new file mode 100644 index 0000000000..a68df7464a --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-conditionals/expected.txt @@ -0,0 +1,5 @@ +B +admin +has math +no nickname +an empty list tested by its length diff --git a/fixtures/jinja-render-conformance/jinja-render-conditionals/meta.json b/fixtures/jinja-render-conformance/jinja-render-conditionals/meta.json new file mode 100644 index 0000000000..7dc7628d0b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-conditionals/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "if / elif / else, and / or / not, comparisons, in, is defined, inline if. An empty list is tested with | length: nunjucks follows JavaScript, where [] is truthy, and jinja2 follows Python, where it is not" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-conditionals/payload.json b/fixtures/jinja-render-conformance/jinja-render-conditionals/payload.json new file mode 100644 index 0000000000..e8cdfe305f --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-conditionals/payload.json @@ -0,0 +1,10 @@ +{ + "score": 85, + "admin": true, + "banned": false, + "guest": false, + "tags": [ + "math" + ], + "empty": [] +} diff --git a/fixtures/jinja-render-conformance/jinja-render-conditionals/template.jinja b/fixtures/jinja-render-conformance/jinja-render-conditionals/template.jinja new file mode 100644 index 0000000000..b818ad8903 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-conditionals/template.jinja @@ -0,0 +1,5 @@ +{% if score > 90 %}A{% elif score > 80 %}B{% else %}C{% endif %} +{% if admin and not banned %}admin{% endif %}{% if banned or guest %}restricted{% endif %} +{{ "has math" if "math" in tags else "no math" }} +{% if nickname is defined %}{{ nickname }}{% else %}no nickname{% endif %} +{% if empty | length %}nonempty{% else %}an empty list tested by its length{% endif %} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/expected-error.txt b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/expected-error.txt new file mode 100644 index 0000000000..a528acc344 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/expected-error.txt @@ -0,0 +1 @@ +partial cycle: partials/a -> partials/b -> partials/a diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/meta.json b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/meta.json new file mode 100644 index 0000000000..a8aa86485b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "a cycle in the static include graph fails before rendering, with the Mustache path's message" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/a.jinja b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/a.jinja new file mode 100644 index 0000000000..b8bb5f245b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/a.jinja @@ -0,0 +1 @@ +a {% include "partials/b" %} \ No newline at end of file diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/b.jinja b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/b.jinja new file mode 100644 index 0000000000..d4808c88f0 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/b.jinja @@ -0,0 +1 @@ +b {% include "partials/a" %} \ No newline at end of file diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/payload.json b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/payload.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/payload.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/template.jinja b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/template.jinja new file mode 100644 index 0000000000..9d50f0269c --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-cycle/template.jinja @@ -0,0 +1 @@ +{% include "partials/a" %} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/expected-error.txt b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/expected-error.txt new file mode 100644 index 0000000000..f80137f91c --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/expected-error.txt @@ -0,0 +1 @@ +unresolved partial: partials/missing diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/meta.json b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/meta.json new file mode 100644 index 0000000000..b06681c165 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "an include the provider cannot resolve fails with the Mustache path's message; `ignore missing` is the opt-out" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/payload.json b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/payload.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/payload.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/template.jinja b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/template.jinja new file mode 100644 index 0000000000..8411527265 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/template.jinja @@ -0,0 +1 @@ +x {% include "partials/missing" %} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/expected.txt b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/expected.txt new file mode 100644 index 0000000000..5e285a42b6 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/expected.txt @@ -0,0 +1,4 @@ +

Tom & "Jerry" <it's>

+

Tom & "Jerry" <it's>

+
bold
+
<b>bold</b>
diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/meta.json b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/meta.json new file mode 100644 index 0000000000..98f1434635 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/meta.json @@ -0,0 +1,4 @@ +{ + "format": "html", + "note": "| escape, | e and | forceescape use the format's escaper, not the engine's own HTML escaper; an escaped value is not escaped again, and | forceescape escapes a value marked | safe" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/payload.json b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/payload.json new file mode 100644 index 0000000000..7ec650661d --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/payload.json @@ -0,0 +1,4 @@ +{ + "title": "Tom & \"Jerry\" ", + "markup": "bold" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/template.jinja b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/template.jinja new file mode 100644 index 0000000000..c94df00c4e --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-html/template.jinja @@ -0,0 +1,4 @@ +

{{ title | e }}

+

{{ title | escape | e }}

+
{{ markup | safe | e }}
+
{{ markup | safe | forceescape }}
diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/expected.txt b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/expected.txt new file mode 100644 index 0000000000..1cf36c66d9 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/expected.txt @@ -0,0 +1 @@ +{"e": "she said \"hi\"\nbye", "escape": "she said \"hi\"\nbye", "force": "she said \"hi\"\nbye"} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/meta.json b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/meta.json new file mode 100644 index 0000000000..59b67ba29c --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/meta.json @@ -0,0 +1,4 @@ +{ + "format": "json", + "note": "json format: | escape, | e and | forceescape JSON-string escape, so a template written with the Jinja escape idiom still builds a valid JSON document" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/payload.json b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/payload.json new file mode 100644 index 0000000000..d92862a630 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/payload.json @@ -0,0 +1,3 @@ +{ + "quote": "she said \"hi\"\nbye" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/template.jinja b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/template.jinja new file mode 100644 index 0000000000..8269dfe203 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escape-filter-json/template.jinja @@ -0,0 +1 @@ +{"e": "{{ quote | e }}", "escape": "{{ quote | escape }}", "force": "{{ quote | forceescape }}"} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/expected.txt b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/expected.txt new file mode 100644 index 0000000000..062b209784 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/expected.txt @@ -0,0 +1,4 @@ + +Tom & <Jerry> +a < b +a < b diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/meta.json b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/meta.json new file mode 100644 index 0000000000..851324da77 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/meta.json @@ -0,0 +1,4 @@ +{ + "format": "html", + "note": "html format: what a macro, a call block or a block set renders is already escaped, so printing it does not escape it again" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/payload.json b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/payload.json new file mode 100644 index 0000000000..bf9f980cb2 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/payload.json @@ -0,0 +1,4 @@ +{ + "name": "Tom & ", + "note": "a < b" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/template.jinja b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/template.jinja new file mode 100644 index 0000000000..53183784ae --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-blocks/template.jinja @@ -0,0 +1,4 @@ +{% macro badge(x) %}{{ x }}{% endmacro %}{% macro wrap() %}{{ caller() }}{% endmacro %}{% set line %}{{ note }}{% endset %} +{{ badge(name) }} +{% call wrap() %}{{ note }}{% endcall %} +{{ line }} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-html/expected.txt b/fixtures/jinja-render-conformance/jinja-render-escaping-html/expected.txt new file mode 100644 index 0000000000..0981374916 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-html/expected.txt @@ -0,0 +1,3 @@ +

<script>alert('x')</script>

+
bold
+
  • a < b
  • it's
  • diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-html/meta.json b/fixtures/jinja-render-conformance/jinja-render-escaping-html/meta.json new file mode 100644 index 0000000000..f84bc60e14 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-html/meta.json @@ -0,0 +1,4 @@ +{ + "format": "html", + "note": "html format: every {{ }} value is XML-escaped by the port's escaper; | safe bypasses it; template text is never escaped" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-html/payload.json b/fixtures/jinja-render-conformance/jinja-render-escaping-html/payload.json new file mode 100644 index 0000000000..7170fcbbe6 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-html/payload.json @@ -0,0 +1,9 @@ +{ + "title": "Tom & \"Jerry\"", + "body": "", + "markup": "bold", + "comments": [ + "a < b", + "it's" + ] +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-html/template.jinja b/fixtures/jinja-render-conformance/jinja-render-escaping-html/template.jinja new file mode 100644 index 0000000000..afb15300b1 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-html/template.jinja @@ -0,0 +1,3 @@ +

    {{ body }}

    +
    {{ markup | safe }}
    +{% for c in comments %}
  • {{ c }}
  • {% endfor %} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-json/expected.txt b/fixtures/jinja-render-conformance/jinja-render-escaping-json/expected.txt new file mode 100644 index 0000000000..b98af2ea60 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-json/expected.txt @@ -0,0 +1 @@ +{"name": "Ada", "quote": "she said \"hi\"", "lines": "one\ntwo\ttab"} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-json/meta.json b/fixtures/jinja-render-conformance/jinja-render-escaping-json/meta.json new file mode 100644 index 0000000000..a262251cee --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-json/meta.json @@ -0,0 +1,4 @@ +{ + "format": "json", + "note": "json format: values are JSON-string escaped, so a template can build a JSON document" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-json/payload.json b/fixtures/jinja-render-conformance/jinja-render-escaping-json/payload.json new file mode 100644 index 0000000000..0692672075 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-json/payload.json @@ -0,0 +1,5 @@ +{ + "name": "Ada", + "quote": "she said \"hi\"", + "lines": "one\ntwo\ttab" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-escaping-json/template.jinja b/fixtures/jinja-render-conformance/jinja-render-escaping-json/template.jinja new file mode 100644 index 0000000000..1e64d8253d --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-escaping-json/template.jinja @@ -0,0 +1 @@ +{"name": "{{ name }}", "quote": "{{ quote }}", "lines": "{{ lines }}"} diff --git a/fixtures/jinja-render-conformance/jinja-render-filters/expected.txt b/fixtures/jinja-render-conformance/jinja-render-filters/expected.txt new file mode 100644 index 0000000000..6787beb221 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-filters/expected.txt @@ -0,0 +1,4 @@ +ADA LOVELACE ada lovelace 3 +math, engines, notes / Ada & Charles +math notes Ada +the dog sat [x] fallback diff --git a/fixtures/jinja-render-conformance/jinja-render-filters/meta.json b/fixtures/jinja-render-conformance/jinja-render-filters/meta.json new file mode 100644 index 0000000000..7822ca6db4 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-filters/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "the filters both engines implement alike: upper, lower, length, join (with and without an attribute), first, last, replace, trim, default" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-filters/payload.json b/fixtures/jinja-render-conformance/jinja-render-filters/payload.json new file mode 100644 index 0000000000..6a239657f6 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-filters/payload.json @@ -0,0 +1,18 @@ +{ + "name": "Ada Lovelace", + "tags": [ + "math", + "engines", + "notes" + ], + "people": [ + { + "name": "Ada" + }, + { + "name": "Charles" + } + ], + "phrase": "the cat sat", + "padded": " x " +} diff --git a/fixtures/jinja-render-conformance/jinja-render-filters/template.jinja b/fixtures/jinja-render-conformance/jinja-render-filters/template.jinja new file mode 100644 index 0000000000..0b97a040b6 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-filters/template.jinja @@ -0,0 +1,4 @@ +{{ name | upper }} {{ name | lower }} {{ tags | length }} +{{ tags | join(", ") }} / {{ people | join(" & ", "name") }} +{{ tags | first }} {{ tags | last }} {{ (people | first).name }} +{{ phrase | replace("cat", "dog") }} [{{ padded | trim }}] {{ absent | default("fallback") }} diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/expected.txt b/fixtures/jinja-render-conformance/jinja-render-include-import/expected.txt new file mode 100644 index 0000000000..d49c6d5718 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/expected.txt @@ -0,0 +1,4 @@ +== Report == +* Ada +DONE! +-- Ada -- diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/meta.json b/fixtures/jinja-render-conformance/jinja-render-include-import/meta.json new file mode 100644 index 0000000000..e6839b1d7e --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "include sees the including context; import and from-import bring macros; a .jinja suffix on the name is dropped" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/partials/footer.jinja b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/footer.jinja new file mode 100644 index 0000000000..1cc99761e1 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/footer.jinja @@ -0,0 +1 @@ +-- {{ user.name }} -- \ No newline at end of file diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/partials/header.jinja b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/header.jinja new file mode 100644 index 0000000000..2361aa195f --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/header.jinja @@ -0,0 +1 @@ +== {{ title }} == \ No newline at end of file diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/partials/macros.jinja b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/macros.jinja new file mode 100644 index 0000000000..974e725a07 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/partials/macros.jinja @@ -0,0 +1 @@ +{% macro line(x) %}* {{ x }}{% endmacro %}{% macro shout(x) %}{{ x | upper }}!{% endmacro %} \ No newline at end of file diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/payload.json b/fixtures/jinja-render-conformance/jinja-render-include-import/payload.json new file mode 100644 index 0000000000..d015412ab2 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/payload.json @@ -0,0 +1,6 @@ +{ + "title": "Report", + "user": { + "name": "Ada" + } +} diff --git a/fixtures/jinja-render-conformance/jinja-render-include-import/template.jinja b/fixtures/jinja-render-conformance/jinja-render-include-import/template.jinja new file mode 100644 index 0000000000..546f79310a --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-include-import/template.jinja @@ -0,0 +1,4 @@ +{% import "partials/macros" as m %}{% from "partials/macros" import shout %}{% include "partials/header" %} +{{ m.line(user.name) }} +{{ shout("done") }} +{% include "partials/footer.jinja" %} diff --git a/fixtures/jinja-render-conformance/jinja-render-loops/expected.txt b/fixtures/jinja-render-conformance/jinja-render-loops/expected.txt new file mode 100644 index 0000000000..77fe48331d --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-loops/expected.txt @@ -0,0 +1,5 @@ +1. Engines (first), 2. Notes, 3. Letters +Engines: a b +Notes: +Letters: c +nothing here diff --git a/fixtures/jinja-render-conformance/jinja-render-loops/meta.json b/fixtures/jinja-render-conformance/jinja-render-loops/meta.json new file mode 100644 index 0000000000..f67c8e929b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-loops/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "for over objects and scalars, loop.index / first / last, for-else on an empty list, nested loops" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-loops/payload.json b/fixtures/jinja-render-conformance/jinja-render-loops/payload.json new file mode 100644 index 0000000000..b3e647f49f --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-loops/payload.json @@ -0,0 +1,22 @@ +{ + "posts": [ + { + "title": "Engines", + "tags": [ + "a", + "b" + ] + }, + { + "title": "Notes", + "tags": [] + }, + { + "title": "Letters", + "tags": [ + "c" + ] + } + ], + "empty": [] +} diff --git a/fixtures/jinja-render-conformance/jinja-render-loops/template.jinja b/fixtures/jinja-render-conformance/jinja-render-loops/template.jinja new file mode 100644 index 0000000000..0fb13ce2ed --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-loops/template.jinja @@ -0,0 +1,3 @@ +{% for p in posts %}{{ loop.index }}. {{ p.title }}{% if loop.first %} (first){% endif %}{% if not loop.last %}, {% endif %}{% endfor %} +{% for p in posts %}{{ p.title }}:{% for t in p.tags %} {{ t }}{% endfor %} +{% endfor %}{% for x in empty %}{{ x }}{% else %}nothing here{% endfor %} diff --git a/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/expected.txt b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/expected.txt new file mode 100644 index 0000000000..e796fbb84c --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/expected.txt @@ -0,0 +1,2 @@ +ab +{{ not_a_var }} {% if %} diff --git a/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/meta.json b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/meta.json new file mode 100644 index 0000000000..09f2d448f4 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "comments render nothing; raw blocks print Jinja syntax verbatim" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/payload.json b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/payload.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/payload.json @@ -0,0 +1 @@ +{} diff --git a/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/template.jinja b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/template.jinja new file mode 100644 index 0000000000..5ae8723c3a --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-raw-and-comments/template.jinja @@ -0,0 +1,2 @@ +a{# a comment #}b +{% raw %}{{ not_a_var }} {% if %}{% endraw %} diff --git a/fixtures/jinja-render-conformance/jinja-render-set-and-macros/expected.txt b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/expected.txt new file mode 100644 index 0000000000..93e8999a09 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/expected.txt @@ -0,0 +1,3 @@ +Hi, Ada Lovelace! +[info:Ada] [warn:x] + diff --git a/fixtures/jinja-render-conformance/jinja-render-set-and-macros/meta.json b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/meta.json new file mode 100644 index 0000000000..8571a9b606 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "set (expression and block), macro with a default, call block with caller()" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-set-and-macros/payload.json b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/payload.json new file mode 100644 index 0000000000..fb338bd354 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/payload.json @@ -0,0 +1,6 @@ +{ + "user": { + "first": "Ada", + "last": "Lovelace" + } +} diff --git a/fixtures/jinja-render-conformance/jinja-render-set-and-macros/template.jinja b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/template.jinja new file mode 100644 index 0000000000..a5a52fe358 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-set-and-macros/template.jinja @@ -0,0 +1,3 @@ +{% set greeting = "Hi" %}{% set full %}{{ user.first }} {{ user.last }}{% endset %}{{ greeting }}, {{ full }}! +{% macro badge(label, kind="info") %}[{{ kind }}:{{ label }}]{% endmacro %}{{ badge(user.first) }} {{ badge("x", "warn") }} +{% macro box() %}<{{ caller() }}>{% endmacro %}{% call box() %}inside {{ user.last }}{% endcall %} diff --git a/fixtures/jinja-render-conformance/jinja-render-variables/expected.txt b/fixtures/jinja-render-conformance/jinja-render-variables/expected.txt new file mode 100644 index 0000000000..374a93eb10 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-variables/expected.txt @@ -0,0 +1,4 @@ +Hello Ada +count=3 ratio=2.5 whole=2 +active=true off=false none=[] missing=[] +first tag: math diff --git a/fixtures/jinja-render-conformance/jinja-render-variables/meta.json b/fixtures/jinja-render-conformance/jinja-render-variables/meta.json new file mode 100644 index 0000000000..b59e20d939 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-variables/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "attribute and subscript access, scalar printing (bool, whole float, null, missing)" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-variables/payload.json b/fixtures/jinja-render-conformance/jinja-render-variables/payload.json new file mode 100644 index 0000000000..182ee52b79 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-variables/payload.json @@ -0,0 +1,16 @@ +{ + "user": { + "name": "Ada", + "email": "ada@example.com" + }, + "count": 3, + "ratio": 2.5, + "whole": 2.0, + "active": true, + "off": false, + "nothing": null, + "tags": [ + "math", + "engines" + ] +} diff --git a/fixtures/jinja-render-conformance/jinja-render-variables/template.jinja b/fixtures/jinja-render-conformance/jinja-render-variables/template.jinja new file mode 100644 index 0000000000..7b85505cc4 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-variables/template.jinja @@ -0,0 +1,4 @@ +Hello {{ user.name }} <{{ user["email"] }}> +count={{ count }} ratio={{ ratio }} whole={{ whole }} +active={{ active }} off={{ off }} none=[{{ nothing }}] missing=[{{ absent }}] +first tag: {{ tags[0] }} diff --git a/fixtures/jinja-render-conformance/jinja-render-whitespace-control/expected.txt b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/expected.txt new file mode 100644 index 0000000000..74a51ccb9e --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/expected.txt @@ -0,0 +1,7 @@ +items: + - a + - b + +kept + +end diff --git a/fixtures/jinja-render-conformance/jinja-render-whitespace-control/meta.json b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/meta.json new file mode 100644 index 0000000000..0c71fa4d4f --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/meta.json @@ -0,0 +1,4 @@ +{ + "format": "text", + "note": "{%- -%} trims, a trailing newline is kept, block tags leave their own newline (no trim_blocks)" +} diff --git a/fixtures/jinja-render-conformance/jinja-render-whitespace-control/payload.json b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/payload.json new file mode 100644 index 0000000000..ac7ff52308 --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/payload.json @@ -0,0 +1,6 @@ +{ + "items": [ + "a", + "b" + ] +} diff --git a/fixtures/jinja-render-conformance/jinja-render-whitespace-control/template.jinja b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/template.jinja new file mode 100644 index 0000000000..704ee1680d --- /dev/null +++ b/fixtures/jinja-render-conformance/jinja-render-whitespace-control/template.jinja @@ -0,0 +1,8 @@ +items: +{%- for i in items %} + - {{ i }} +{%- endfor %} +{% if true %} +kept +{% endif %} +end diff --git a/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/template.jinja new file mode 100644 index 0000000000..cee8345fdf --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/template.jinja @@ -0,0 +1 @@ +{% macro box(title) %}[{{ title }}: {{ caller() }}]{% endmacro %}{% call box(displayName) %}{{ user.name }}{% endcall %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-conditionals/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/expected-drift.json new file mode 100644 index 0000000000..bf39d965fc --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/expected-drift.json @@ -0,0 +1,5 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "flag" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "ghost" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "other" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-conditionals/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-conditionals/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/template.jinja new file mode 100644 index 0000000000..39234226cc --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-conditionals/template.jinja @@ -0,0 +1 @@ +{% if count > 1 and flag %}a{% elif user.name is defined %}b{% elif ghost is defined %}c{% else %}d{% endif %} {{ "x" if displayName else other }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/expected-drift.json new file mode 100644 index 0000000000..62bcf701c3 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/template.jinja new file mode 100644 index 0000000000..8785f347b6 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/template.jinja @@ -0,0 +1 @@ +{% set d = {"name": user.name, "bad": user.zz} %}{{ d.name }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/expected-drift.json new file mode 100644 index 0000000000..34ccf509d1 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_TEMPLATE_NOT_STATIC", "path": "posts[*]" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/template.jinja new file mode 100644 index 0000000000..586f26ac24 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/template.jinja @@ -0,0 +1 @@ +{{ posts | map(attribute=which) | list }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/expected-drift.json new file mode 100644 index 0000000000..ebd7bafd6f --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/expected-drift.json @@ -0,0 +1,4 @@ +[ + { "code": "ERR_TEMPLATE_NOT_STATIC", "path": "user[*]" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts[*].nope" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/template.jinja new file mode 100644 index 0000000000..1a1ad3c9a5 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/template.jinja @@ -0,0 +1 @@ +{{ user[key] }} {{ posts[count].title }} {{ posts[count].nope }} {{ labels[count] }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/expected-drift.json new file mode 100644 index 0000000000..884e2958d7 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/expected-drift.json @@ -0,0 +1,4 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "ghost" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/partials/base.jinja b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/partials/base.jinja new file mode 100644 index 0000000000..c1f3fa8840 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/partials/base.jinja @@ -0,0 +1 @@ +

    {{ displayName }} {{ ghost }}

    {% block body %}{% endblock %} \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/template.jinja new file mode 100644 index 0000000000..c8be429ed9 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/template.jinja @@ -0,0 +1 @@ +{% extends "partials/base" %}{% block body %}{{ user.name }} {{ user.zz }}{% endblock %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/expected-drift.json new file mode 100644 index 0000000000..68dcdf174a --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/expected-drift.json @@ -0,0 +1,6 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.prise" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.prise" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.titel" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/template.jinja new file mode 100644 index 0000000000..034f7bbfaa --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/template.jinja @@ -0,0 +1,2 @@ +{{ posts | sum("title") }} {{ posts | sum("prise") }} {{ posts | sum(attribute="prise") }} +{{ posts | unique(false, "titel") | list }} {{ posts | min(false, "title") }} {{ posts | max(false, "zz") }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-element/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/expected-drift.json new file mode 100644 index 0000000000..37e9973b26 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts|first.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-element/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filter-element/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/template.jinja new file mode 100644 index 0000000000..594b0ddfb8 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filter-element/template.jinja @@ -0,0 +1 @@ +{{ (posts | first).title }} {{ (posts | last).author.name }} {{ (posts | first).zz }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filters/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-filters/expected-drift.json new file mode 100644 index 0000000000..c42d63e3f6 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filters/expected-drift.json @@ -0,0 +1,7 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "missing" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "nope" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.author.zz" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.bogus" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.nah" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filters/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-filters/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filters/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-filters/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-filters/template.jinja new file mode 100644 index 0000000000..0ea7a54c05 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-filters/template.jinja @@ -0,0 +1,3 @@ +{{ nope | upper }} {{ count | default(missing) }} {{ displayName | fancyfilter }} +{{ posts | join(", ", "title") }} {{ posts | join(", ", "nah") }} {{ posts | map(attribute="author.name") | join(", ") }} {{ posts | map(attribute="author.zz") | list }} +{{ posts | selectattr("title") | list }} {{ posts | rejectattr("bogus") | list }} {{ posts | sort(false, false, "title") | first }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-element/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-for-element/expected-drift.json new file mode 100644 index 0000000000..692e849b97 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-element/expected-drift.json @@ -0,0 +1,5 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "l.x" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "p.nope" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "t.x" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-element/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-for-element/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-element/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-element/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-for-element/template.jinja new file mode 100644 index 0000000000..1d716eb42c --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-element/template.jinja @@ -0,0 +1,2 @@ +{% for p in posts %}{{ p.title }} {{ p.author.name }} {{ p.nope }}{% for t in p.tags %}{{ t }}{{ t.x }}{% endfor %}{% endfor %} +{% for l in labels %}{{ l }}{{ l.x }}{% endfor %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/expected-drift.json new file mode 100644 index 0000000000..9bd5422568 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "p" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/template.jinja new file mode 100644 index 0000000000..87cdb33cdb --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/template.jinja @@ -0,0 +1 @@ +{% for p in posts %}{{ p.title }}{% else %}none: {{ p }}{% endfor %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/template.jinja new file mode 100644 index 0000000000..a7dc5c6cff --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/template.jinja @@ -0,0 +1 @@ +{% for k, v in user | dictsort %}{{ k }}={{ v }}{% endfor %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/partials/macros.jinja b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/partials/macros.jinja new file mode 100644 index 0000000000..3ede3c6da3 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/partials/macros.jinja @@ -0,0 +1 @@ +{% macro line(x) %}{{ x }} by {{ user.name }}{% endmacro %} \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/template.jinja new file mode 100644 index 0000000000..ad3e5e8789 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/template.jinja @@ -0,0 +1 @@ +{% from "partials/macros" import line with context %}{{ line(displayName) }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/expected-drift.json new file mode 100644 index 0000000000..4901261f9a --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.name" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/partials/macros.jinja b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/partials/macros.jinja new file mode 100644 index 0000000000..3ede3c6da3 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/partials/macros.jinja @@ -0,0 +1 @@ +{% macro line(x) %}{{ x }} by {{ user.name }}{% endmacro %} \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/template.jinja new file mode 100644 index 0000000000..0f8fe81621 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-import-without-context/template.jinja @@ -0,0 +1 @@ +{% import "partials/macros" as m %}{{ m.line(displayName) }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/expected-drift.json new file mode 100644 index 0000000000..d825447a05 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_TEMPLATE_NOT_STATIC", "path": "include" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/template.jinja new file mode 100644 index 0000000000..d77de91fe3 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/template.jinja @@ -0,0 +1 @@ +{% include displayName %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/options.json b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/options.json new file mode 100644 index 0000000000..8b563cbf7b --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/options.json @@ -0,0 +1,3 @@ +{ + "provider": "without" +} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/template.jinja new file mode 100644 index 0000000000..6ee02ce387 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/template.jinja @@ -0,0 +1 @@ +{% include "partials/footer" %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/partials/footer.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/partials/footer.jinja new file mode 100644 index 0000000000..1cc99761e1 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/partials/footer.jinja @@ -0,0 +1 @@ +-- {{ user.name }} -- \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/template.jinja new file mode 100644 index 0000000000..6ee02ce387 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/template.jinja @@ -0,0 +1 @@ +{% include "partials/footer" %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/expected-drift.json new file mode 100644 index 0000000000..884e2958d7 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/expected-drift.json @@ -0,0 +1,4 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "ghost" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/partials/footer.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/partials/footer.jinja new file mode 100644 index 0000000000..c2e147265e --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/partials/footer.jinja @@ -0,0 +1 @@ +-- {{ user.zz }} {{ ghost }} -- \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/template.jinja new file mode 100644 index 0000000000..93cb449618 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/template.jinja @@ -0,0 +1 @@ +{% include "partials/footer.jinja" %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/expected-drift.json new file mode 100644 index 0000000000..1fd91ce3ce --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "p.nope" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/partials/item.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/partials/item.jinja new file mode 100644 index 0000000000..919c96d4c0 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/partials/item.jinja @@ -0,0 +1 @@ +{{ p.title }} {{ p.nope }} \ No newline at end of file diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/template.jinja new file mode 100644 index 0000000000..8d22d86258 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/template.jinja @@ -0,0 +1 @@ +{% for p in posts %}{% include "partials/item" %}{% endfor %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/expected-drift.json new file mode 100644 index 0000000000..18095f2332 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_PARTIAL_UNRESOLVED", "path": "partials/missing" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/options.json b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/options.json new file mode 100644 index 0000000000..1af3ad35da --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/options.json @@ -0,0 +1,3 @@ +{ + "provider": "with" +} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/template.jinja new file mode 100644 index 0000000000..7921d6d2d4 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/template.jinja @@ -0,0 +1 @@ +{% include "partials/missing" %}{% include "partials/optional" ignore missing %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/template.jinja new file mode 100644 index 0000000000..3a50d43ab2 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/template.jinja @@ -0,0 +1 @@ +{% for p in posts %}{{ loop.index }}{% if loop.first %}^{% endif %}{% if not loop.last %},{% endif %}{% endfor %} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/expected-drift.json new file mode 100644 index 0000000000..0c5dce5480 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/expected-drift.json @@ -0,0 +1,4 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "stray" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.nope" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/template.jinja new file mode 100644 index 0000000000..30406c8df1 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/template.jinja @@ -0,0 +1 @@ +{% macro badge(label, kind=user.nope) %}{{ label }}{{ kind }}{{ count }}{{ stray }}{% endmacro %}{{ badge(displayName) }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/expected-drift.json new file mode 100644 index 0000000000..79306bbee4 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/expected-drift.json @@ -0,0 +1,5 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "displayName.first" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "labels.x" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts.title" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/template.jinja new file mode 100644 index 0000000000..8df48f35cb --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/template.jinja @@ -0,0 +1 @@ +{{ displayName.first }} {{ posts.title }} {{ labels.x }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/expected-drift.json new file mode 100644 index 0000000000..944ca2fc98 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.nm" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/template.jinja new file mode 100644 index 0000000000..12df6896b9 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/template.jinja @@ -0,0 +1 @@ +{{ displayName.upper() }} {{ user.nm.upper() }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/expected-drift.json new file mode 100644 index 0000000000..499b43f892 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/expected-drift.json @@ -0,0 +1,4 @@ +[ + { "code": "ERR_OUTPUT_TAG_MISSING", "path": "reasoning" }, + { "code": "ERR_REQUIRED_SLOT_UNUSED", "path": "displayName" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/options.json b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/options.json new file mode 100644 index 0000000000..628d54aa6b --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/options.json @@ -0,0 +1,10 @@ +{ + "requiredSlots": [ + "posts", + "displayName" + ], + "requiredTags": [ + "answer", + "reasoning" + ] +} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/template.jinja new file mode 100644 index 0000000000..9150b44e12 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/template.jinja @@ -0,0 +1 @@ +{% for p in posts %}{{ p.title }}{% endfor %}{{ user.name }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/template.jinja new file mode 100644 index 0000000000..d61c36b538 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/template.jinja @@ -0,0 +1 @@ +{% if count %}{% set shown = displayName %}{% endif %}{{ shown }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-locals/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/expected-drift.json new file mode 100644 index 0000000000..abb33270f1 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "u.zz" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-locals/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-set-locals/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/template.jinja new file mode 100644 index 0000000000..dbb4ff65af --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-set-locals/template.jinja @@ -0,0 +1 @@ +{% set greeting = "Hi" %}{{ greeting }}{% set u = user %}{{ u.name }}{{ u.zz }}{% set block %}{{ user.name }}{% endset %}{{ block }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/expected-drift.json new file mode 100644 index 0000000000..af62056f6b --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_TEMPLATE_SYNTAX", "path": "" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/template.jinja new file mode 100644 index 0000000000..b6680ff502 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-syntax-error/template.jinja @@ -0,0 +1 @@ +{{ broken diff --git a/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/expected-drift.json new file mode 100644 index 0000000000..82f1863bf8 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/expected-drift.json @@ -0,0 +1,3 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "helper" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/template.jinja new file mode 100644 index 0000000000..54f0639a25 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-unknown-function/template.jinja @@ -0,0 +1 @@ +{{ helper(user.name) }} {{ range(count) | list }} {{ cycler("a", "b").next() }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-clean/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/expected-drift.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/expected-drift.json @@ -0,0 +1 @@ +[] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-clean/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-clean/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/template.jinja new file mode 100644 index 0000000000..853ca415d0 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-clean/template.jinja @@ -0,0 +1 @@ +{{ displayName }} {{ user.name }} {{ user["email"] }} {{ labels[0] }} {{ posts[0].author.name }} diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/expected-drift.json b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/expected-drift.json new file mode 100644 index 0000000000..36614198c2 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/expected-drift.json @@ -0,0 +1,6 @@ +[ + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "nope" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "posts[0].author.zz" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.nmae" }, + { "code": "ERR_VAR_NOT_ON_PAYLOAD", "path": "user.nope" } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/payload.json b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/payload.json new file mode 100644 index 0000000000..deb58c7a61 --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/payload.json @@ -0,0 +1,50 @@ +[ + { + "name": "displayName" + }, + { + "name": "count" + }, + { + "name": "user", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + } + ] + }, + { + "name": "posts", + "isArray": true, + "fields": [ + { + "name": "title" + }, + { + "name": "tags", + "isArray": true + }, + { + "name": "author", + "fields": [ + { + "name": "name" + } + ] + } + ] + }, + { + "name": "labels", + "isArray": true + }, + { + "name": "key" + }, + { + "name": "which" + } +] diff --git a/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/template.jinja b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/template.jinja new file mode 100644 index 0000000000..9bb046efea --- /dev/null +++ b/fixtures/jinja-verify-conformance/jinja-verify-var-unknown/template.jinja @@ -0,0 +1 @@ +{{ nope }} {{ user.nmae }} {{ user["nope"] }} {{ posts[0].author.zz }} diff --git a/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs index 5ff48dc17f..4da708f582 100644 --- a/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/VerifySubverbTests.cs @@ -1,6 +1,7 @@ using MetaObjects.Cli; using MetaObjects.Codegen; using MetaObjects.Meta; +using MetaObjects.Render; using Xunit; namespace MetaObjects.Cli.Tests; @@ -96,6 +97,20 @@ public void Templates_drift_is_nonzero() Assert.NotEqual(0, r.ExitCode); } + // ADR-0061: Jinja bodies are a Python/TypeScript engine. Here a Jinja-only body is + // refused by name instead of being reported as an unresolved ref. + [Fact] + public void Templates_jinja_only_body_is_refused_by_code() + { + var dir = Path.Combine(TplDir, "t"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "main.jinja"), "Hi {{ name }}."); + var outcome = VerifyCommand.Run(MetaDir, TplDir); + Assert.Contains(outcome.Errors, d => d.Code == TemplateEngines.ErrTemplateEngineUnsupported && d.Path == "t/main"); + Assert.Empty(outcome.UnresolvedText); + Assert.NotEqual(0, VerifyCommand.RunSubverbs(TemplatesOpts()).ExitCode); + } + // An ejected project's `verify --codegen` runs in its owned runner, so the in-process // gate sees Codegen=false. That must not read as a BARE verify: it used to fall through // to the templates default with no root and crash in FilesystemProvider. diff --git a/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index f45a8f3763..2c775f05e0 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -559,7 +559,17 @@ public static Outcome Run(LoadResult load, string templatesRoot) }; foreach (var reference in refs) { - var text = provider.Resolve(reference); + string? text; + try + { + text = provider.Resolve(reference); + } + catch (RenderException e) when (TemplateEngines.IsEngineRefusal(e)) + { + // ADR-0061: a Jinja-only body is refused by name (this port renders Mustache only). + errors.Add(new Drift(tmpl.Name, kind, TemplateEngines.ErrTemplateEngineUnsupported, reference)); + continue; + } if (text is null) { unresolved.Add($"template \"{tmpl.Name}\": ref \"{reference}\" did not resolve under {templatesRoot}"); diff --git a/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs b/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs new file mode 100644 index 0000000000..a219f59837 --- /dev/null +++ b/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs @@ -0,0 +1,51 @@ +using Xunit; + +namespace MetaObjects.Render.Tests; + +/// ADR-0061: the C# port renders Mustache only and refuses a Jinja body by name. +public sealed class TemplateEngineTests : IDisposable +{ + private readonly string _root = Directory.CreateTempSubdirectory("fp-jinja-").FullName; + + public void Dispose() => Directory.Delete(_root, recursive: true); + + private void Write(string rel, string text) + { + var full = Path.Combine(_root, rel); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, text); + } + + [Fact] + public void A_jinja_only_ref_is_refused_with_the_code() + { + Write("g/s.j2", "{{ name }}"); + var e = Assert.Throws(() => new FilesystemProvider(_root).Resolve("g/s")); + Assert.StartsWith($"{TemplateEngines.ErrTemplateEngineUnsupported}: g/s.j2", e.Message); + } + + [Fact] + public void A_mustache_body_beside_a_jinja_one_still_resolves() + { + Write("g/s.mustache", "M"); + Write("g/s.jinja", "J"); + Assert.Equal("M", new FilesystemProvider(_root).Resolve("g/s")); + Assert.Null(new FilesystemProvider(_root).Resolve("g/none")); + } + + [Fact] + public void A_mustache_partial_that_is_jinja_only_is_a_finding_not_a_crash() + { + Write("g/footer.jinja", "{{ name }}"); + var errors = Verify.Check("{{> g/footer}}", [new PayloadField("name")], + new VerifyOptions { Provider = new FilesystemProvider(_root) }); + Assert.Equal([new VerifyError(TemplateEngines.ErrTemplateEngineUnsupported, "g/footer")], errors); + } + + [Fact] + public void A_jinja_extension_is_refused() + { + var e = Assert.Throws(() => new FilesystemProvider(_root, ".Jinja2")); + Assert.StartsWith(TemplateEngines.ErrTemplateEngineUnsupported, e.Message); + } +} diff --git a/server/csharp/MetaObjects.Render/FilesystemProvider.cs b/server/csharp/MetaObjects.Render/FilesystemProvider.cs index dca97325b5..8190ee3dd9 100644 --- a/server/csharp/MetaObjects.Render/FilesystemProvider.cs +++ b/server/csharp/MetaObjects.Render/FilesystemProvider.cs @@ -11,6 +11,9 @@ namespace MetaObjects.Render; /// Resolves group/source references to text files under a root directory: /// resolve("npc/turn") → <root>/npc/turn.mustache. Returns null /// when the file is absent. Refs that escape the root (via ..) are rejected. +/// Mustache only (ADR-0061): a ref whose only body is a Jinja file (.jinja / +/// .j2 / .jinja2) throws a carrying +/// , and so does a Jinja extension. /// public sealed class FilesystemProvider : IProvider { @@ -21,6 +24,7 @@ public FilesystemProvider(string root, string extension = ".mustache") { _root = Path.GetFullPath(root); _extension = extension; + TemplateEngines.RefuseJinjaExtension(extension); } public string? Resolve(string reference) @@ -29,13 +33,16 @@ public FilesystemProvider(string root, string extension = ".mustache") var segments = reference.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length == 0) return null; - var combined = Path.GetFullPath(Path.Combine([_root, .. segments])) + _extension; + var stem = Path.GetFullPath(Path.Combine([_root, .. segments])); + var combined = stem + _extension; // Path-traversal guard: the resolved file must stay under the root. if (!combined.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !combined.StartsWith(_root, StringComparison.Ordinal)) return null; - return File.Exists(combined) ? File.ReadAllText(combined) : null; + if (File.Exists(combined)) return File.ReadAllText(combined); + TemplateEngines.RefuseJinjaSibling(reference, ext => File.Exists(stem + ext)); + return null; } } diff --git a/server/csharp/MetaObjects.Render/TemplateEngines.cs b/server/csharp/MetaObjects.Render/TemplateEngines.cs new file mode 100644 index 0000000000..47f205fd92 --- /dev/null +++ b/server/csharp/MetaObjects.Render/TemplateEngines.cs @@ -0,0 +1,43 @@ +// Which template engines this port renders (ADR-0061). Mustache is the cross-port engine +// and the only one the C# port ships. Jinja is a second engine that only the Python and +// TypeScript ports render, chosen there by a .jinja / .j2 / .jinja2 file extension. A +// provider here that meets a body written for Jinja refuses it by name, rather than +// rendering it as Mustache (which would print its {% %} tags verbatim) or reporting the +// ref as missing. + +namespace MetaObjects.Render; + +public static class TemplateEngines +{ + /// The diagnostic code for a body written for an engine this port does not ship. + public const string ErrTemplateEngineUnsupported = "ERR_TEMPLATE_ENGINE_UNSUPPORTED"; + + /// File extensions that mark a body as Jinja, in the Python and TypeScript lookup order. + public static readonly IReadOnlyList JinjaExtensions = [".jinja", ".j2", ".jinja2"]; + + /// True when (with its leading dot) marks a Jinja body. + public static bool IsJinjaExtension(string extension) => + JinjaExtensions.Contains(extension.ToLowerInvariant()); + + /// True when is this refusal (its message starts with the code). + public static bool IsEngineRefusal(RenderException e) => + e.Message.StartsWith(ErrTemplateEngineUnsupported, StringComparison.Ordinal); + + internal static void RefuseJinjaExtension(string extension) + { + if (IsJinjaExtension(extension)) + throw new RenderException( + $"{ErrTemplateEngineUnsupported}: {extension} templates are Jinja, which only the Python and TypeScript ports render (ADR-0061)"); + } + + internal static void RefuseJinjaSibling(string reference, Func exists) + { + foreach (var ext in JinjaExtensions) + { + if (exists(ext)) + throw new RenderException( + $"{ErrTemplateEngineUnsupported}: {reference}{ext} is a Jinja template; the C# port renders Mustache only (ADR-0061)." + + $" Rewrite it as {reference}.mustache, or render it from the Python or TypeScript port."); + } + } +} diff --git a/server/csharp/MetaObjects.Render/Verify.cs b/server/csharp/MetaObjects.Render/Verify.cs index ce225b24d3..ffa1d399db 100644 --- a/server/csharp/MetaObjects.Render/Verify.cs +++ b/server/csharp/MetaObjects.Render/Verify.cs @@ -221,7 +221,14 @@ void Walk(IReadOnlyList tokens, List> stack, Li case PartialTok p: if (provider is null) break; if (seen.Contains(p.Value) || seen.Count >= MaxDepth) break; - var text = provider.Resolve(p.Value); + string? text; + try { text = provider.Resolve(p.Value); } + catch (RenderException e) when (TemplateEngines.IsEngineRefusal(e)) + { + // The partial exists only as a Jinja file (ADR-0061): a finding, not a crash. + errors.Add(new VerifyError(TemplateEngines.ErrTemplateEngineUnsupported, p.Value)); + break; + } if (text is null) { errors.Add(new VerifyError(ERR_PARTIAL_UNRESOLVED, p.Value)); diff --git a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java index 97f95fb9cc..0b251ed6c7 100644 --- a/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java +++ b/server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java @@ -6,6 +6,8 @@ import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.render.FilesystemProvider; import com.metaobjects.render.PayloadField; +import com.metaobjects.render.RenderException; +import com.metaobjects.render.TemplateEngines; import com.metaobjects.render.Verify; import com.metaobjects.render.VerifyError; import com.metaobjects.render.VerifyOptions; @@ -152,7 +154,15 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) { VerifyOptions opts = new VerifyOptions(provider, requiredSlots, requiredTags); for (String ref : refs) { // Render-engine drift check: mustache variables ↔ payload field names. - String text = provider.resolve(ref); + String text; + try { + text = provider.resolve(ref); + } catch (RenderException e) { + // ADR-0061: a Jinja-only body is refused by name (this port renders Mustache only). + if (!TemplateEngines.isEngineRefusal(e)) throw e; + errors.add(new Drift(tmpl.getName(), kind, TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED, ref)); + continue; + } if (text == null) { unresolved.add("template \"" + tmpl.getName() + "\": ref \"" + ref + "\" did not resolve under " + templateRoot); diff --git a/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java b/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java index 97f69ae596..6a766e06db 100644 --- a/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java +++ b/server/java/codegen-base/src/test/java/com/metaobjects/generator/verify/TemplateVerifyTest.java @@ -100,6 +100,23 @@ public void unknownFieldProducesDrift() throws Exception { assertEquals("subject", d.path()); } + /** ADR-0061: a Jinja-only body is refused by name, not reported as an unresolved ref. */ + @Test + public void jinjaOnlyBodyIsRefusedByCode() throws Exception { + Path templateRoot = Files.createTempDirectory("tv-jinja-templates"); + Path file = templateRoot.resolve("ai/greeting.jinja"); + Files.createDirectories(file.getParent()); + Files.writeString(file, "Hello {{ title }}"); + + TemplateVerify.Outcome out = TemplateVerify.run(loadFixture("jinja", CLEAN_FIXTURE), templateRoot); + + assertFalse(out.ok()); + assertTrue(out.unresolvedText().isEmpty()); + assertEquals(1, out.errors().size()); + assertEquals("ERR_TEMPLATE_ENGINE_UNSUPPORTED", out.errors().get(0).code()); + assertEquals("ai/greeting", out.errors().get(0).path()); + } + @Test public void unresolvedTextRefIsReported() throws Exception { Path templateRoot = Files.createTempDirectory("tv-missing-templates"); diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index b32a3a1745..91a9221b49 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -278,6 +278,15 @@ public enum ErrorCode { */ ERR_OUTPUT_TAG_MISSING, + /** Build-time verify (ADR-0061, Jinja, Python and TypeScript only): a reference the check cannot settle statically. */ + ERR_TEMPLATE_NOT_STATIC, + + /** Build-time verify (ADR-0061, Jinja, Python and TypeScript only): the template body does not parse. */ + ERR_TEMPLATE_SYNTAX, + + /** Verify and render (ADR-0061): a body written for an engine this port does not ship (Jinja, here). */ + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + /** A dataGrid @filter references a non-filterable field or uses a disallowed op. */ ERR_BAD_ATTR_FILTER, diff --git a/server/java/render/src/main/java/com/metaobjects/render/ClasspathResourceProvider.java b/server/java/render/src/main/java/com/metaobjects/render/ClasspathResourceProvider.java index f309688d4b..c271f4ece4 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/ClasspathResourceProvider.java +++ b/server/java/render/src/main/java/com/metaobjects/render/ClasspathResourceProvider.java @@ -26,6 +26,7 @@ public ClasspathResourceProvider(ClassLoader classLoader, String basePrefix, Str this.classLoader = Objects.requireNonNull(classLoader, "classLoader"); this.basePrefix = basePrefix == null ? "" : basePrefix; this.extension = Objects.requireNonNull(extension, "extension"); + TemplateEngines.refuseJinjaExtension(extension); } @Override @@ -35,7 +36,12 @@ public String resolve(String reference) { String resourcePath = basePrefix + reference + extension; try (InputStream in = classLoader.getResourceAsStream(resourcePath)) { - if (in == null) return null; + if (in == null) { + // ADR-0061: a Jinja-only body is refused by name rather than reported missing. + TemplateEngines.refuseJinjaSibling(reference, + ext -> classLoader.getResource(basePrefix + reference + ext) != null); + return null; + } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { return null; diff --git a/server/java/render/src/main/java/com/metaobjects/render/FilesystemProvider.java b/server/java/render/src/main/java/com/metaobjects/render/FilesystemProvider.java index 2d7eca12da..92d0c4aa7b 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/FilesystemProvider.java +++ b/server/java/render/src/main/java/com/metaobjects/render/FilesystemProvider.java @@ -15,6 +15,11 @@ * "unresolved" outcome. A second belt-and-suspenders check confirms the * normalized candidate path remains under {@code root}. * + *

    Mustache only (ADR-0061): a ref whose only body is a Jinja file + * ({@code .jinja} / {@code .j2} / {@code .jinja2}) throws a {@link RenderException} + * carrying {@link TemplateEngines#ERR_TEMPLATE_ENGINE_UNSUPPORTED}, and so does a + * Jinja {@code extension}. + * *

    Mirrors {@code server/csharp/MetaObjects.Render/FilesystemProvider.cs}. */ public final class FilesystemProvider implements Provider { @@ -29,6 +34,7 @@ public FilesystemProvider(Path root) { public FilesystemProvider(Path root, String extension) { this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize(); this.extension = Objects.requireNonNull(extension, "extension"); + TemplateEngines.refuseJinjaExtension(extension); } @Override @@ -38,7 +44,13 @@ public String resolve(String reference) { Path candidate = root.resolve(reference + extension).normalize(); if (!candidate.startsWith(root)) return null; // belt-and-suspenders - if (!Files.isRegularFile(candidate)) return null; + if (!Files.isRegularFile(candidate)) { + TemplateEngines.refuseJinjaSibling(reference, ext -> { + Path jinja = root.resolve(reference + ext).normalize(); + return jinja.startsWith(root) && Files.isRegularFile(jinja); + }); + return null; + } try { return Files.readString(candidate, StandardCharsets.UTF_8); diff --git a/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java b/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java new file mode 100644 index 0000000000..bc10b66c52 --- /dev/null +++ b/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java @@ -0,0 +1,56 @@ +package com.metaobjects.render; + +import java.util.List; +import java.util.Locale; +import java.util.function.Predicate; + +/** + * Which template engines this port renders (ADR-0061). Mustache is the cross-port engine + * and the only one the JVM ports (Java and Kotlin) ship. Jinja is a second engine that only + * the Python and TypeScript ports render, chosen there by a {@code .jinja} / {@code .j2} / + * {@code .jinja2} file extension. A provider here that meets a body written for Jinja refuses + * it with {@link #ERR_TEMPLATE_ENGINE_UNSUPPORTED} rather than rendering it as Mustache (it + * would print the {@code {%...%}} tags verbatim) or reporting the ref as missing. + */ +public final class TemplateEngines { + + /** The diagnostic code for a template body written for an engine this port does not ship. */ + public static final String ERR_TEMPLATE_ENGINE_UNSUPPORTED = "ERR_TEMPLATE_ENGINE_UNSUPPORTED"; + + /** File extensions that mark a body as Jinja, in the Python and TypeScript lookup order. */ + public static final List JINJA_EXTENSIONS = List.of(".jinja", ".j2", ".jinja2"); + + private TemplateEngines() {} + + /** True when {@code extension} (with its leading dot) marks a Jinja body. */ + public static boolean isJinjaExtension(String extension) { + return JINJA_EXTENSIONS.contains(extension.toLowerCase(Locale.ROOT)); + } + + /** True when {@code e} is this refusal (its message starts with the code). */ + public static boolean isEngineRefusal(RenderException e) { + return e.getMessage() != null && e.getMessage().startsWith(ERR_TEMPLATE_ENGINE_UNSUPPORTED); + } + + /** Refuse a provider configured to read Jinja files. */ + static void refuseJinjaExtension(String extension) { + if (isJinjaExtension(extension)) { + throw new RenderException(ERR_TEMPLATE_ENGINE_UNSUPPORTED + ": " + extension + + " templates are Jinja, which only the Python and TypeScript ports render (ADR-0061)"); + } + } + + /** + * Called when {@code reference} has no Mustache body: throw when a Jinja body exists for it + * ({@code exists} answers for a candidate suffix), so the author learns why it did not load. + */ + static void refuseJinjaSibling(String reference, Predicate exists) { + for (String ext : JINJA_EXTENSIONS) { + if (exists.test(ext)) { + throw new RenderException(ERR_TEMPLATE_ENGINE_UNSUPPORTED + ": " + reference + ext + + " is a Jinja template; the Java and Kotlin ports render Mustache only (ADR-0061)." + + " Rewrite it as " + reference + ".mustache, or render it from the Python or TypeScript port."); + } + } + } +} diff --git a/server/java/render/src/main/java/com/metaobjects/render/Verify.java b/server/java/render/src/main/java/com/metaobjects/render/Verify.java index 1e403fcd50..7529882c77 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/Verify.java +++ b/server/java/render/src/main/java/com/metaobjects/render/Verify.java @@ -204,7 +204,15 @@ private static void walk( case PartialTok p -> { if (ctx.provider() == null) break; // can't resolve without a provider if (seen.contains(p.value()) || seen.size() >= MAX_DEPTH) break; - String text = ctx.provider().resolve(p.value()); + String text; + try { + text = ctx.provider().resolve(p.value()); + } catch (RenderException e) { + if (!TemplateEngines.isEngineRefusal(e)) throw e; + // The partial exists only as a Jinja file (ADR-0061): a finding, not a crash. + ctx.errors().add(new VerifyError(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED, p.value())); + break; + } if (text == null) { ctx.errors().add(new VerifyError(ERR_PARTIAL_UNRESOLVED, p.value())); break; diff --git a/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java b/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java new file mode 100644 index 0000000000..cf36a7fadb --- /dev/null +++ b/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java @@ -0,0 +1,56 @@ +package com.metaobjects.render; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** ADR-0061: the JVM ports render Mustache only and refuse a Jinja body by name. */ +public class TemplateEnginesTest { + + @Test + public void aJinjaOnlyRefIsRefusedWithTheCode() throws IOException { + Path root = Files.createTempDirectory("fp-jinja-"); + Files.createDirectories(root.resolve("g")); + Files.writeString(root.resolve("g/s.j2"), "{{ name }}"); + Provider p = new FilesystemProvider(root); + RenderException e = assertThrows(RenderException.class, () -> p.resolve("g/s")); + assertTrue(e.getMessage(), e.getMessage().startsWith(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED + ": g/s.j2")); + } + + @Test + public void aMustacheBodyBesideAJinjaOneStillResolves() throws IOException { + Path root = Files.createTempDirectory("fp-jinja-"); + Files.createDirectories(root.resolve("g")); + Files.writeString(root.resolve("g/s.mustache"), "M"); + Files.writeString(root.resolve("g/s.jinja"), "J"); + assertEquals("M", new FilesystemProvider(root).resolve("g/s")); + assertNull(new FilesystemProvider(root).resolve("g/none")); + } + + @Test + public void aMustachePartialThatIsJinjaOnlyIsAFindingNotACrash() throws IOException { + Path root = Files.createTempDirectory("fp-jinja-"); + Files.createDirectories(root.resolve("g")); + Files.writeString(root.resolve("g/footer.jinja"), "{{ name }}"); + List errors = Verify.check("{{> g/footer}}", List.of(PayloadField.scalar("name")), + new VerifyOptions(new FilesystemProvider(root), null, null)); + assertEquals(List.of(new VerifyError(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED, "g/footer")), errors); + } + + @Test + public void aJinjaExtensionIsRefused() throws IOException { + Path root = Files.createTempDirectory("fp-jinja-"); + RenderException e = assertThrows(RenderException.class, () -> new FilesystemProvider(root, ".jinja")); + assertTrue(e.getMessage().startsWith(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED)); + assertThrows(RenderException.class, + () -> new ClasspathResourceProvider(getClass().getClassLoader(), "", ".J2")); + } +} diff --git a/server/python/README.md b/server/python/README.md index c9b3482e51..a4958eb706 100644 --- a/server/python/README.md +++ b/server/python/README.md @@ -44,8 +44,8 @@ The primary public API is the **loader** (`load_directory` / `load_uris` / `load_string`, `MetaDataLoader`, `LoadResult`, `ErrorCode`, `MetaError`). The distribution also ships the Python implementations of the other pillars used by the CLI and tooling: `codegen` (Pydantic + FastAPI emit — reference generators you own: -`metaobjects eject ` copies one into your repo), `render` (Mustache + payload-VO + -verify), and `runtime` (a DB-API 2 `ObjectManager` — pg8000 / psycopg on Postgres, the standard library's `sqlite3` on SQLite, PyMySQL on MySQL 8 (`pip install metaobjects[mysql]`) — no SQLAlchemy). +`metaobjects eject ` copies one into your repo), `render` (Mustache, or Jinja with +`pip install metaobjects[jinja]` — ADR-0061 — + payload-VO + verify), and `runtime` (a DB-API 2 `ObjectManager` — pg8000 / psycopg on Postgres, the standard library's `sqlite3` on SQLite, PyMySQL on MySQL 8 (`pip install metaobjects[mysql]`) — no SQLAlchemy). Schema migrations are owned by the TypeScript `meta` CLI (ADR-0015); there is **no `migrate` module** in this distribution. diff --git a/server/python/pyproject.toml b/server/python/pyproject.toml index bf4a41c817..1212021e4d 100644 --- a/server/python/pyproject.toml +++ b/server/python/pyproject.toml @@ -62,6 +62,10 @@ integration = ["pg8000>=1.31", "PyMySQL>=1.1", "fastapi>=0.110", "httpx>=0.27"] # so like pg8000 it installs without native build dependencies. SQLite needs nothing: the # driver wraps the standard library's `sqlite3`. mysql = ["PyMySQL>=1.1"] +# Jinja templates (ADR-0061): the second template engine beside Mustache. Imported only when a +# `.jinja` / `.j2` / `.jinja2` template is rendered or checked, so a Mustache-only project +# installs nothing extra. +jinja = ["jinja2>=3.1"] [[tool.mypy.overrides]] # PyMySQL ships no type information. @@ -115,4 +119,7 @@ dev = [ # cli-registry corpus runs codegen, so without ruff in the group `uv run pytest` # fails at format time with "No module named ruff". "ruff>=0.6", + # The Jinja engine and its conformance corpora (fixtures/jinja-*-conformance/) run in the + # default suite, so the optional `jinja` extra's dependency is in the dev group too. + "jinja2>=3.1", ] diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index e235516248..a092c71036 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -141,6 +141,7 @@ package_of_resolution_key, removed_naming_source_message, ) +from metaobjects.render.engine import template_engine_of from metaobjects.render.filesystem_provider import FilesystemProvider from metaobjects.render.verify import ( ERR_REQUIRED_SLOT_UNUSED, @@ -2401,6 +2402,7 @@ def _verify_templates(args: argparse.Namespace) -> int: for e in render_verify( text, fields, provider=provider, required_slots=req_slots, required_tags=req_tags, + engine=template_engine_of(provider, ref), ): if e.code == ERR_REQUIRED_SLOT_UNUSED: continue # an unused required slot is a warning, not drift diff --git a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py index ea4f265afe..839093c2fe 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -63,9 +63,15 @@ from metaobjects.meta.core.object.object_constants import OBJECT_SUBTYPE_VALUE from metaobjects.meta.meta_data import MetaData from metaobjects.meta.template import template_constants as tc +from metaobjects.render.engine import ( + TEMPLATE_ENGINE_JINJA, + TEMPLATE_ENGINE_MUSTACHE, + template_engine_of, +) from metaobjects.render.filesystem_provider import FilesystemProvider from metaobjects.render.verify import ( ERR_REQUIRED_SLOT_UNUSED, + ERR_VAR_NOT_ON_PAYLOAD, PayloadField, verify, ) @@ -251,24 +257,29 @@ def generate(self, ctx: GenContext) -> list[EmittedFile]: def _gate_ref( self, template_name: str, reference: str, fields: list[PayloadField] - ) -> None: - """Run the build-time drift gate for one referenced mustache. RAISES (fails + ) -> str: + """Run the build-time drift gate for one referenced template body. RAISES (fails codegen) when the ref is unresolvable OR ``verify`` reports a non-warning error. Warnings (``ERR_REQUIRED_SLOT_UNUSED``) are tolerated. Matches the cross-port - message style exactly.""" + message style exactly. Returns the engine the body is written for (ADR-0061).""" text = self._provider.resolve(reference) if text is None: raise ValueError( f'render-helper drift: template "{template_name}" ref "{reference}" ' "— unresolved (provider returned no text)" ) - for e in verify(text, fields, provider=self._provider): + engine = template_engine_of(self._provider, reference) + for e in verify(text, fields, provider=self._provider, engine=engine): if e.code == ERR_REQUIRED_SLOT_UNUSED: continue # warning + detail = ( + f"{{{{{e.path}}}}} not on payload VO" if e.code == ERR_VAR_NOT_ON_PAYLOAD else e.path + ) raise ValueError( f'render-helper drift: template "{template_name}" ref "{reference}" ' - f"— {e.code}: {{{{{e.path}}}}} not on payload VO" + f"— {e.code}: {detail}" ) + return engine # -- emission -------------------------------------------------------------- @@ -324,7 +335,7 @@ def _emit_document( max_chars = _max_chars_of(tmpl) # BUILD-TIME drift gate (THE headline) — raises on drift. - self._gate_ref(template_name, text_ref, fields) + engine = self._gate_ref(template_name, text_ref, fields) request_lines = [ " RenderRequest(", @@ -335,6 +346,7 @@ def _emit_document( ] if max_chars is not None: request_lines.append(f" max_chars={max_chars},") + request_lines.extend(_jinja_request_lines(engine, fields, " ")) request_lines.append(" )") lines = [ @@ -342,6 +354,7 @@ def _emit_document( "from __future__ import annotations", "", "from metaobjects.render.renderer import render, RenderRequest", + *_jinja_imports(engine), "", f"from .{payload_class} import {payload_class}", "", @@ -384,26 +397,30 @@ def _emit_email( raise ValueError( f'template.output "{template_name}" (email) missing @htmlBodyRef' ) - has_text = isinstance(text_body_ref, str) and bool(text_body_ref) + text_ref = text_body_ref if isinstance(text_body_ref, str) and text_body_ref else None + has_text = text_ref is not None # BUILD-TIME drift gate — every email part-ref is resolved + verified. - self._gate_ref(template_name, subject_ref, fields) - self._gate_ref(template_name, html_body_ref, fields) - if has_text: - self._gate_ref(template_name, text_body_ref, fields) - + subject_engine = self._gate_ref(template_name, subject_ref, fields) + html_engine = self._gate_ref(template_name, html_body_ref, fields) + text_engine = ( + self._gate_ref(template_name, text_ref, fields) if text_ref is not None else TEMPLATE_ENGINE_MUSTACHE + ) + pad = " " text_body_expr = ( "render(\n" " RenderRequest(\n" " payload=payload,\n" " provider=provider,\n" - f" ref={_py_str(text_body_ref)},\n" + f" ref={_py_str(text_ref or '')},\n" ' format="text",\n' - " )\n" + + "".join(f"{line}\n" for line in _jinja_request_lines(text_engine, fields, pad)) + + " )\n" " )" if has_text else "None" ) + any_jinja = TEMPLATE_ENGINE_JINJA in (subject_engine, html_engine, text_engine) lines = [ header, @@ -411,6 +428,7 @@ def _emit_email( "", "from metaobjects.render.email_document import EmailDocument", "from metaobjects.render.renderer import render, RenderRequest", + *_jinja_imports(TEMPLATE_ENGINE_JINJA if any_jinja else TEMPLATE_ENGINE_MUSTACHE), "", f"from .{payload_class} import {payload_class}", "", @@ -428,6 +446,7 @@ def _emit_email( " provider=provider,", f" ref={_py_str(subject_ref)},", ' format="text",', + *_jinja_request_lines(subject_engine, fields, pad), " )", " ),", " html_body=render(", @@ -436,6 +455,7 @@ def _emit_email( " provider=provider,", f" ref={_py_str(html_body_ref)},", ' format="html",', + *_jinja_request_lines(html_engine, fields, pad), " )", " ),", f" text_body={text_body_expr},", @@ -456,3 +476,23 @@ def render_helper_generator( """Factory mirroring the TS ``renderHelper()`` / C# ``RenderHelperGenerator`` / Java ``SpringRenderHelperGenerator`` constructors.""" return RenderHelperGenerator(template_root, filter=filter) + + +def _jinja_imports(engine: str) -> list[str]: + """The import a helper needs when it renders a Jinja body (ADR-0061); none for Mustache, + so a Mustache helper is byte-identical to what it was before Jinja existed.""" + if engine != TEMPLATE_ENGINE_JINJA: + return [] + return ["from metaobjects.render.verify import PayloadField"] + + +def _jinja_request_lines(engine: str, fields: list[PayloadField], pad: str) -> list[str]: + """For a Jinja body: name the engine (so a runtime provider that cannot tell, such as an + in-memory one, still renders it as Jinja) and run the payload check again at render + time against the field tree the build gate used.""" + if engine != TEMPLATE_ENGINE_JINJA: + return [] + return [ + f"{pad}engine={_py_str(TEMPLATE_ENGINE_JINJA)},", + f"{pad}verify={_field_tree_literal(fields)},", + ] diff --git a/server/python/src/metaobjects/errors.py b/server/python/src/metaobjects/errors.py index 47a6c47c88..0e27deaa5c 100644 --- a/server/python/src/metaobjects/errors.py +++ b/server/python/src/metaobjects/errors.py @@ -219,6 +219,11 @@ class ErrorCode(str, Enum): # letting one entity's route win. ERR_COLLECTION_NAME_COLLISION = "ERR_COLLECTION_NAME_COLLISION" ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING" + # ADR-0061 template engines: a Jinja reference the check cannot settle statically, a + # body that does not parse, and a body written for an engine a port does not ship. + ERR_TEMPLATE_NOT_STATIC = "ERR_TEMPLATE_NOT_STATIC" + ERR_TEMPLATE_SYNTAX = "ERR_TEMPLATE_SYNTAX" + ERR_TEMPLATE_ENGINE_UNSUPPORTED = "ERR_TEMPLATE_ENGINE_UNSUPPORTED" # SP-H Unit9 — @filterable: true on a field subtype with no filter-operator # band (e.g. field.object). Would silently generate an empty-ops filter. ERR_FILTERABLE_UNSUPPORTED_SUBTYPE = "ERR_FILTERABLE_UNSUPPORTED_SUBTYPE" diff --git a/server/python/src/metaobjects/render/__init__.py b/server/python/src/metaobjects/render/__init__.py index bd6b98a5f1..c0779448e6 100644 --- a/server/python/src/metaobjects/render/__init__.py +++ b/server/python/src/metaobjects/render/__init__.py @@ -1,7 +1,17 @@ """Render-tier engine: the build-time template drift-check ``verify`` (FR-004), the -FR-010 tolerant ``extract`` parser, and the FR-010 output-format prompt renderer.""" +FR-010 tolerant ``extract`` parser, and the FR-010 output-format prompt renderer. +Templates are Mustache by default; Jinja is the Python port's second engine (ADR-0061).""" from metaobjects.render.email_document import EmailDocument +from metaobjects.render.engine import ( + JINJA_EXTENSIONS, + MUSTACHE_EXTENSION, + TEMPLATE_ENGINE_JINJA, + TEMPLATE_ENGINE_MUSTACHE, + TEMPLATE_ENGINES, + engine_for_extension, + template_engine_of, +) from metaobjects.render.filesystem_provider import FilesystemProvider from metaobjects.render.prompt import ( PROMPT_OVERRIDES_NONE, @@ -31,6 +41,9 @@ ERR_OUTPUT_TAG_MISSING, ERR_PARTIAL_UNRESOLVED, ERR_REQUIRED_SLOT_UNUSED, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, ERR_VAR_NOT_ON_PAYLOAD, InMemoryProvider, PayloadField, @@ -43,7 +56,17 @@ "ERR_OUTPUT_TAG_MISSING", "ERR_PARTIAL_UNRESOLVED", "ERR_REQUIRED_SLOT_UNUSED", + "ERR_TEMPLATE_ENGINE_UNSUPPORTED", + "ERR_TEMPLATE_NOT_STATIC", + "ERR_TEMPLATE_SYNTAX", "ERR_VAR_NOT_ON_PAYLOAD", + "JINJA_EXTENSIONS", + "MUSTACHE_EXTENSION", + "TEMPLATE_ENGINES", + "TEMPLATE_ENGINE_JINJA", + "TEMPLATE_ENGINE_MUSTACHE", + "engine_for_extension", + "template_engine_of", "PROMPT_OVERRIDES_NONE", "EmailDocument", "FieldKind", diff --git a/server/python/src/metaobjects/render/engine.py b/server/python/src/metaobjects/render/engine.py new file mode 100644 index 0000000000..5cbf0c1b49 --- /dev/null +++ b/server/python/src/metaobjects/render/engine.py @@ -0,0 +1,79 @@ +"""Which template engine a body is written for (ADR-0061). + +Mustache is the default and the cross-port engine. Jinja is a per-port second engine +(Python and TypeScript): a body is Jinja when its FILE says so — a ``.jinja`` / ``.j2`` / +``.jinja2`` extension beside the usual ``.mustache`` — or when the caller says so with an +explicit ``engine``. The metadata never names an engine: ``@textRef`` stays a logical +``group/source`` reference, so the portable vocabulary carries nothing one port +understands and another does not. Mirrors ``server/typescript/packages/render/src/engine.ts``. +""" + +from __future__ import annotations + +from typing import Any + +TEMPLATE_ENGINE_MUSTACHE = "mustache" +TEMPLATE_ENGINE_JINJA = "jinja" + +#: The engines ``render`` and ``verify`` accept. +TEMPLATE_ENGINES = (TEMPLATE_ENGINE_MUSTACHE, TEMPLATE_ENGINE_JINJA) + +#: The Mustache file extension — the default in every port. +MUSTACHE_EXTENSION = ".mustache" + +#: File extensions that mark a body as Jinja, in lookup order. +JINJA_EXTENSIONS = (".jinja", ".j2", ".jinja2") + + +def engine_for_extension(extension: str) -> str: + """The engine a file extension (with its leading dot) selects. Anything else is Mustache.""" + return TEMPLATE_ENGINE_JINJA if extension.lower() in JINJA_EXTENSIONS else TEMPLATE_ENGINE_MUSTACHE + + +def partial_engine_mismatch(provider: Any, ref: str, including: str) -> bool: + """A partial written for the other engine. A Mustache body includes Mustache partials + and a Jinja body includes Jinja templates; when the provider reports that ``ref`` is the + other engine's file, the include is refused (``ERR_TEMPLATE_ENGINE_UNSUPPORTED``) rather + than spliced in as text the including engine would misread.""" + engine_of = getattr(provider, "engine_of", None) + if not callable(engine_of): + return False + known = engine_of(ref) + return known is not None and known != including + + +def partial_engine_message(ref: str, including: str) -> str: + """The message a refused cross-engine partial raises at render time.""" + return ( + f"ERR_TEMPLATE_ENGINE_UNSUPPORTED: partial {ref} is not a {including} template; " + f"a {including} body includes only {including} partials" + ) + + +def template_engine_of(provider: Any, ref: str | None, explicit: str | None = None) -> str: + """The engine for ``ref`` resolved through ``provider``: an explicit choice wins, then + what the provider knows about the ref (its ``engine_of``, from the file extension), then + Mustache. ``engine_of`` is optional on a provider.""" + if explicit is not None: + if explicit not in TEMPLATE_ENGINES: + raise ValueError(f"unknown template engine {explicit!r}; expected one of {TEMPLATE_ENGINES}") + return explicit + engine_of = getattr(provider, "engine_of", None) + if ref is not None and callable(engine_of): + known = engine_of(ref) + if known is not None: + return str(known) + return TEMPLATE_ENGINE_MUSTACHE + + +__all__ = [ + "JINJA_EXTENSIONS", + "MUSTACHE_EXTENSION", + "TEMPLATE_ENGINES", + "TEMPLATE_ENGINE_JINJA", + "TEMPLATE_ENGINE_MUSTACHE", + "engine_for_extension", + "partial_engine_message", + "partial_engine_mismatch", + "template_engine_of", +] diff --git a/server/python/src/metaobjects/render/filesystem_provider.py b/server/python/src/metaobjects/render/filesystem_provider.py index cb9a6541ac..ea287f54ef 100644 --- a/server/python/src/metaobjects/render/filesystem_provider.py +++ b/server/python/src/metaobjects/render/filesystem_provider.py @@ -1,6 +1,8 @@ """Filesystem-backed :class:`~metaobjects.render.verify.Provider`. -Resolves a ``group/source`` reference to ``/group/source.mustache``. The render +Resolves a ``group/source`` reference to ``/group/source.mustache`` — or, when no +Mustache file exists, to a Jinja one (``.jinja`` / ``.j2`` / ``.jinja2``, ADR-0061), and +reports which engine the file it found is written for. The render engine + verify delegate all I/O to a provider, so this is the only file-touching piece of the render tier. Mirrors the C# ``MetaObjects.Render.FilesystemProvider`` and the Java ``com.metaobjects.render.FilesystemProvider`` semantics: read the file if present, return @@ -11,41 +13,58 @@ from pathlib import Path +from .engine import JINJA_EXTENSIONS, MUSTACHE_EXTENSION, engine_for_extension + class FilesystemProvider: """Resolves ``group/source`` references to text files under a root directory: - ``resolve("npc/turn")`` → ``/npc/turn.mustache``. Returns ``None`` when the file - is absent. Refs that escape the root (via ``..``) are rejected. + ``resolve("npc/turn")`` → ``/npc/turn.mustache``, else the first of + ``turn.jinja`` / ``turn.j2`` / ``turn.jinja2`` that exists. Returns ``None`` when no + file is present. Refs that escape the root (via ``..``) are rejected. + + Pass ``extension`` to look for that one extension only. - Implements the :class:`~metaobjects.render.verify.Provider` protocol. + Implements the :class:`~metaobjects.render.verify.Provider` protocol, including the + optional ``engine_of``. """ - def __init__(self, root: str | Path, extension: str = ".mustache") -> None: + def __init__(self, root: str | Path, extension: str | None = None) -> None: self._root = Path(root).resolve() - self._extension = extension + self._extensions: tuple[str, ...] = ( + (extension,) if extension is not None else (MUSTACHE_EXTENSION, *JINJA_EXTENSIONS) + ) def resolve(self, ref: str | None) -> str | None: - # Build // from the slash-separated ref, dropping empties. - if not ref: + found = self._find(ref) + if found is None: return None - segments = [s for s in ref.split("/") if s] - if not segments or any(s == ".." for s in segments): - return None - - base = self._root.joinpath(*segments) - candidate = base.with_name(base.name + self._extension) try: - resolved = candidate.resolve() + return found[0].read_text(encoding="utf-8") except OSError: return None - # Path-traversal guard: the resolved file must stay under the root. - if resolved != self._root and self._root not in resolved.parents: - return None + def engine_of(self, ref: str) -> str | None: + """The engine the file ``ref`` resolves to is written for, from its extension.""" + found = self._find(ref) + return None if found is None else engine_for_extension(found[1]) - if not resolved.is_file(): + def _find(self, ref: str | None) -> tuple[Path, str] | None: + # Build // from the slash-separated ref, dropping empties. + if not ref: return None - try: - return resolved.read_text(encoding="utf-8") - except OSError: + segments = [s for s in ref.split("/") if s] + if not segments or any(s == ".." for s in segments): return None + base = self._root.joinpath(*segments) + for extension in self._extensions: + candidate = base.with_name(base.name + extension) + try: + resolved = candidate.resolve() + except OSError: + continue + # Path-traversal guard: the resolved file must stay under the root. + if resolved != self._root and self._root not in resolved.parents: + continue + if resolved.is_file(): + return resolved, extension + return None diff --git a/server/python/src/metaobjects/render/jinja_render.py b/server/python/src/metaobjects/render/jinja_render.py new file mode 100644 index 0000000000..a283dc215d --- /dev/null +++ b/server/python/src/metaobjects/render/jinja_render.py @@ -0,0 +1,133 @@ +"""Render a Jinja body (ADR-0061) with jinja2, through the same provider and the same +format-driven escapers as the Mustache path. + +Output matches the TypeScript port (nunjucks) for the shared Jinja vocabulary — gated by +``fixtures/jinja-render-conformance/``. These settings make that hold: + +* every ``{{ … }}`` value is printed the way the Mustache renderer prints it (``True`` → + ``true``, ``1.0`` → ``1``, ``None`` → empty) and then escaped for the request's format; + ``| safe`` marks a value as already-escaped and bypasses the escaper, and ``| escape`` / + ``| e`` / ``| forceescape`` escape with that same escaper, as in nunjucks; +* what a macro, a ``call`` block or a block ``set`` renders is already escaped, so printing + it does not escape it again (nunjucks returns those as safe strings); +* a lookup on a missing key renders empty however deep it chains (``v.x.y``), as in + nunjucks; every engine failure surfaces as ``JinjaRenderError``; +* a trailing newline is kept (jinja2 drops it by default; nunjucks and Mustache keep it); +* the static template graph is checked first, so a cycle or a missing template fails with + the Mustache path's messages (``partial cycle:``, ``unresolved partial:``). +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Mapping +from typing import Any + +from . import escapers +from .engine import TEMPLATE_ENGINE_JINJA, partial_engine_message, partial_engine_mismatch +from .jinja_verify import jinja2_module, parse_jinja, ref_of_template_name +from .renderer import _stringify +from .verify import MAX_DEPTH, Provider + + +class JinjaRenderError(Exception): + """A Jinja render failed before or during template evaluation.""" + + +class _Rendered(str): + """Text a template already rendered (a macro, a ``call`` body, a block ``set``): its + values were escaped as they were printed, so printing it again must not escape twice.""" + + +def _concat(parts: Any) -> _Rendered: + return _Rendered("".join(parts)) + + +def _context(payload: Any) -> dict[str, Any]: + """The template's top-level names. A Pydantic model (a generated payload model, + ADR-0056) is its JSON-mode dump, as on the Mustache path: field names as keys, enums as + their wire values. A mapping, a dataclass or a plain object is read by its fields; + anything else has no names a template could address, and is refused.""" + dump = getattr(payload, "model_dump", None) + if callable(dump) and not isinstance(payload, type): + payload = dump(mode="json") + if isinstance(payload, Mapping): + return dict(payload) + if dataclasses.is_dataclass(payload) and not isinstance(payload, type): + return {f.name: getattr(payload, f.name) for f in dataclasses.fields(payload)} + if hasattr(payload, "__dict__") and not isinstance(payload, type): + return {k: v for k, v in vars(payload).items() if not k.startswith("_")} + raise JinjaRenderError(f"a Jinja payload must be a mapping or an object, not {type(payload).__name__}") + + +def _template_refs(text: str) -> list[tuple[str, bool]]: + jinja2 = jinja2_module() + nodes = jinja2.nodes + out: list[tuple[str, bool]] = [] + for n in parse_jinja(text).find_all((nodes.Include, nodes.Import, nodes.FromImport, nodes.Extends)): + t = n.template + if isinstance(t, nodes.Const) and isinstance(t.value, str): + out.append((ref_of_template_name(t.value), bool(getattr(n, "ignore_missing", False)))) + return out + + +def _check_template_graph(text: str, provider: Provider, path: list[str]) -> None: + for ref, optional in _template_refs(text): + if ref in path: + raise JinjaRenderError(f"partial cycle: {' -> '.join([*path, ref])}") + if len(path) >= MAX_DEPTH: + raise JinjaRenderError(f"partial depth exceeded {MAX_DEPTH}: {ref}") + body = provider.resolve(ref) + if body is None: + if optional: + continue + raise JinjaRenderError(f"unresolved partial: {ref}") + if partial_engine_mismatch(provider, ref, TEMPLATE_ENGINE_JINJA): + raise JinjaRenderError(partial_engine_message(ref, TEMPLATE_ENGINE_JINJA)) + _check_template_graph(body, provider, [*path, ref]) + + +def render_jinja(body: str, ref: str | None, payload: Any, provider: Provider, format_: str) -> str: + """Render Jinja ``body`` (whose own ref, when it has one, is ``ref``) against ``payload``.""" + jinja2 = jinja2_module() + from markupsafe import Markup + + try: + _check_template_graph(body, provider, [ref] if ref is not None else []) + except jinja2.TemplateSyntaxError as e: + raise JinjaRenderError(f"template syntax error: {e.message}") from e + + def forceescape(value: Any) -> Markup: + if value is None or isinstance(value, jinja2.Undefined): + return Markup("") + return Markup(escapers.escape(format_, _stringify(value))) + + def escape(value: Any) -> Any: + return value if isinstance(value, (Markup, _Rendered)) else forceescape(value) + + def finalize(value: Any) -> str: + return str(escape(value)) + + def load(name: str) -> tuple[str, None, Any] | None: + src = provider.resolve(ref_of_template_name(name)) + return None if src is None else (src, None, lambda: False) + + env = jinja2.Environment( + loader=jinja2.FunctionLoader(load), + autoescape=False, + keep_trailing_newline=True, + finalize=finalize, + undefined=jinja2.ChainableUndefined, + ) + env.concat = _concat + env.filters.update(escape=escape, e=escape, forceescape=forceescape) + context = _context(payload) + try: + return str(env.from_string(body).render(**context)) + except jinja2.TemplateSyntaxError as e: + raise JinjaRenderError(f"template syntax error: {e.message}") from e + except (jinja2.TemplateError, TypeError, ValueError, AttributeError, KeyError) as e: + raise JinjaRenderError(f"jinja render failed: {type(e).__name__}: {e}") from e + + +__all__ = ["JinjaRenderError", "render_jinja"] diff --git a/server/python/src/metaobjects/render/jinja_verify.py b/server/python/src/metaobjects/render/jinja_verify.py new file mode 100644 index 0000000000..cfe42a2666 --- /dev/null +++ b/server/python/src/metaobjects/render/jinja_verify.py @@ -0,0 +1,506 @@ +"""The Jinja half of the template drift check (ADR-0061). + +Same contract as the Mustache :func:`~metaobjects.render.verify.verify`: every payload +reference a template makes must exist on the declared payload field tree, or it is +reported. It walks the PARSED jinja2 AST — never the text — and follows what Jinja code +actually does: + +* ``{{ user.name }}`` / ``{{ user["name"] }}`` — a path into the payload, checked per segment +* ``{% for p in posts %}{{ p.title }}`` — ``posts`` is checked, then ``p.title`` against + posts' element fields +* ``{% set x = … %}`` / macro params — locals, not payload; never reported +* ``{{ name | upper }}`` — the argument is checked, the filter is not +* ``{% if %}`` / ``{{ a if b else c }}`` — every operand is checked +* ``{% include "x" %}`` / import / extends — resolved through the provider, walked + +A reference the check cannot settle without running the template — a dynamic key on an +object, a computed template name — is ``ERR_TEMPLATE_NOT_STATIC``: reported, never passed. + +The TypeScript port (``packages/render/src/jinja/verify.ts``) walks the nunjucks AST to the +same rules; both are held to ``fixtures/jinja-verify-conformance/``. jinja2 is an optional +dependency (the ``jinja`` extra); it is imported only when a Jinja template is checked. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any + +from .engine import JINJA_EXTENSIONS, TEMPLATE_ENGINE_JINJA, partial_engine_mismatch +from .verify import ( + ERR_PARTIAL_UNRESOLVED, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_VAR_NOT_ON_PAYLOAD, + MAX_DEPTH, + PayloadField, + Provider, + VerifyError, + slot_and_tag_findings, +) + +#: Names a template may use without the payload declaring them: the engines' own globals +#: and block helpers. Shared with the TypeScript port. +JINJA_GLOBALS = frozenset( + {"range", "cycler", "joiner", "namespace", "dict", "lipsum", "super", "self"} +) + +_ELEMENT_FILTERS = frozenset({"first", "last", "random"}) +_SAME_SHAPE_FILTERS = frozenset( + {"sort", "reverse", "list", "unique", "selectattr", "rejectattr", "default", "d"} +) +#: Filters with a positional argument that names an element attribute, and its index among +#: the arguments after the subject (``join(", ", "name")``, ``sort(false, false, "name")``). +#: The same positions in jinja2 and nunjucks; ``min``, ``max`` and ``unique`` are jinja2's. +_ATTRIBUTE_ARG_INDEX = { + "selectattr": 0, + "rejectattr": 0, + "groupby": 0, + "sum": 0, + "join": 1, + "min": 1, + "max": 1, + "unique": 1, + "sort": 2, +} +_ATTRIBUTE_KWARG = "attribute" + + +def jinja2_module() -> Any: + """Import jinja2, or explain how to get it. Jinja is an optional extra.""" + try: + import jinja2 + except ImportError as e: # pragma: no cover - exercised only without the extra + raise ImportError( + "a Jinja template needs jinja2: install metaobjects with the 'jinja' extra " + "(pip install 'metaobjects[jinja]')" + ) from e + return jinja2 + + +def ref_of_template_name(name: str) -> str: + """The provider ref a Jinja template name addresses: a trailing ``.jinja`` / ``.j2`` / + ``.jinja2`` is dropped, so ``"parts/footer.jinja"`` and ``"parts/footer"`` agree.""" + lower = name.lower() + for ext in JINJA_EXTENSIONS: + if lower.endswith(ext): + return name[: -len(ext)] + return name + + +def parse_jinja(text: str) -> Any: + """Parse Jinja text into a jinja2 ``nodes.Template``. Raises ``TemplateSyntaxError``.""" + jinja2 = jinja2_module() + return jinja2.Environment(keep_trailing_newline=True).parse(text) + + +@dataclass(frozen=True) +class _Typed: + """A value whose shape the payload declares. ``path`` is the expression as written.""" + + fields: list[PayloadField] | None + is_array: bool + path: str + + +_OPAQUE: Any = None # a local, a literal or a computed value: nothing to check on it + + +@dataclass +class _Scope: + frames: list[dict[str, Any]] + root: list[PayloadField] + seen: tuple[str, ...] = field(default=()) + + +def _typed_of(f: PayloadField, path: str) -> _Typed: + return _Typed(f.fields, f.is_array, path) + + +def _element_of(v: Any, path: str) -> Any: + if not isinstance(v, _Typed): + return _OPAQUE + # An array iterates its elements; a non-array object its keys; a scalar its characters. + return _Typed(v.fields if v.is_array else None, False, path) + + +def verify_jinja( + template_text: str, + fields: list[PayloadField], + *, + provider: Provider | None = None, + required_slots: list[str] | None = None, + required_tags: list[str] | None = None, +) -> list[VerifyError]: + """Check a Jinja template against a payload field tree. Same options and codes as + :func:`~metaobjects.render.verify.verify`; reached through ``verify(..., engine="jinja")``.""" + jinja2 = jinja2_module() + nodes = jinja2.nodes + errors: list[VerifyError] = [] + referenced_at_root: set[str] = set() + static_texts: list[str] = [template_text] + + def report(code: str, path: str) -> None: + errors.append(VerifyError(code, path)) + + def lookup_name(name: str, scope: _Scope) -> Any: + for frame in reversed(scope.frames): + if name in frame: + return frame[name] if frame[name] is not None else _LOCAL + hit = next((f for f in scope.root if f.name == name), None) + if hit is not None: + if scope.root is fields: + referenced_at_root.add(name) + return _typed_of(hit, name) + return _LOCAL if name in JINJA_GLOBALS else None + + def bind(scope: _Scope, name: str, value: Any) -> None: + scope.frames[-1][name] = value + + def push_frame(scope: _Scope) -> _Scope: + return replace(scope, frames=[*scope.frames, {}]) + + # ---- member chains ------------------------------------------------------- + + def chain_of(node: Any) -> tuple[Any, list[tuple[str, Any]]]: + segments: list[tuple[str, Any]] = [] + cur = node + while True: + if isinstance(cur, nodes.Getattr): + segments.insert(0, ("key", cur.attr)) + cur = cur.node + elif isinstance(cur, nodes.Getitem) and not isinstance(cur.arg, nodes.Slice): + arg = cur.arg + if isinstance(arg, nodes.Const) and isinstance(arg.value, str): + segments.insert(0, ("key", arg.value)) + elif ( + isinstance(arg, nodes.Const) + and isinstance(arg.value, int) + and not isinstance(arg.value, bool) + ): + segments.insert(0, ("index", arg.value)) + else: + segments.insert(0, ("dynamic", arg)) + cur = cur.node + else: + return cur, segments + + def seg_text(kind: str, value: Any) -> str: + if kind == "key": + return f".{value}" + if kind == "index": + return f"[{value}]" + return "[*]" + + def eval_chain(node: Any, scope: _Scope) -> Any: + base, segments = chain_of(node) + for kind, value in segments: + if kind == "dynamic": + eval_expr(value, scope) + suffix = "".join(seg_text(k, v) for k, v in segments) + if isinstance(base, nodes.Name): + path = base.name + hit = lookup_name(path, scope) + if hit is None: + report(ERR_VAR_NOT_ON_PAYLOAD, path + suffix) + return _OPAQUE + value_: Any = hit + else: + value_ = eval_expr(base, scope) + path = value_.path if isinstance(value_, _Typed) else "" + full_path = path + suffix + for kind, seg in segments: + if not isinstance(value_, _Typed): + return _OPAQUE + path += seg_text(kind, seg) + if kind == "key": + child = ( + None + if value_.is_array + else next((f for f in value_.fields or [] if f.name == seg), None) + ) + if child is None: + report(ERR_VAR_NOT_ON_PAYLOAD, full_path) + return _OPAQUE + value_ = _typed_of(child, path) + elif value_.is_array: + value_ = _Typed(value_.fields, False, path) + elif kind == "dynamic" and value_.fields is not None: + report(ERR_TEMPLATE_NOT_STATIC, full_path) + return _OPAQUE + else: + value_ = _Typed(value_.fields, False, path) + return value_ + + # ---- filters, tests, calls --------------------------------------------- + + def check_attribute_name(receiver: Any, attr_node: Any, scope: _Scope) -> None: + if not isinstance(receiver, _Typed): + eval_expr(attr_node, scope) + return + if not (isinstance(attr_node, nodes.Const) and isinstance(attr_node.value, str)): + eval_expr(attr_node, scope) + report(ERR_TEMPLATE_NOT_STATIC, f"{receiver.path}[*]") + return + attr = attr_node.value + current = receiver.fields + for seg in attr.split("."): + hit = next((f for f in current or [] if f.name == seg), None) + if hit is None: + report(ERR_VAR_NOT_ON_PAYLOAD, f"{receiver.path}.{attr}") + return + current = hit.fields + + def eval_extra_args(node: Any, scope: _Scope) -> None: + for extra in (getattr(node, "dyn_args", None), getattr(node, "dyn_kwargs", None)): + if extra is not None: + eval_expr(extra, scope) + + def eval_filter(node: Any, scope: _Scope, subject: Any) -> Any: + receiver = _OPAQUE if subject is None else eval_expr(subject, scope) + for i, a in enumerate(node.args): + if i == _ATTRIBUTE_ARG_INDEX.get(node.name): + check_attribute_name(receiver, a, scope) + else: + eval_expr(a, scope) + for kw in node.kwargs: + if kw.key == _ATTRIBUTE_KWARG: + check_attribute_name(receiver, kw.value, scope) + else: + eval_expr(kw.value, scope) + eval_extra_args(node, scope) + if node.name in _ELEMENT_FILTERS: + if isinstance(receiver, _Typed): + return _element_of(receiver, f"{receiver.path}|{node.name}") + return _OPAQUE + if node.name in _SAME_SHAPE_FILTERS: + return receiver + return _OPAQUE + + def eval_call(node: Any, scope: _Scope) -> Any: + callee = node.node + if isinstance(callee, nodes.Getattr): + # A method call: the receiver is checked; the method belongs to the value. + eval_expr(callee.node, scope) + elif isinstance(callee, nodes.Name): + if lookup_name(callee.name, scope) is None: + report(ERR_VAR_NOT_ON_PAYLOAD, callee.name) + else: + eval_expr(callee, scope) + for a in node.args: + eval_expr(a, scope) + for kw in node.kwargs: + eval_expr(kw.value, scope) + eval_extra_args(node, scope) + return _OPAQUE + + def walk_callable(args: list[Any], defaults: list[Any], body: list[Any], scope: _Scope) -> None: + for d in defaults: + eval_expr(d, scope) # a default is evaluated where it is declared + inner = push_frame(scope) + for local in ("caller", "varargs", "kwargs"): + bind(inner, local, _OPAQUE) + for a in args: + bind(inner, a.name, _OPAQUE) + walk_all(body, inner) + + # ---- include / import / extends ---------------------------------------- + + def follow_template( + keyword: str, template_node: Any, scope: _Scope, into: Any, ignore_missing: bool = False + ) -> None: + if not (isinstance(template_node, nodes.Const) and isinstance(template_node.value, str)): + eval_expr(template_node, scope) + report(ERR_TEMPLATE_NOT_STATIC, keyword) + return + if provider is None: + return + ref = ref_of_template_name(template_node.value) + if ref in scope.seen or len(scope.seen) >= MAX_DEPTH: + return + text = provider.resolve(ref) + if text is None: + if not ignore_missing: + report(ERR_PARTIAL_UNRESOLVED, ref) + return + if partial_engine_mismatch(provider, ref, TEMPLATE_ENGINE_JINJA): + report(ERR_TEMPLATE_ENGINE_UNSUPPORTED, ref) + return + static_texts.append(text) + try: + ast = parse_jinja(text) + except jinja2.TemplateSyntaxError as e: + report(ERR_TEMPLATE_SYNTAX, f"{ref}: {e.message}") + return + walk_all(ast.body, into(scope, ref)) + + def bind_target(target: Any, value: Any, scope: _Scope) -> None: + if isinstance(target, nodes.Name): + bind(scope, target.name, value) + elif isinstance(target, nodes.NSRef): + if lookup_name(target.name, scope) is None: + report(ERR_VAR_NOT_ON_PAYLOAD, target.name) + else: + for t in getattr(target, "items", []): + bind_target(t, _OPAQUE, scope) + + # ---- expressions ------------------------------------------------------- + + def eval_expr(node: Any, scope: _Scope) -> Any: + if isinstance(node, (nodes.Name, nodes.Getattr)) or ( + isinstance(node, nodes.Getitem) and not isinstance(node.arg, nodes.Slice) + ): + value = eval_chain(node, scope) + return None if value is _LOCAL else value + if isinstance(node, nodes.Getitem): # a slice keeps the receiver's shape + receiver = eval_expr(node.node, scope) + eval_expr(node.arg, scope) + return receiver + if isinstance(node, (nodes.Const, nodes.TemplateData)): + return _OPAQUE + if isinstance(node, nodes.Filter): + return eval_filter(node, scope, node.node) + if isinstance(node, nodes.Test): + # `x is defined` / `x is divisibleby(3)`: the subject is checked, the test is not. + eval_expr(node.node, scope) + for a in node.args: + eval_expr(a, scope) + for kw in node.kwargs: + eval_expr(kw.value, scope) + eval_extra_args(node, scope) + return _OPAQUE + if isinstance(node, nodes.Call): + return eval_call(node, scope) + if isinstance(node, nodes.Pair): + # A dict literal entry: a bare-name key is the key's own spelling, not a lookup. + if not isinstance(node.key, nodes.Name): + eval_expr(node.key, scope) + eval_expr(node.value, scope) + return _OPAQUE + for child in node.iter_child_nodes(): + eval_expr(child, scope) + return _OPAQUE + + # ---- statements -------------------------------------------------------- + + def walk_all(body: list[Any], scope: _Scope) -> None: + for n in body: + walk(n, scope) + + def walk(node: Any, scope: _Scope) -> None: + if isinstance(node, nodes.Output): + for child in node.nodes: + eval_expr(child, scope) + elif isinstance(node, nodes.If): + eval_expr(node.test, scope) + # An `if` opens no scope: a `set` inside it is visible after it, as in Jinja. + walk_all(node.body, scope) + for elif_ in node.elif_: + walk(elif_, scope) + walk_all(node.else_, scope) + elif isinstance(node, nodes.For): + iterable = eval_expr(node.iter, scope) + inner = push_frame(scope) + bind(inner, "loop", _OPAQUE) + if isinstance(node.target, nodes.Name): + bind(inner, node.target.name, _element_of(iterable, node.target.name)) + else: + bind_target(node.target, _OPAQUE, inner) + if node.test is not None: + eval_expr(node.test, inner) + walk_all(node.body, inner) + walk_all(node.else_, scope) + elif isinstance(node, nodes.Assign): + value = eval_expr(node.node, scope) + if isinstance(node.target, nodes.Name): + bind( + scope, + node.target.name, + replace(value, path=node.target.name) if isinstance(value, _Typed) else _OPAQUE, + ) + else: + bind_target(node.target, _OPAQUE, scope) + elif isinstance(node, nodes.AssignBlock): + walk_all(node.body, scope) + if node.filter is not None: + eval_filter(node.filter, scope, None) + bind_target(node.target, _OPAQUE, scope) + elif isinstance(node, nodes.Macro): + bind(scope, node.name, _OPAQUE) + walk_callable(node.args, node.defaults, node.body, scope) + elif isinstance(node, nodes.CallBlock): + eval_call(node.call, scope) + walk_callable(node.args, node.defaults, node.body, scope) + elif isinstance(node, nodes.FilterBlock): + walk_all(node.body, scope) + eval_filter(node.filter, scope, None) + elif isinstance(node, nodes.Include): + follow_template( + "include", + node.template, + scope, + lambda s, ref: replace(push_frame(s), seen=(*s.seen, ref)), + node.ignore_missing, + ) + elif isinstance(node, (nodes.Import, nodes.FromImport)): + with_context = node.with_context + follow_template( + "import", + node.template, + scope, + lambda s, ref: _Scope([{}], fields if with_context else [], (*s.seen, ref)), + ) + if isinstance(node, nodes.Import): + bind(scope, node.target, _OPAQUE) + else: + for n in node.names: + bind(scope, n[1] if isinstance(n, tuple) else n, _OPAQUE) + elif isinstance(node, nodes.Extends): + follow_template( + "extends", node.template, scope, lambda s, ref: _Scope([{}], fields, (*s.seen, ref)) + ) + elif isinstance(node, nodes.Block): + walk_all(node.body, scope) + elif isinstance(node, nodes.With): + inner = push_frame(scope) + for target, value in zip(node.targets, node.values): + v = eval_expr(value, scope) + bind_target(target, v if isinstance(v, _Typed) else _OPAQUE, inner) + walk_all(node.body, inner) + elif isinstance(node, nodes.Scope): + walk_all(node.body, push_frame(scope)) + elif isinstance(node, nodes.Stmt): + for child in node.iter_child_nodes(): + if isinstance(child, nodes.Stmt): + walk(child, scope) + else: + eval_expr(child, scope) + else: + eval_expr(node, scope) + + try: + ast = parse_jinja(template_text) + except jinja2.TemplateSyntaxError as e: + return [VerifyError(ERR_TEMPLATE_SYNTAX, e.message or str(e))] + walk_all(ast.body, _Scope([{}], fields)) + errors.extend( + slot_and_tag_findings(referenced_at_root, static_texts, required_slots, required_tags) + ) + return errors + + +class _LocalMarker: + """A name the template binds itself (or an engine global): resolvable, shapeless.""" + + +_LOCAL: Any = _LocalMarker() + + +__all__ = [ + "JINJA_GLOBALS", + "jinja2_module", + "parse_jinja", + "ref_of_template_name", + "verify_jinja", +] diff --git a/server/python/src/metaobjects/render/renderer.py b/server/python/src/metaobjects/render/renderer.py index 02642d280b..feb23860d5 100644 --- a/server/python/src/metaobjects/render/renderer.py +++ b/server/python/src/metaobjects/render/renderer.py @@ -23,10 +23,22 @@ from typing import Any from . import escapers +from .engine import ( + TEMPLATE_ENGINE_JINJA, + TEMPLATE_ENGINE_MUSTACHE, + partial_engine_message, + partial_engine_mismatch, + template_engine_of, +) from .payload_accessors import with_derived_accessors -from .verify import InMemoryProvider, Provider - -MAX_DEPTH = 32 +from .verify import ( + ERR_REQUIRED_SLOT_UNUSED, + MAX_DEPTH, + InMemoryProvider, + PayloadField, + Provider, +) +from .verify import verify as verify_template # {{> name }} — captures the reference between the marker and the closing }}. _PARTIAL_PATTERN = re.compile(r"\{\{>\s*([^\s}]+)\s*\}\}") @@ -46,6 +58,13 @@ class RenderRequest: template body. ``format`` defaults to ``"text"`` (no escaping). ``max_chars`` is an optional fail-closed budget: if the final output exceeds it, render RAISES (never truncates). + + ``engine`` is the engine the body is written for (ADR-0061): default is what + the provider reports for ``ref`` (a ``.jinja`` / ``.j2`` file is Jinja), else + Mustache. Inline ``template`` text has no file, so it is Mustache unless this + says otherwise. ``verify`` (optional) is the payload field tree: when given, + the template is drift-checked before it renders and a finding RAISES — the + runtime half of the build-time check. """ payload: Any @@ -54,21 +73,47 @@ class RenderRequest: ref: str | None = None format: str = escapers.FORMAT_TEXT max_chars: int | None = None + engine: str | None = None + verify: list[PayloadField] | None = None def render(req: RenderRequest) -> str: _validate(req) body = req.template if req.template is not None else _resolve_or_raise(req.provider, req.ref) + own_ref = req.ref if req.template is None else None + engine = template_engine_of(req.provider, own_ref, req.engine) + if req.verify is not None: + drift = [ + e + for e in verify_template(body, req.verify, provider=req.provider, engine=engine) + if e.code != ERR_REQUIRED_SLOT_UNUSED + ] + if drift: + raise RenderError( + "render verify failed: " + ", ".join(f"{e.code} ({e.path})" for e in drift) + ) + if engine == TEMPLATE_ENGINE_JINJA: + from .jinja_render import JinjaRenderError, render_jinja + + try: + out = render_jinja(body, own_ref, req.payload, req.provider, req.format) + except JinjaRenderError as e: + raise RenderError(str(e)) from e + return _check_budget(out, req.max_chars) expanded = _pre_expand_partials(body, req.provider, []) # Derived `has` accessors are part of the payload contract, not of the # caller's object — see payload_accessors. Injected here so a `{{#hasFoo}}` section # resolves the same way it does against a generated JVM payload record. out = _interpret(expanded, with_derived_accessors(req.payload), req.format) + return _check_budget(out, req.max_chars) + + +def _check_budget(out: str, max_chars: int | None) -> str: # @maxChars is a fail-closed render budget: over-budget output RAISES (never # silently truncates). Canonical cross-port behavior — message shape matches # TS/C#/Java: "render exceeded maxChars budget: > ". - if req.max_chars is not None and len(out) > req.max_chars: - raise RenderError(f"render exceeded maxChars budget: {len(out)} > {req.max_chars}") + if max_chars is not None and len(out) > max_chars: + raise RenderError(f"render exceeded maxChars budget: {len(out)} > {max_chars}") return out @@ -108,6 +153,8 @@ def replace(match: re.Match[str]) -> str: body = provider.resolve(ref) if body is None: raise RenderError(f"unresolved partial: {ref}") + if partial_engine_mismatch(provider, ref, TEMPLATE_ENGINE_MUSTACHE): + raise RenderError(partial_engine_message(ref, TEMPLATE_ENGINE_MUSTACHE)) stack.append(ref) try: return _pre_expand_partials(body, provider, stack) diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py index bb32646403..ed6fb9cbda 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -23,18 +23,31 @@ from dataclasses import dataclass from typing import Protocol +from .engine import TEMPLATE_ENGINE_JINJA, TEMPLATE_ENGINE_MUSTACHE, partial_engine_mismatch from .payload_accessors import is_boolean_accessor, is_positional_accessor #: A ``{{var}}`` references a field the (contextual) payload does not declare. ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD" -#: A ``{{> ref}}`` partial does not resolve in the provider. +#: A ``{{> ref}}`` partial (or a Jinja ``include`` / ``import`` / ``extends``) does not +#: resolve in the provider. ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED" #: A declared @requiredSlots slot is never referenced by the template (warning). ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED" #: A declared @requiredTags output tag is absent from the template text. ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING" - -_MAX_DEPTH = 32 +#: Jinja: a payload reference the check cannot resolve without running the template — a +#: dynamic key on an object (``user[key]``), a computed ``attribute=`` argument, or a +#: template name that is an expression (``{% include name %}``). Reported, never passed. +ERR_TEMPLATE_NOT_STATIC = "ERR_TEMPLATE_NOT_STATIC" +#: Jinja: the template text does not parse. path = the parser's message. +ERR_TEMPLATE_SYNTAX = "ERR_TEMPLATE_SYNTAX" +#: A body is written for an engine this port does not run (Java, Kotlin and C# run +#: Mustache only, so a ``.jinja`` template there is refused with this code). +ERR_TEMPLATE_ENGINE_UNSUPPORTED = "ERR_TEMPLATE_ENGINE_UNSUPPORTED" + +#: How deep partial / include / import / extends resolution recurses before it stops. +#: Shared by the Mustache and Jinja walks, and by both renderers. +MAX_DEPTH = 32 #: An opening tag is ```` or XML whitespace, so #: attributes are allowed (````) but a longer name is not over-matched #: (```` does not satisfy ``answer``). @@ -70,20 +83,32 @@ class VerifyError: class Provider(Protocol): - """Resolves a ``{{> group/source}}`` partial ref to its body text, or ``None``.""" + """Resolves a ``{{> group/source}}`` partial ref to its body text, or ``None``. + + A provider MAY also define ``engine_of(ref) -> str | None`` — the engine the ref's + text is written for (a filesystem provider knows from the file extension it found). + Without it, a body is Mustache unless the caller passes ``engine`` (ADR-0061). + """ def resolve(self, ref: str) -> str | None: ... class InMemoryProvider: - """A :class:`Provider` backed by a dict of ref → body text.""" + """A :class:`Provider` backed by a dict of ref → body text. + + ``engine`` (optional) is the engine every body in this provider is written for. + """ - def __init__(self, partials: dict[str, str] | None = None) -> None: + def __init__(self, partials: dict[str, str] | None = None, engine: str | None = None) -> None: self._partials = dict(partials or {}) + self._engine = engine def resolve(self, ref: str) -> str | None: return self._partials.get(ref) + def engine_of(self, ref: str) -> str | None: + return self._engine if ref in self._partials else None + # --- Mustache tag token model (rendering-agnostic; only tag structure) --- @@ -202,11 +227,22 @@ def verify( provider: Provider | None = None, required_slots: list[str] | None = None, required_tags: list[str] | None = None, + engine: str | None = None, ) -> list[VerifyError]: - """Walk a Mustache template's tokens against a payload field tree, returning a - list of drift findings. Context-sensitive: a section ``{{#posts}}…{{/posts}}`` - over a container field checks its body against that field's element type. + """Check a template's references against a payload field tree, returning a list of + drift findings. Mustache by default; ``engine="jinja"`` walks the jinja2 AST instead + (:mod:`metaobjects.render.jinja_verify`) and reports the same codes. + + Mustache is context-sensitive: a section ``{{#posts}}…{{/posts}}`` over a container + field checks its body against that field's element type. """ + if engine == TEMPLATE_ENGINE_JINJA: + from .jinja_verify import verify_jinja + + return verify_jinja( + template_text, fields, provider=provider, + required_slots=required_slots, required_tags=required_tags, + ) errors: list[VerifyError] = [] root = fields referenced_at_root: set[str] = set() @@ -276,21 +312,37 @@ def walk( else: # _Partial if provider is None: continue # can't resolve without a provider - if tok.value in seen or len(seen) >= _MAX_DEPTH: + if tok.value in seen or len(seen) >= MAX_DEPTH: continue # cycle/depth guard body = provider.resolve(tok.value) if body is None: errors.append(VerifyError(ERR_PARTIAL_UNRESOLVED, tok.value)) continue + if partial_engine_mismatch(provider, tok.value, TEMPLATE_ENGINE_MUSTACHE): + errors.append(VerifyError(ERR_TEMPLATE_ENGINE_UNSUPPORTED, tok.value)) + continue static_texts.append(body) walk(_tokenize(body), stack, [*seen, tok.value], in_element) walk(_tokenize(template_text), [root], []) + errors.extend(slot_and_tag_findings(referenced_at_root, static_texts, required_slots, required_tags)) + return errors + + +def slot_and_tag_findings( + referenced_at_root: set[str], + static_texts: list[str], + required_slots: list[str] | None, + required_tags: list[str] | None, +) -> list[VerifyError]: + """The checks that run after either engine's walk: every required slot must have been + referenced at the payload root, and every required tag must appear (open and close) in + the body plus its resolved partials.""" + errors: list[VerifyError] = [] for slot in required_slots or []: if slot not in referenced_at_root: errors.append(VerifyError(ERR_REQUIRED_SLOT_UNUSED, slot)) - if required_tags: # Scan body + resolved partials as one joined string: the open and close # forms are located independently, so a tag may legitimately straddle the @@ -300,5 +352,4 @@ def walk( for tag in required_tags: if not _has_open_tag(haystack, tag) or not _has_close_tag(haystack, tag): errors.append(VerifyError(ERR_OUTPUT_TAG_MISSING, tag)) - return errors diff --git a/server/python/tests/codegen/test_cli_verify_subverbs.py b/server/python/tests/codegen/test_cli_verify_subverbs.py index d73bbbae91..74bfb3369b 100644 --- a/server/python/tests/codegen/test_cli_verify_subverbs.py +++ b/server/python/tests/codegen/test_cli_verify_subverbs.py @@ -450,3 +450,27 @@ def test_templates_payload_ref_fqn_collision_binds_correct_package_not_load_firs # drift. The fix must report CLEAN (exit 0) regardless of load order. rc = main(["verify", "--generators", GEN_SUITE, "--templates", str(d), "--templates-root", str(troot)]) assert rc == 0 + + +# ADR-0061: a body may be a Jinja file beside the Mustache ones; the same gate checks it. +def _jinja_templates_dir(tmp_path: Path, body: str, ext: str = ".jinja") -> str: + root = tmp_path / "templates" + (root / "pages").mkdir(parents=True) + (root / "pages" / f"welcome{ext}").write_text(body) + return str(root) + + +def test_templates_jinja_clean_returns_zero(tmp_path: Path) -> None: + meta_dir = _meta_dir_with(tmp_path, _META_CLEAN) + troot = _jinja_templates_dir(tmp_path, "{% set n = name %}Hello {{ n | title }}") + rc = main(["verify", "--generators", GEN_SUITE, "--templates", meta_dir, "--templates-root", troot]) + assert rc == 0 + + +def test_templates_jinja_drift_is_caught(tmp_path: Path, capsys) -> None: + meta_dir = _meta_dir_with(tmp_path, _META_CLEAN) + troot = _jinja_templates_dir(tmp_path, "{% if nickname %}{{ nickname }}{% endif %}", ".j2") + rc = main(["verify", "--generators", GEN_SUITE, "--templates", meta_dir, "--templates-root", troot]) + assert rc != 0 + err = capsys.readouterr().err + assert "ERR_VAR_NOT_ON_PAYLOAD: nickname" in err diff --git a/server/python/tests/codegen/test_render_helper_generator.py b/server/python/tests/codegen/test_render_helper_generator.py index aafdeaf251..aae39ae178 100644 --- a/server/python/tests/codegen/test_render_helper_generator.py +++ b/server/python/tests/codegen/test_render_helper_generator.py @@ -11,10 +11,10 @@ * email → render subject (text) + html body (html) + optional text body (text) → an ``EmailDocument``. -Python divergence (documented in the generator): Python's ``RenderRequest`` has no -``verify`` field (the render engine does not run a runtime drift pass the way TS / -Java / C# do), so the emitted helper does NOT pass a runtime ``verify`` field-tree. -The BUILD-TIME drift gate below is the only — and a complete — drift guarantee. +Python divergence: a helper over a MUSTACHE body passes no runtime ``verify`` field +tree (the BUILD-TIME drift gate below is its guarantee). A helper over a JINJA body +(ADR-0061) names the engine and passes the field tree, so ``render`` checks the body +again at runtime — see the Jinja tests at the end of this file. """ from __future__ import annotations @@ -471,3 +471,116 @@ def test_resolve_payload_vo_bare_value_does_not_bind_a_packaged_object() -> None ]).root assert resolve_payload_vo(root, "acme::blog::WelcomePayload", "") is not None assert resolve_payload_vo(root, "WelcomePayload", "acme::blog") is None + + +# --------------------------------------------------------------------------- +# Jinja bodies (ADR-0061) +# --------------------------------------------------------------------------- + + +def _write_jinja(tmp_path, rel: str, body: str, ext: str = ".jinja") -> None: + import os + + full = os.path.join(str(tmp_path), *rel.split("/")) + ext + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w") as fh: + fh.write(body) + + +def test_jinja_body_renders_through_the_helper_and_names_its_engine(tmp_path, monkeypatch) -> None: + _write_jinja(tmp_path, "pages/welcome", "Hello {{ name | upper }}") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome") + root = _root([payload, tmpl]) + + files = RenderHelperGenerator(str(tmp_path)).generate(_ctx(root)) + src = files[0].content + assert 'engine="jinja"' in src + assert 'verify=[PayloadField("name")]' in src + _materialize_and_import([*files, *_models(root)], tmp_path, monkeypatch) + helper = import_module("_rh_pkg.welcome_page_render_helper") + + from metaobjects.render.verify import InMemoryProvider + + # The runtime provider knows nothing about engines; the helper's own engine decides. + runtime = InMemoryProvider({"pages/welcome": "Hello {{ name | upper }}"}) + assert helper.render_welcome_page({"name": "Ada"}, runtime) == "Hello ADA" + + +def test_jinja_helper_rechecks_at_runtime(tmp_path, monkeypatch) -> None: + _write_jinja(tmp_path, "pages/welcome", "Hello {{ name }}") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome") + root = _root([payload, tmpl]) + files = RenderHelperGenerator(str(tmp_path)).generate(_ctx(root)) + _materialize_and_import([*files, *_models(root)], tmp_path, monkeypatch) + helper = import_module("_rh_pkg.welcome_page_render_helper") + + from metaobjects.render.renderer import RenderError + from metaobjects.render.verify import InMemoryProvider + + # A runtime provider serving a body that drifted after the build. + drifted = InMemoryProvider({"pages/welcome": "Hello {{ nickname }}"}) + try: + helper.render_welcome_page({"name": "Ada"}, drifted) + except RenderError as e: + assert "ERR_VAR_NOT_ON_PAYLOAD (nickname)" in str(e) + return + raise AssertionError("expected the runtime check to refuse the drifted body") + + +def test_jinja_drift_gate_fails_codegen(tmp_path) -> None: + _write_jinja(tmp_path, "pages/welcome", "{% for x in nope %}{{ x }}{% endfor %}", ".j2") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome") + root = _root([payload, tmpl]) + try: + RenderHelperGenerator(str(tmp_path)).generate(_ctx(root)) + except ValueError as e: + assert "ERR_VAR_NOT_ON_PAYLOAD: {{nope}}" in str(e) + return + raise AssertionError("expected the Jinja drift gate to fail codegen") + + +def test_jinja_not_static_fails_codegen(tmp_path) -> None: + _write_jinja(tmp_path, "pages/welcome", "{% include name %}") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome") + root = _root([payload, tmpl]) + try: + RenderHelperGenerator(str(tmp_path)).generate(_ctx(root)) + except ValueError as e: + assert "ERR_TEMPLATE_NOT_STATIC: include" in str(e) + return + raise AssertionError("expected ERR_TEMPLATE_NOT_STATIC to fail codegen") + + +def test_mustache_helper_has_no_engine_or_runtime_tree(tmp_path) -> None: + _write_mustache(tmp_path, "pages/welcome", "Hi {{name}}") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome") + root = _root([payload, tmpl]) + src = RenderHelperGenerator(str(tmp_path)).generate(_ctx(root))[0].content + assert "engine=" not in src + assert "verify=" not in src + assert "PayloadField" not in src + + +def test_email_parts_may_mix_engines(tmp_path, monkeypatch) -> None: + _write_mustache(tmp_path, "mail/subject", "Welcome {{name}}") + _write_jinja(tmp_path, "mail/html", "

    Hi {{ name }}

    ") + _write_jinja(tmp_path, "mail/text", "Hi {{ name | upper }}", ".j2") + payload = _payload_vo("Welcome", [_field("name")]) + tmpl = _email_template("WelcomeEmail", "Welcome") + root = _root([payload, tmpl]) + files = RenderHelperGenerator(str(tmp_path)).generate(_ctx(root)) + assert files[0].content.count('engine="jinja"') == 2 + _materialize_and_import([*files, *_models(root)], tmp_path, monkeypatch) + helper = import_module("_rh_pkg.welcome_email_render_helper") + + from metaobjects.render.filesystem_provider import FilesystemProvider + + doc = helper.render_welcome_email({"name": "A&B"}, FilesystemProvider(str(tmp_path))) + assert doc.subject == "Welcome A&B" + assert doc.html_body == "

    Hi A&B

    " + assert doc.text_body == "Hi A&B" diff --git a/server/python/tests/render/test_jinja_conformance.py b/server/python/tests/render/test_jinja_conformance.py new file mode 100644 index 0000000000..af93940864 --- /dev/null +++ b/server/python/tests/render/test_jinja_conformance.py @@ -0,0 +1,135 @@ +"""Jinja conformance corpora runner (ADR-0061) — the Python half of the Jinja arm. + +Two corpora, shared with the TypeScript port and run by no other port (Java, Kotlin and +C# run Mustache only): + +- ``fixtures/jinja-render-conformance//`` — ``meta.json`` ``{ format, note }``, + ``template.jinja``, optional ``partials/.jinja`` (referenced as ``partials/``), + ``payload.json``, and either ``expected.txt`` (byte-identical output) or + ``expected-error.txt`` (the exact render error message, trailing newline dropped). +- ``fixtures/jinja-verify-conformance//`` — ``payload.json`` (the payload FIELD TREE), + ``template.jinja``, optional ``partials/`` and ``options.json`` + ``{ requiredSlots?, requiredTags?, provider?: "with" | "without" }``, and + ``expected-drift.json`` (compared as a sorted multiset). An ``ERR_TEMPLATE_SYNTAX`` + finding's path is the engine's own parser message, so it is compared as ``""``. + +Mirrors ``server/typescript/packages/render/test/jinja-conformance.test.ts``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from metaobjects.render import ( + ERR_OUTPUT_TAG_MISSING, + ERR_PARTIAL_UNRESOLVED, + ERR_REQUIRED_SLOT_UNUSED, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_VAR_NOT_ON_PAYLOAD, + InMemoryProvider, + PayloadField, + verify, +) +from metaobjects.render.renderer import RenderError, RenderRequest, render + +_VERIFY_CODES = frozenset( + { + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_REQUIRED_SLOT_UNUSED, + ERR_OUTPUT_TAG_MISSING, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + } +) + + +def _corpus(name: str) -> Path: + here = Path(__file__).resolve() + for parent in [here, *here.parents]: + candidate = parent / "fixtures" / name + if candidate.is_dir(): + return candidate + raise RuntimeError(f"could not locate fixtures/{name} from {here}") + + +_RENDER = _corpus("jinja-render-conformance") +_VERIFY = _corpus("jinja-verify-conformance") + + +def _cases(root: Path) -> list[Path]: + return sorted(p for p in root.iterdir() if p.is_dir()) + + +def _provider(fixture_dir: Path) -> InMemoryProvider: + partials: dict[str, str] = {} + pdir = fixture_dir / "partials" + if pdir.is_dir(): + for f in pdir.glob("*.jinja"): + partials[f"partials/{f.stem}"] = f.read_text(encoding="utf-8") + return InMemoryProvider(partials, engine="jinja") + + +def _parse_fields(raw: Any) -> list[PayloadField]: + return [ + PayloadField( + node["name"], + _parse_fields(node["fields"]) if "fields" in node else None, + is_array=bool(node.get("isArray", False)), + ) + for node in raw + ] + + +@pytest.mark.parametrize("fixture_dir", _cases(_RENDER), ids=lambda p: p.name) +def test_jinja_render(fixture_dir: Path) -> None: + meta = json.loads((fixture_dir / "meta.json").read_text(encoding="utf-8")) + request = RenderRequest( + payload=json.loads((fixture_dir / "payload.json").read_text(encoding="utf-8")), + provider=_provider(fixture_dir), + template=(fixture_dir / "template.jinja").read_text(encoding="utf-8"), + format=meta.get("format", "text"), + engine="jinja", + ) + error_path = fixture_dir / "expected-error.txt" + if error_path.exists(): + with pytest.raises(RenderError) as info: + render(request) + assert str(info.value) == error_path.read_text(encoding="utf-8").rstrip("\n") + return + expected = (fixture_dir / "expected.txt").read_text(encoding="utf-8") + actual = render(request) + assert actual == expected + assert render(request) == actual # determinism + + +def _normalized(findings: list[tuple[str, str]]) -> list[tuple[str, str]]: + return sorted((c, "" if c == ERR_TEMPLATE_SYNTAX else p) for c, p in findings) + + +@pytest.mark.parametrize("fixture_dir", _cases(_VERIFY), ids=lambda p: p.name) +def test_jinja_verify(fixture_dir: Path) -> None: + fields = _parse_fields(json.loads((fixture_dir / "payload.json").read_text(encoding="utf-8"))) + template = (fixture_dir / "template.jinja").read_text(encoding="utf-8") + opts_path = fixture_dir / "options.json" + opts = json.loads(opts_path.read_text(encoding="utf-8")) if opts_path.exists() else {} + expected = json.loads((fixture_dir / "expected-drift.json").read_text(encoding="utf-8")) + for e in expected: + assert e["code"] in _VERIFY_CODES, f"unknown verify code {e['code']!r}" + mode = opts.get("provider", "with" if (fixture_dir / "partials").is_dir() else "without") + actual = verify( + template, + fields, + provider=_provider(fixture_dir) if mode == "with" else None, + required_slots=opts.get("requiredSlots"), + required_tags=opts.get("requiredTags"), + engine="jinja", + ) + assert _normalized([(e.code, e.path) for e in actual]) == _normalized( + [(e["code"], e["path"]) for e in expected] + ) diff --git a/server/python/tests/render/test_jinja_engine.py b/server/python/tests/render/test_jinja_engine.py new file mode 100644 index 0000000000..bfe146bac6 --- /dev/null +++ b/server/python/tests/render/test_jinja_engine.py @@ -0,0 +1,200 @@ +"""Engine selection and the Jinja render path's runtime behaviour (ADR-0061). + +What a Jinja template renders to and what the check reports are pinned by the shared +corpora (``test_jinja_conformance.py``); this file covers the selection rules around them. +Mirrors ``server/typescript/packages/render/test/jinja-engine.test.ts``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from metaobjects.render import ( + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + ERR_VAR_NOT_ON_PAYLOAD, + FilesystemProvider, + InMemoryProvider, + PayloadField, + engine_for_extension, + template_engine_of, + verify, +) +from metaobjects.render.renderer import RenderError, RenderRequest, render + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + p = tmp_path / "p" + p.mkdir() + (p / "both.mustache").write_text("M {{name}}") + (p / "both.jinja").write_text("J {{ name }}") + (p / "only.j2").write_text("{% for t in tags %}{{ t }}{% if not loop.last %},{% endif %}{% endfor %}") + (p / "plain.mustache").write_text("{{name}}") + (p / "inc.jinja").write_text('{% include "p/part" %}!') + (p / "part.jinja2").write_text("<{{ name }}>") + (p / "mix-m.mustache").write_text("{{> p/only}}") + (p / "mix-j.jinja").write_text('{% include "p/plain" %}') + return tmp_path + + +def test_extension_selects_engine() -> None: + assert engine_for_extension(".jinja") == "jinja" + assert engine_for_extension(".J2") == "jinja" + assert engine_for_extension(".jinja2") == "jinja" + assert engine_for_extension(".mustache") == "mustache" + assert engine_for_extension(".txt") == "mustache" + + +def test_filesystem_provider_prefers_mustache_and_reports_engine(root: Path) -> None: + fs = FilesystemProvider(root) + assert fs.resolve("p/both") == "M {{name}}" + assert fs.engine_of("p/both") == "mustache" + assert fs.engine_of("p/only") == "jinja" + assert fs.engine_of("p/missing") is None + + +def test_engine_follows_the_matched_extension_not_a_symlink_target(root: Path) -> None: + shared = root / "shared" + shared.mkdir() + (shared / "welcome.txt").write_text("{{ name | upper }}") + (shared / "plain.j2").write_text("{{name}}") + (root / "p" / "welcome.jinja").symlink_to(shared / "welcome.txt") + (root / "p" / "linked.mustache").symlink_to(shared / "plain.j2") + fs = FilesystemProvider(root) + assert fs.engine_of("p/welcome") == "jinja" + assert fs.engine_of("p/linked") == "mustache" + + +def test_explicit_extension_reads_only_that_extension(root: Path) -> None: + fs = FilesystemProvider(root, extension=".jinja") + assert fs.resolve("p/both") == "J {{ name }}" + assert fs.resolve("p/plain") is None + + +def test_explicit_mustache_does_not_fall_back_to_jinja(root: Path) -> None: + fs = FilesystemProvider(root, extension=".mustache") + assert fs.resolve("p/only") is None + assert fs.engine_of("p/only") is None + assert fs.resolve("p/both") == "M {{name}}" + + +def test_explicit_engine_wins_and_unknown_is_refused(root: Path) -> None: + fs = FilesystemProvider(root) + assert template_engine_of(fs, "p/only", "mustache") == "mustache" + assert template_engine_of(InMemoryProvider({"a": "x"}), "a") == "mustache" + assert template_engine_of(InMemoryProvider({"a": "x"}, engine="jinja"), "a") == "jinja" + with pytest.raises(ValueError, match="unknown template engine"): + template_engine_of(fs, "p/only", "handlebars") + + +def test_ref_to_a_jinja_file_renders_with_jinja(root: Path) -> None: + fs = FilesystemProvider(root) + assert render(RenderRequest(payload={"tags": ["a", "b"]}, provider=fs, ref="p/only")) == "a,b" + out = render(RenderRequest(payload={"name": "x&y"}, provider=fs, ref="p/inc", format="html")) + assert out == "!" + + +def test_inline_text_is_mustache_unless_named() -> None: + provider = InMemoryProvider({}) + assert render(RenderRequest(payload={"name": "a"}, provider=provider, template="{{ name }}")) == "a" + out = render( + RenderRequest(payload={"name": "a"}, provider=provider, template="{{ name | upper }}", engine="jinja") + ) + assert out == "A" + + +def test_runtime_check_refuses_a_drifted_jinja_body(root: Path) -> None: + fs = FilesystemProvider(root) + with pytest.raises(RenderError, match=f"render verify failed: {ERR_VAR_NOT_ON_PAYLOAD} \\(tags\\)"): + render(RenderRequest(payload={"tags": []}, provider=fs, ref="p/only", + verify=[PayloadField("labels", is_array=True)])) + ok = render(RenderRequest(payload={"tags": ["a"]}, provider=fs, ref="p/only", + verify=[PayloadField("tags", is_array=True)])) + assert ok == "a" + + +def test_max_chars_applies_to_jinja() -> None: + with pytest.raises(RenderError, match="render exceeded maxChars budget: 6 > 3"): + render(RenderRequest(payload={"name": "abcdef"}, provider=InMemoryProvider({}), + template="{{ name }}", engine="jinja", max_chars=3)) + + +def test_pydantic_payload_renders_as_its_json_dump() -> None: + from enum import Enum + + from pydantic import BaseModel + + class Tier(Enum): + GOLD = "gold" + + class Member(BaseModel): + name: str + tier: Tier + + out = render(RenderRequest(payload=Member(name="Ada", tier=Tier.GOLD), provider=InMemoryProvider({}), + template="{{ name }}:{{ tier }}", engine="jinja")) + assert out == "Ada:gold" + + +def test_verify_engine_jinja_and_default_mustache() -> None: + fields = [PayloadField("user", [PayloadField("name")])] + found = verify("{{ user.nope }}", fields, engine="jinja") + assert [(e.code, e.path) for e in found] == [(ERR_VAR_NOT_ON_PAYLOAD, "user.nope")] + assert verify("{{user.name}}", fields) == [] + + +_MIX_FIELDS = [PayloadField("name"), PayloadField("tags", is_array=True)] + + +def test_mustache_partial_that_is_jinja_is_refused(root: Path) -> None: + provider = FilesystemProvider(root) + with pytest.raises(RenderError, match=ERR_TEMPLATE_ENGINE_UNSUPPORTED): + render(RenderRequest(payload={"tags": []}, provider=provider, ref="p/mix-m")) + errors = verify("{{> p/only}}", _MIX_FIELDS, provider=provider) + assert [(e.code, e.path) for e in errors] == [(ERR_TEMPLATE_ENGINE_UNSUPPORTED, "p/only")] + + +def test_jinja_include_that_is_mustache_is_refused(root: Path) -> None: + provider = FilesystemProvider(root) + with pytest.raises(RenderError, match=ERR_TEMPLATE_ENGINE_UNSUPPORTED): + render(RenderRequest(payload={"name": "x"}, provider=provider, ref="p/mix-j")) + errors = verify('{% include "p/plain" %}', _MIX_FIELDS, provider=provider, engine="jinja") + assert [(e.code, e.path) for e in errors] == [(ERR_TEMPLATE_ENGINE_UNSUPPORTED, "p/plain")] + + +def _jinja(template: str, payload: object, fmt: str = "text") -> str: + return render( + RenderRequest(payload=payload, provider=InMemoryProvider({}), template=template, format=fmt, engine="jinja") + ) + + +@dataclass +class _Greeting: + name: str + + +def test_dataclass_and_object_payloads_are_read_by_field() -> None: + assert _jinja("[{{ name }}]", _Greeting("Ada")) == "[Ada]" + + class Plain: + def __init__(self) -> None: + self.name = "Bo" + + assert _jinja("[{{ name }}]", Plain()) == "[Bo]" + + +def test_a_payload_with_no_fields_is_refused() -> None: + with pytest.raises(RenderError, match="a Jinja payload must be a mapping or an object"): + _jinja("{{ x }}", 5) + + +def test_a_missing_key_renders_empty_at_any_depth() -> None: + assert _jinja("[{{ v.x.y }}]", {"v": {}}) == "[]" + + +def test_an_engine_failure_is_a_render_error() -> None: + with pytest.raises(RenderError, match="jinja render failed"): + _jinja("{% for i in items %}{{ i }}{% endfor %}", {"items": None}) diff --git a/server/python/uv.lock b/server/python/uv.lock index c32a9f0111..a3a0e53631 100644 --- a/server/python/uv.lock +++ b/server/python/uv.lock @@ -184,6 +184,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -257,6 +269,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + [[package]] name = "metaobjects" version = "1.1.4" @@ -279,12 +419,16 @@ integration = [ { name = "pg8000" }, { name = "pymysql" }, ] +jinja = [ + { name = "jinja2" }, +] mysql = [ { name = "pymysql" }, ] [package.dev-dependencies] dev = [ + { name = "jinja2" }, { name = "pydantic" }, { name = "pytest" }, { name = "pytest-xdist" }, @@ -295,6 +439,7 @@ dev = [ requires-dist = [ { name = "fastapi", marker = "extra == 'integration'", specifier = ">=0.110" }, { name = "httpx", marker = "extra == 'integration'", specifier = ">=0.27" }, + { name = "jinja2", marker = "extra == 'jinja'", specifier = ">=3.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "pg8000", marker = "extra == 'integration'", specifier = ">=1.31" }, { name = "pydantic", marker = "extra == 'dev'", specifier = ">=2" }, @@ -305,10 +450,11 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, ] -provides-extras = ["dev", "integration", "mysql"] +provides-extras = ["dev", "integration", "mysql", "jinja"] [package.metadata.requires-dev] dev = [ + { name = "jinja2", specifier = ">=3.1" }, { name = "pydantic", specifier = ">=2" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-xdist", specifier = ">=3.6" }, diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index c027beae4b..7df968fb18 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -29,7 +29,7 @@ import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js"; import { scanForMissingBaseUrl, type BaseUrlFinding } from "../lib/base-url-advisory.js"; import { scanForRemovedProps, type RemovedPropFinding } from "../lib/removed-prop-advisory.js"; import { replayRemedy } from "../lib/replay-remedy.js"; -import { FileProvider, mustacheTagLine } from "../lib/file-provider.js"; +import { FileProvider, templateReferenceLine } from "../lib/file-provider.js"; import { derivePayloadFieldTree } from "../lib/payload-field-tree.js"; import { loadMemoryOptionsFrom, loadMetaobjectsConfig, RemovedNamingConfigError, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js"; import { collectionLoadOptions } from "../lib/collection-load-options.js"; @@ -120,7 +120,12 @@ import { WIRE_KEY_NULL_MEMBERS, type WireFormat, } from "@metaobjectsdev/metadata"; -import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render"; +import { + verify, + templateEngineOf, + ERR_REQUIRED_SLOT_UNUSED, + ERR_PARTIAL_UNRESOLVED, +} from "@metaobjectsdev/render"; import { describeLoadError, reportLoadError } from "../lib/load-error.js"; import { describeError } from "../lib/error-text.js"; @@ -1217,18 +1222,20 @@ export async function verifyCommand( let anyBodyChecked = false; let anyErrorHere = false; for (const { label, ref } of refs) { - // Render-engine drift check: mustache variables ↔ payload field names. + // Render-engine drift check: template variables ↔ payload field names. The + // engine comes from the file the ref resolved to (`.mustache` or `.jinja`/`.j2`). const text = provider.resolve(ref); if (text === undefined) { log.error( `[${tmpl.name}] (${label}) ${ERR_PARTIAL_UNRESOLVED}: ref "${ref}" did not resolve under ${promptsDir}` + - ` — create ${join(promptsDir, `${ref}.mustache`)} (or .txt), or point --prompts at the directory that holds it`, + ` — create ${join(promptsDir, `${ref}.mustache`)} (or .jinja, .j2, .txt), or point --prompts at the directory that holds it`, ); errorCount++; anyErrorHere = true; continue; } - const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags }); + const engine = templateEngineOf(provider, ref); + const drift = verify(text, fieldTree, { provider, requiredSlots, requiredTags, engine }); anyBodyChecked = true; // WHERE to look. The {code, path} diagnostic is pinned by the cross-port verify // corpus, so the location is added here, in the display only: the body's file @@ -1237,7 +1244,7 @@ export async function verifyCommand( const bodyFile = provider.pathOf(ref); const where = (varPath: string): string => { if (bodyFile === undefined) return ""; - const line = mustacheTagLine(text, varPath); + const line = templateReferenceLine(text, varPath, engine); return ` (${relative(projectRoot, bodyFile)}${line !== undefined ? `:${line}` : ""})`; }; for (const e of drift) { diff --git a/server/typescript/packages/cli/src/lib/file-provider.ts b/server/typescript/packages/cli/src/lib/file-provider.ts index a11e0c7adc..bbd34e4912 100644 --- a/server/typescript/packages/cli/src/lib/file-provider.ts +++ b/server/typescript/packages/cli/src/lib/file-provider.ts @@ -7,10 +7,19 @@ // provider without touching the engine. import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { Provider } from "@metaobjectsdev/render"; +import { extname, join } from "node:path"; +import { + JINJA_EXTENSIONS, + MUSTACHE_EXTENSION, + engineForExtension, + jinjaReferenceLine, + TEMPLATE_ENGINE_JINJA, + type Provider, + type TemplateEngine, +} from "@metaobjectsdev/render"; -const EXTENSIONS = [".mustache", ".txt", ""] as const; +// Mustache first, then Jinja (ADR-0061): when both files exist, the Mustache one wins. +const EXTENSIONS = [MUSTACHE_EXTENSION, ...JINJA_EXTENSIONS, ".txt", ""] as const; export class FileProvider implements Provider { constructor(private readonly baseDir: string) {} @@ -20,6 +29,12 @@ export class FileProvider implements Provider { return path === undefined ? undefined : readFileSync(path, "utf8"); } + /** The engine the file `ref` resolves to is written for: Jinja for `.jinja` / `.j2` / `.jinja2`. */ + engineOf(ref: string): TemplateEngine | undefined { + const path = this.pathOf(ref); + return path === undefined ? undefined : engineForExtension(extname(path)); + } + /** * The file `ref` resolves to, or undefined. `resolve` reads exactly this file, so a * diagnostic that names it names the body that was checked. @@ -42,6 +57,15 @@ export class FileProvider implements Provider { } } +/** The 1-based line `path` is first referenced on in a body written for `engine`. */ +export function templateReferenceLine( + text: string, + path: string, + engine: TemplateEngine, +): number | undefined { + return engine === TEMPLATE_ENGINE_JINJA ? jinjaReferenceLine(text, path) : mustacheTagLine(text, path); +} + /** * The 1-based line of the first mustache tag naming `path` in `text` — `{{path}}`, * `{{{path}}}`, `{{& path}}`, `{{#path}}`, `{{^path}}` — trying the full dotted path and then diff --git a/server/typescript/packages/cli/test/integration/verify.test.ts b/server/typescript/packages/cli/test/integration/verify.test.ts index e3c77843a1..17ea13ece2 100644 --- a/server/typescript/packages/cli/test/integration/verify.test.ts +++ b/server/typescript/packages/cli/test/integration/verify.test.ts @@ -293,3 +293,37 @@ describe("meta verify", () => { } }); }); + +// ADR-0061: a template body may be a Jinja file beside the Mustache ones. The same gate +// checks it, walking the Jinja syntax, and the diagnostic names the .jinja file and line. +describe("meta verify — Jinja template bodies", () => { + function scaffoldJinja(text: string, ext = ".jinja"): string { + const tmp = scaffold(undefined); + mkdirSync(join(tmp, "prompts", "prompt"), { recursive: true }); + writeFileSync(join(tmp, "prompts", "prompt", `strategy${ext}`), text, "utf8"); + return tmp; + } + + test("exit 0 for a clean Jinja body", async () => { + const tmp = scaffoldJinja( + "Hi {{ displayName | title }} ({{ postCount }}).\n{% for p in posts %}- {{ p.title }}\n{% endfor %}", + ); + try { + expect(await run(["verify", "--cwd", tmp])).toBe(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("exit 1 naming the .j2 file and line for a drifted loop member", async () => { + const tmp = scaffoldJinja("Hi {{ displayName }}\n{% for p in posts %}\n{{ p.subtitle }}\n{% endfor %}", ".j2"); + try { + expect(await run(["verify", "--cwd", tmp])).toBe(1); + const line = [...out, ...err].find((l) => l.includes("ERR_VAR_NOT_ON_PAYLOAD")); + expect(line).toContain("p.subtitle"); + expect(line).toContain(join("prompts", "prompt", "strategy.j2") + ":3"); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts b/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts index 1430195566..821fcc9e29 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-doc-builder.ts @@ -33,7 +33,7 @@ import { TYPE_TEMPLATE, TEMPLATE_SUBTYPE_OUTPUT, } from "@metaobjectsdev/metadata"; -import type { Provider } from "@metaobjectsdev/render"; +import { TEMPLATE_ENGINE_JINJA, templateEngineOf, type Provider } from "@metaobjectsdev/render"; import { GENERATED_HEADER } from "../constants.js"; import type { OutputLayout } from "../import-path.js"; import { docPageHref, docPageNode, type DocPageNode } from "../docs-paths.js"; @@ -264,6 +264,15 @@ function buildTemplateSourceSection(args: BuildSectionArgs): string | undefined for (const spec of refs) { const source = provider.resolve(spec.ref); if (source === undefined) continue; // missing source → skip this part. + if (templateEngineOf(provider, spec.ref) === TEMPLATE_ENGINE_JINJA) { + // A Jinja body (ADR-0061) is shown as source only: the variable table and the + // linked rendering come from the Mustache annotator, which does not read Jinja. + const parts: string[] = []; + if (isMultipart && spec.label !== undefined) parts.push(`### ${spec.label}`); + parts.push("```jinja\n" + source + "\n```"); + blocks.push(parts.join("\n\n")); + continue; + } const tokens = annotateTemplate(source, tree, { ownerVoName: payloadName, resolvePartialHref, diff --git a/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts b/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts index a673e333cc..d0458b1c39 100644 --- a/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts +++ b/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts @@ -17,9 +17,10 @@ // (enum membership, numeric and length validators), when the model implies any. // Apply it after `prisma db push` or a migration: `prisma db execute --file`. // customize: which objects get a model (the `filter` option, ANDed with "has a table"), -// where the files land (`fileName`, `prismaChecksFileName`), and the generator and -// datasource blocks (`header`; `false` for a multi-file schema whose blocks live -// in a file you own). Copied beside it, so yours too: `buildPrismaSchema`, which +// where the files land (`target`, and `fileName`, from which the checks file +// takes its name), whether the checks file is written (`checks: "omit"` leaves +// it out), the datasource `provider`, and the generator and datasource blocks +// (`header`; `false` for a multi-file schema whose blocks live in a file you own). Copied beside it, so yours too: `buildPrismaSchema`, which // maps the model's tables to Prisma models and raises every refusal // (`PrismaSchemaError`), and the renderers in `render-prisma-schema`. Change the // type or name mapping only if you mean to stop agreeing with `meta verify --db`, diff --git a/server/typescript/packages/codegen-ts/src/render-engine/framework-provider.ts b/server/typescript/packages/codegen-ts/src/render-engine/framework-provider.ts index 21c1b6dc6a..f86a2cffe2 100644 --- a/server/typescript/packages/codegen-ts/src/render-engine/framework-provider.ts +++ b/server/typescript/packages/codegen-ts/src/render-engine/framework-provider.ts @@ -12,7 +12,8 @@ // `templates/` directory is included in the published tarball via // package.json `files: ["dist", "src", "templates", ...]`. -import type { Provider } from "@metaobjectsdev/render"; +import { templateEngineOf, type Provider, type TemplateEngine } from "@metaobjectsdev/render"; +import { FilesystemProvider } from "@metaobjectsdev/render/providers"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -73,14 +74,19 @@ function frameworkTemplatesDir(): string | undefined { } /** Provider backed by an arbitrary on-disk template directory. References - * resolve as `/.mustache`. Used by both the framework default - * and adopter override paths. */ + * resolve as `/.mustache`, or — when there is no Mustache file — as a + * Jinja file beside it (`.jinja` / `.j2` / `.jinja2`, ADR-0061), whose engine + * `engineOf` reports. The adopter override path. */ export class FileSystemProvider implements Provider { - constructor(private readonly root: string) {} + private readonly files: FilesystemProvider; + constructor(root: string) { + this.files = new FilesystemProvider(root); + } resolve(ref: string): string | undefined { - const path = join(this.root, `${ref}.mustache`); - if (!existsSync(path)) return undefined; - return readFileSync(path, "utf-8"); + return this.files.resolve(ref); + } + engineOf(ref: string): TemplateEngine | undefined { + return this.files.engineOf(ref); } } @@ -129,6 +135,13 @@ export class ProviderChain implements Provider { } return undefined; } + /** The engine of the body `resolve` returns: the one the answering provider reports. */ + engineOf(ref: string): TemplateEngine | undefined { + for (const p of this.providers) { + if (p.resolve(ref) !== undefined) return templateEngineOf(p, ref); + } + return undefined; + } } /** Build a project-scoped Provider: layers an optional project `templates/` diff --git a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts index 296aded889..bfa8abc344 100644 --- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts @@ -46,7 +46,10 @@ import { } from "@metaobjectsdev/metadata"; import { verify, + templateEngineOf, + TEMPLATE_ENGINE_JINJA, ERR_REQUIRED_SLOT_UNUSED, + ERR_VAR_NOT_ON_PAYLOAD, type Provider, type PayloadField, type VerifyError, @@ -123,22 +126,28 @@ function gateRef( ref: string, provider: Provider, fields: PayloadField[], -): void { +): string { const text = provider.resolve(ref); if (text === undefined) { throw new Error( `render-helper drift: template "${templateName}" ref "${ref}" — unresolved (provider returned no text)`, ); } - const errors: VerifyError[] = verify(text, fields, { provider }).filter( + const engine = templateEngineOf(provider, ref); + const errors: VerifyError[] = verify(text, fields, { provider, engine }).filter( (e) => e.code !== ERR_REQUIRED_SLOT_UNUSED, ); if (errors.length > 0) { const e = errors[0]!; throw new Error( - `render-helper drift: template "${templateName}" ref "${ref}" — ${e.code}: {{${e.path}}} not on payload VO`, + `render-helper drift: template "${templateName}" ref "${ref}" — ${e.code}: ` + + (e.code === ERR_VAR_NOT_ON_PAYLOAD ? `{{${e.path}}} not on payload VO` : e.path), ); } + // The emitted render() call names a Jinja body's engine (ADR-0061), so a runtime + // provider that cannot tell — an in-memory one — still renders it as Jinja. A + // Mustache body adds nothing, so a Mustache helper is byte-identical to before. + return engine === TEMPLATE_ENGINE_JINJA ? `, engine: ${JSON.stringify(TEMPLATE_ENGINE_JINJA)}` : ""; } /** @@ -221,15 +230,14 @@ export function renderRenderHelper( } // BUILD-TIME drift gate — every email part-ref is resolved + verified. - gateRef(templateName, subjectRef, provider, fields); - gateRef(templateName, htmlBodyRef, provider, fields); - if (typeof textBodyRef === "string") { - gateRef(templateName, textBodyRef, provider, fields); - } + const subjectEngine = gateRef(templateName, subjectRef, provider, fields); + const htmlEngine = gateRef(templateName, htmlBodyRef, provider, fields); + const textEngine = + typeof textBodyRef === "string" ? gateRef(templateName, textBodyRef, provider, fields) : ""; const textBodyLine = typeof textBodyRef === "string" - ? `\n textBody: render({ ref: ${JSON.stringify(textBodyRef)}, payload, format: "text", provider, verify: ${ft} }),` + ? `\n textBody: render({ ref: ${JSON.stringify(textBodyRef)}, payload, format: "text", provider, verify: ${ft}${textEngine} }),` : ""; return `${renderHelperHeader("Provider, EmailDocument", payloadTypeName, payloadSpecifier)} @@ -241,8 +249,8 @@ export function renderRenderHelper( */ export function ${fnName}(payload: ${payloadTypeName}, provider: Provider): EmailDocument { return { - subject: render({ ref: ${JSON.stringify(subjectRef)}, payload, format: "text", provider, verify: ${ft} }), - htmlBody: render({ ref: ${JSON.stringify(htmlBodyRef)}, payload, format: "html", provider, verify: ${ft} }),${textBodyLine} + subject: render({ ref: ${JSON.stringify(subjectRef)}, payload, format: "text", provider, verify: ${ft}${subjectEngine} }), + htmlBody: render({ ref: ${JSON.stringify(htmlBodyRef)}, payload, format: "html", provider, verify: ${ft}${htmlEngine} }),${textBodyLine} }; } `; @@ -265,7 +273,7 @@ export function ${fnName}(payload: ${payloadTypeName}, provider: Provider): Emai : undefined; // BUILD-TIME drift gate. - gateRef(templateName, textRef, provider, fields); + const engineArg = gateRef(templateName, textRef, provider, fields); const maxCharsArg = maxChars !== undefined && Number.isFinite(maxChars) ? `, maxChars: ${maxChars}` : ""; @@ -278,7 +286,7 @@ export function ${fnName}(payload: ${payloadTypeName}, provider: Provider): Emai * check matches the build-time gate enforced when this file was generated. */ export function ${fnName}(payload: ${payloadTypeName}, provider: Provider): string { - return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider, verify: ${ft}${maxCharsArg} }); + return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider, verify: ${ft}${maxCharsArg}${engineArg} }); } `; } diff --git a/server/typescript/packages/codegen-ts/test/render-engine/framework-provider.test.ts b/server/typescript/packages/codegen-ts/test/render-engine/framework-provider.test.ts index 7bd6d2b16e..679bd46575 100644 --- a/server/typescript/packages/codegen-ts/test/render-engine/framework-provider.test.ts +++ b/server/typescript/packages/codegen-ts/test/render-engine/framework-provider.test.ts @@ -110,3 +110,28 @@ describe("ProviderChain", () => { expect(chain.resolve("nope")).toBeUndefined(); }); }); + +// ADR-0061: a project template may be Jinja; the chain reports the engine of the body it returns. +describe("Jinja project templates", () => { + test("a project templates/.jinja resolves, and the chain reports engine jinja", () => { + const dir = makeTempProject(); + mkdirSync(join(dir, "templates", "prompts"), { recursive: true }); + writeFileSync(join(dir, "templates", "prompts", "greet.jinja"), "Hi {{ name }}"); + writeFileSync(join(dir, "templates", "prompts", "plain.mustache"), "Hi {{name}}"); + const provider = projectProvider(dir); + expect(provider.resolve("prompts/greet")).toBe("Hi {{ name }}"); + expect(provider.engineOf?.("prompts/greet")).toBe("jinja"); + expect(provider.engineOf?.("prompts/plain")).toBe("mustache"); + expect(provider.engineOf?.("prompts/none")).toBeUndefined(); + expect(new FileSystemProvider(join(dir, "templates")).engineOf("prompts/greet")).toBe("jinja"); + }); + + test("ProviderChain asks the provider that answered", () => { + const chain = new ProviderChain([ + { resolve: (r: string) => (r === "a" ? "A" : undefined), engineOf: () => "jinja" as const }, + { resolve: () => "B" }, + ]); + expect(chain.engineOf("a")).toBe("jinja"); + expect(chain.engineOf("b")).toBe("mustache"); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/render-helper-codegen.test.ts b/server/typescript/packages/codegen-ts/test/render-helper-codegen.test.ts index 0a074e5644..b9ada72cdb 100644 --- a/server/typescript/packages/codegen-ts/test/render-helper-codegen.test.ts +++ b/server/typescript/packages/codegen-ts/test/render-helper-codegen.test.ts @@ -266,3 +266,41 @@ describe("render-helper codegen — import-and-RUN proof (bun dynamic import)", expect(email.textBody).toBe("Hi Ada"); }); }); + +// ADR-0061: a Jinja body goes through the same gate, and the helper names its engine so a +// runtime provider that cannot tell (an in-memory one, as here) still renders it as Jinja. +describe("render-helper codegen — Jinja bodies", () => { + test("a Jinja body passes the gate, and the emitted call names the engine", async () => { + const root = await loadRoot(DOC_MODEL); + const gate = new InMemoryProvider({ "pages/welcome": "Hello {{ name | upper }}" }, { engine: "jinja" }); + const src = renderRenderHelper(root, "WelcomePage", gate); + expect(src).toContain('engine: "jinja"'); + + const dir = mkdtempSync(join(import.meta.dir, "rh-jinja-")); + TEMP_DIRS.push(dir); + writeFileSync(join(dir, "payloads.ts"), "export interface WelcomeVO { name: string; }\n"); + writeFileSync(join(dir, "WelcomePage.render.ts"), src); + const mod = await import(join(dir, "WelcomePage.render.ts")); + // The runtime provider here knows nothing about engines; the helper's own `engine` decides. + const runtime = new InMemoryProvider({ "pages/welcome": "Hello {{ name | upper }}" }); + expect(mod.renderWelcomePage({ name: "Ada" }, runtime)).toBe("Hello ADA"); + }); + + test("a drifted Jinja body THROWS (fails codegen) naming the path", async () => { + const root = await loadRoot(DOC_MODEL); + const gate = new InMemoryProvider({ "pages/welcome": "{% for x in nope %}{{ x }}{% endfor %}" }, { engine: "jinja" }); + expect(() => renderRenderHelper(root, "WelcomePage", gate)).toThrow(/ERR_VAR_NOT_ON_PAYLOAD: \{\{nope\}\}/); + }); + + test("a Jinja body the check cannot settle THROWS with ERR_TEMPLATE_NOT_STATIC", async () => { + const root = await loadRoot(DOC_MODEL); + const gate = new InMemoryProvider({ "pages/welcome": "{% include name %}" }, { engine: "jinja" }); + expect(() => renderRenderHelper(root, "WelcomePage", gate)).toThrow(/ERR_TEMPLATE_NOT_STATIC: include/); + }); + + test("a Mustache body emits no engine argument (byte-identical to before)", async () => { + const root = await loadRoot(DOC_MODEL); + const src = renderRenderHelper(root, "WelcomePage", new InMemoryProvider({ "pages/welcome": "Hello {{name}}" })); + expect(src).not.toContain("engine:"); + }); +}); diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index 05923a8ddf..da75f58441 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -10,7 +10,8 @@ import type { ErrorSource, LoaderError, NodeContext } from "./source.js"; // Cross-language conformance consumers should not expect these codes from the TS adapter. // // FR-004 build-time `verify` codes (ERR_VAR_NOT_ON_PAYLOAD, ERR_PARTIAL_UNRESOLVED, -// ERR_REQUIRED_SLOT_UNUSED, ERR_OUTPUT_TAG_MISSING) are emitted by `meta verify` / the +// ERR_REQUIRED_SLOT_UNUSED, ERR_OUTPUT_TAG_MISSING, and ADR-0061's ERR_TEMPLATE_NOT_STATIC, +// ERR_TEMPLATE_SYNTAX, ERR_TEMPLATE_ENGINE_UNSUPPORTED) are emitted by `meta verify` / the // zero-core-dependency @metaobjectsdev/render engine — NOT by the loader. They are // registered here (and in fixtures/conformance/ERROR-CODES.json) only to keep the code // vocabulary single-sourced across languages; render re-declares them locally to avoid @@ -141,6 +142,9 @@ export const ERROR_CODES = [ "ERR_PARTIAL_UNRESOLVED", "ERR_REQUIRED_SLOT_UNUSED", "ERR_OUTPUT_TAG_MISSING", + "ERR_TEMPLATE_NOT_STATIC", + "ERR_TEMPLATE_SYNTAX", + "ERR_TEMPLATE_ENGINE_UNSUPPORTED", "ERR_BAD_ATTR_FILTER", "ERR_STORAGE_FLATTENED_ARRAY", "ERR_STORAGE_WITHOUT_OBJECT_REF", diff --git a/server/typescript/packages/render/README.md b/server/typescript/packages/render/README.md index cbd9115a35..f622404ce3 100644 --- a/server/typescript/packages/render/README.md +++ b/server/typescript/packages/render/README.md @@ -1,6 +1,6 @@ # @metaobjectsdev/render -Logic-less, deterministic text render engine (Mustache) for MetaObjects templates — provider-resolved partials, format-driven escaping, and stable, snapshot-testable output. Zero runtime dependency on the rest of MetaObjects. +Deterministic text render engine for MetaObjects templates — Mustache (logic-less, the default and cross-port engine) and Jinja (via nunjucks, ADR-0061) — with provider-resolved partials, format-driven escaping, and stable, snapshot-testable output. Zero runtime dependency on the rest of MetaObjects. Part of the [MetaObjects](https://github.com/metaobjectsdev/metaobjects) monorepo. @@ -17,7 +17,14 @@ npm install @metaobjectsdev/render - **Providers** — `InMemoryProvider` (root entry, browser-safe) and `FilesystemProvider` (Node-only, on the `@metaobjectsdev/render/providers` subpath so the root entry never pulls in `node:fs`). `new FilesystemProvider("./prompts").resolve("lobby/welcome")` reads - `./prompts/lobby/welcome.mustache`, exactly as the C#, Java and Python ports do. -- **`verify()`** — a build-time drift check that every `{{field}}` referenced by a template resolves to a field on its payload value-object; a renamed field fails the build instead of silently degrading a prompt. + `./prompts/lobby/welcome.mustache`, exactly as the C#, Java and Python ports do; when there + is none it reads `welcome.jinja`, `.j2` or `.jinja2`, and `engineOf(ref)` says which engine + that file is for. +- **Jinja bodies** — a ref that resolves to a Jinja file renders with nunjucks, through the + same escapers and provider. `render({ engine: "jinja", … })` and + `verify(text, fields, { engine: "jinja" })` name the engine explicitly (for an + `InMemoryProvider`, or pass `{ engine: "jinja" }` to its constructor). The Python port renders + the same vocabulary byte-identically with jinja2; Java, Kotlin and C# render Mustache only. +- **`verify()`** — a build-time drift check that every `{{field}}` referenced by a template resolves to a field on its payload value-object; a renamed field fails the build instead of silently degrading a prompt. For a Jinja body it walks the parsed template (paths, loop targets, filter arguments, includes) and reports what it cannot settle statically as `ERR_TEMPLATE_NOT_STATIC`. This engine backs the prompt-construction pillar — the generated `render()` helpers emitted by `@metaobjectsdev/codegen-ts` wrap it. See the **metaobjects-prompts** skill and the repo `docs/` for authoring `template.*` and payload VOs. diff --git a/server/typescript/packages/render/package.json b/server/typescript/packages/render/package.json index d3c1ba7d6a..279ac39cff 100644 --- a/server/typescript/packages/render/package.json +++ b/server/typescript/packages/render/package.json @@ -1,7 +1,7 @@ { "name": "@metaobjectsdev/render", "version": "1.1.4", - "description": "Logic-less, deterministic text render engine (Mustache) for MetaObjects templates — provider-resolved partials, format-driven escaping, zero core dependency.", + "description": "Deterministic text render engine for MetaObjects templates \u2014 Mustache (logic-less, the cross-port default) and Jinja (via nunjucks), provider-resolved partials, format-driven escaping, build-time payload drift check, zero core dependency.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,6 +42,7 @@ "metaobjects", "render", "mustache", + "jinja", "prompt", "template" ], @@ -49,10 +50,12 @@ "access": "public" }, "dependencies": { - "mustache": "^4.2.0" + "mustache": "^4.2.0", + "nunjucks": "^3.2.4" }, "devDependencies": { "@types/mustache": "^4.2.5", + "@types/nunjucks": "^3.2.6", "bun-types": "latest", "typescript": "^5.6.0" } diff --git a/server/typescript/packages/render/src/engine.ts b/server/typescript/packages/render/src/engine.ts new file mode 100644 index 0000000000..7dd78467ac --- /dev/null +++ b/server/typescript/packages/render/src/engine.ts @@ -0,0 +1,72 @@ +// Which template engine a body is written for (ADR-0061). +// +// Mustache is the default and the cross-port engine. Jinja is a per-port second +// engine (TypeScript and Python): a body is Jinja when its FILE says so — a +// `.jinja` / `.j2` / `.jinja2` extension beside the usual `.mustache` — or when +// the caller says so with an explicit `engine` option. The metadata never names +// an engine: `@textRef` stays a logical `group/source` reference, so the portable +// vocabulary carries nothing one port understands and another does not. + +import type { Provider } from "./provider.js"; + +/** The engines `render` and `verify` accept. */ +export const TEMPLATE_ENGINES = ["mustache", "jinja"] as const; +export type TemplateEngine = (typeof TEMPLATE_ENGINES)[number]; + +export const TEMPLATE_ENGINE_MUSTACHE: TemplateEngine = "mustache"; +export const TEMPLATE_ENGINE_JINJA: TemplateEngine = "jinja"; + +/** The Mustache file extension — the default in every port. */ +export const MUSTACHE_EXTENSION = ".mustache"; + +/** File extensions that mark a body as Jinja, in lookup order. */ +export const JINJA_EXTENSIONS = [".jinja", ".j2", ".jinja2"] as const; + +/** The engine a file extension (with its leading dot) selects. Anything else is Mustache. */ +export function engineForExtension(extension: string): TemplateEngine { + return (JINJA_EXTENSIONS as readonly string[]).includes(extension.toLowerCase()) + ? TEMPLATE_ENGINE_JINJA + : TEMPLATE_ENGINE_MUSTACHE; +} + +/** + * A partial written for the other engine. A Mustache body includes Mustache partials and + * a Jinja body includes Jinja templates; when the provider reports that `ref` is the + * other engine's file, the include is refused (`ERR_TEMPLATE_ENGINE_UNSUPPORTED`) rather + * than spliced in as text the including engine would misread. + */ +export function partialEngineMismatch( + provider: Provider, + ref: string, + including: TemplateEngine, +): boolean { + const known = provider.engineOf?.(ref); + return known !== undefined && known !== including; +} + +/** The message a refused cross-engine partial raises at render time. */ +export function partialEngineMessage(ref: string, including: TemplateEngine): string { + return `ERR_TEMPLATE_ENGINE_UNSUPPORTED: partial ${ref} is not a ${including} template; a ${including} body includes only ${including} partials`; +} + +/** + * The engine for `ref` resolved through `provider`: an explicit choice wins, then + * what the provider knows about the ref (its file extension), then Mustache. + */ +export function templateEngineOf( + provider: Provider | undefined, + ref: string | undefined, + explicit?: TemplateEngine, +): TemplateEngine { + if (explicit !== undefined) { + if (!(TEMPLATE_ENGINES as readonly string[]).includes(explicit)) { + throw new Error(`unknown template engine ${JSON.stringify(explicit)}; expected one of ${TEMPLATE_ENGINES.join(", ")}`); + } + return explicit; + } + if (provider !== undefined && ref !== undefined) { + const known = provider.engineOf?.(ref); + if (known !== undefined) return known; + } + return TEMPLATE_ENGINE_MUSTACHE; +} diff --git a/server/typescript/packages/render/src/filesystem-provider.ts b/server/typescript/packages/render/src/filesystem-provider.ts index 6c7873bbe6..16539e2af1 100644 --- a/server/typescript/packages/render/src/filesystem-provider.ts +++ b/server/typescript/packages/render/src/filesystem-provider.ts @@ -1,5 +1,7 @@ // Filesystem-backed provider: resolves a `group/source` reference to -// //.mustache. NODE-ONLY — it is reachable solely through +// //.mustache — or, when no Mustache file exists, to a Jinja +// file beside it (`.jinja`, `.j2`, `.jinja2`; ADR-0061), whose extension then +// selects the Jinja engine. NODE-ONLY — it is reachable solely through // the `@metaobjectsdev/render/providers` subpath so the package's root entry // stays importable in a browser bundle. // @@ -12,33 +14,59 @@ import { readFileSync, statSync } from "node:fs"; import { resolve, sep } from "node:path"; import type { Provider } from "./provider.js"; +import { JINJA_EXTENSIONS, MUSTACHE_EXTENSION, engineForExtension, type TemplateEngine } from "./engine.js"; /** The default template file extension, shared with every other port. */ -export const DEFAULT_TEMPLATE_EXTENSION = ".mustache"; +export const DEFAULT_TEMPLATE_EXTENSION = MUSTACHE_EXTENSION; const REF_SEPARATOR = "/"; const PARENT_SEGMENT = ".."; export class FilesystemProvider implements Provider { private readonly root: string; + private readonly extensions: readonly string[]; - constructor(root: string, private readonly extension: string = DEFAULT_TEMPLATE_EXTENSION) { + /** + * @param extension the one extension to read. Omitted, the provider reads + * `.mustache` and falls back to the Jinja extensions; given — even as + * `.mustache` — it reads only that extension. + */ + constructor(root: string, extension?: string) { this.root = resolve(root); + this.extensions = extension === undefined + ? [DEFAULT_TEMPLATE_EXTENSION, ...JINJA_EXTENSIONS] + : [extension]; } resolve(ref: string): string | undefined { - const segments = ref.split(REF_SEPARATOR).filter((s) => s.length > 0); - if (segments.length === 0 || segments.includes(PARENT_SEGMENT)) return undefined; - - const candidate = resolve(this.root, ...segments) + this.extension; - // Path-traversal guard (belt-and-suspenders after the `..` check). - if (!candidate.startsWith(this.root + sep)) return undefined; - + const found = this.find(ref); + if (found === undefined) return undefined; try { - if (!statSync(candidate).isFile()) return undefined; - return readFileSync(candidate, "utf8"); + return readFileSync(found.path, "utf8"); } catch { return undefined; } } + + engineOf(ref: string): TemplateEngine | undefined { + const found = this.find(ref); + return found === undefined ? undefined : engineForExtension(found.extension); + } + + private find(ref: string): { path: string; extension: string } | undefined { + const segments = ref.split(REF_SEPARATOR).filter((s) => s.length > 0); + if (segments.length === 0 || segments.includes(PARENT_SEGMENT)) return undefined; + const base = resolve(this.root, ...segments); + // Path-traversal guard (belt-and-suspenders after the `..` check). + if (!base.startsWith(this.root + sep)) return undefined; + for (const extension of this.extensions) { + const path = base + extension; + try { + if (statSync(path).isFile()) return { path, extension }; + } catch { + // absent — try the next extension + } + } + return undefined; + } } diff --git a/server/typescript/packages/render/src/index.ts b/server/typescript/packages/render/src/index.ts index 151f75642c..da7f9bbc3a 100644 --- a/server/typescript/packages/render/src/index.ts +++ b/server/typescript/packages/render/src/index.ts @@ -1,5 +1,16 @@ export { render, type RenderOptions } from "./render.js"; -export { type Provider, InMemoryProvider } from "./provider.js"; +export { type Provider, type InMemoryProviderOptions, InMemoryProvider } from "./provider.js"; +export { + TEMPLATE_ENGINES, + TEMPLATE_ENGINE_MUSTACHE, + TEMPLATE_ENGINE_JINJA, + MUSTACHE_EXTENSION, + JINJA_EXTENSIONS, + engineForExtension, + templateEngineOf, + type TemplateEngine, +} from "./engine.js"; +export { jinjaReferenceLine } from "./jinja/verify.js"; export { ESCAPERS, type RenderFormat } from "./escapers.js"; export { verify, @@ -9,6 +20,9 @@ export { ERR_PARTIAL_UNRESOLVED, ERR_REQUIRED_SLOT_UNUSED, ERR_OUTPUT_TAG_MISSING, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, type PayloadField, type ResolveStack, type VerifyError, diff --git a/server/typescript/packages/render/src/jinja/nunjucks.ts b/server/typescript/packages/render/src/jinja/nunjucks.ts new file mode 100644 index 0000000000..bc73ba2f71 --- /dev/null +++ b/server/typescript/packages/render/src/jinja/nunjucks.ts @@ -0,0 +1,127 @@ +// The one place this package touches nunjucks (ADR-0061). +// +// nunjucks is the TypeScript port's Jinja engine. Its parser and node classes are +// public but untyped (`@types/nunjucks` types only the render API), so the small +// surface the render and verify paths read is declared here, once, instead of +// being cast at every call site. + +import nunjucks from "nunjucks"; +import { JINJA_EXTENSIONS } from "../engine.js"; +import type { Provider } from "../provider.js"; + +/** A parsed nunjucks node. `fields` names the node's own child slots, in order. */ +export interface NjNode { + readonly typename: string; + readonly fields: readonly string[]; + readonly lineno?: number; + readonly [field: string]: unknown; +} + +interface CaptureCompiler { + compileCapture(this: CaptureCompiler, node: unknown, frame: unknown): void; + _emit(code: string): void; +} + +interface NunjucksInternals { + parser: { parse(src: string): NjNode }; + lib: { escape(value: string): string }; + compiler: { Compiler: { prototype: CaptureCompiler } }; +} + +const internals = nunjucks as unknown as NunjucksInternals; + +/** Parse Jinja text into a nunjucks node tree. Throws on a syntax error. */ +export function parseJinja(text: string): NjNode { + return internals.parser.parse(text); +} + +/** True for a parsed node (as opposed to a primitive slot value or null). */ +export function isNode(value: unknown): value is NjNode { + return typeof value === "object" && value !== null && typeof (value as NjNode).typename === "string"; +} + +/** The child nodes in a node's slots, in declaration order (a `children` list is flattened). */ +export function childNodes(node: NjNode): NjNode[] { + const out: NjNode[] = []; + const push = (v: unknown): void => { + if (Array.isArray(v)) v.forEach(push); + else if (isNode(v)) out.push(v); + }; + for (const f of node.fields) push(node[f]); + // A block `{% set x %}…{% endset %}` keeps its body outside `fields`. + if (node.typename === "Set") push(node["body"]); + return out; +} + +/** + * The provider ref a Jinja template name addresses. Jinja code names files, so a + * trailing `.jinja` / `.j2` / `.jinja2` is dropped: `{% include "parts/footer.jinja" %}` + * and `{% include "parts/footer" %}` resolve the same `parts/footer` ref. + */ +export function refOfTemplateName(name: string): string { + for (const ext of JINJA_EXTENSIONS) { + if (name.toLowerCase().endsWith(ext)) return name.slice(0, -ext.length); + } + return name; +} + +/** + * nunjucks compiles a block `{% set x %}…{% endset %}` (and a `{% filter %}` body) to a + * plain string, so printing it escapes its already-escaped values a second time. Jinja + * marks that capture safe, and so does this render: the capture is wrapped in + * `runtime.markSafe` while `renderJinjaText` compiles. Macro and `call` output is already + * a safe string in nunjucks. + */ +function safeCapture(original: CaptureCompiler["compileCapture"]): CaptureCompiler["compileCapture"] { + return function (this: CaptureCompiler, node: unknown, frame: unknown): void { + this._emit("runtime.markSafe("); + original.call(this, node, frame); + this._emit(")"); + }; +} + +/** + * A nunjucks environment whose loader is `provider` and whose escaping is + * `escaper`. `autoescape` is on so every `{{ … }}` goes through the escape hook — + * which `renderJinja` points at the format's escaper for the duration of one + * synchronous render, exactly as `render()` does with `Mustache.escape`. + */ +function environment(provider: Provider): nunjucks.Environment { + const loader = { + getSource(name: string): { src: string; path: string; noCache: boolean } | null { + const src = provider.resolve(refOfTemplateName(name)); + return src === undefined ? null : { src, path: name, noCache: true }; + }, + }; + return new nunjucks.Environment(loader as unknown as nunjucks.ILoader, { + autoescape: true, + throwOnUndefined: false, + trimBlocks: false, + lstripBlocks: false, + }); +} + +/** Render Jinja `text` against `context`, escaping every `{{ … }}` with `escaper`. */ +export function renderJinjaText( + text: string, + context: object, + provider: Provider, + escaper: (s: string) => string, +): string { + const env = environment(provider); + // nunjucks has no per-environment escape or compile hook, so both are swapped on the + // module for the length of this call. The render is synchronous (no async loader or + // filter is registered, and every template compiles inside `renderString` because the + // loader returns `noCache`), so nothing else can observe the swap. + const compiler = internals.compiler.Compiler.prototype; + const prevEscape = internals.lib.escape; + const prevCapture = compiler.compileCapture; + internals.lib.escape = (v: unknown) => escaper(typeof v === "string" ? v : String(v)); + compiler.compileCapture = safeCapture(prevCapture); + try { + return env.renderString(text, context); + } finally { + internals.lib.escape = prevEscape; + compiler.compileCapture = prevCapture; + } +} diff --git a/server/typescript/packages/render/src/jinja/render.ts b/server/typescript/packages/render/src/jinja/render.ts new file mode 100644 index 0000000000..6b4ba2b49b --- /dev/null +++ b/server/typescript/packages/render/src/jinja/render.ts @@ -0,0 +1,55 @@ +// Render a Jinja body (ADR-0061): the nunjucks engine, with the same provider, +// the same format-driven escapers and the same partial rules as the Mustache path. +// +// Before rendering, the static template graph (`{% include %}` / `{% import %}` / +// `{% extends %}` with a literal name) is resolved once, so a cycle or a missing +// template fails with the same messages the Mustache path uses ("partial cycle:", +// "unresolved partial:") rather than as a stack overflow inside the engine. + +import type { Provider } from "../provider.js"; +import { MAX_DEPTH } from "../verify-shared.js"; +import { TEMPLATE_ENGINE_JINJA, partialEngineMessage, partialEngineMismatch } from "../engine.js"; +import { childNodes, parseJinja, refOfTemplateName, renderJinjaText, type NjNode } from "./nunjucks.js"; + +const TEMPLATE_REF_NODES: ReadonlySet = new Set(["Include", "Import", "FromImport", "Extends"]); + +function literalTemplateRefs(node: NjNode, out: { ref: string; optional: boolean }[]): void { + if (TEMPLATE_REF_NODES.has(node.typename)) { + const t = node["template"] as NjNode; + if (t.typename === "Literal" && typeof t["value"] === "string") { + out.push({ ref: refOfTemplateName(t["value"]), optional: node["ignoreMissing"] === true }); + } + } + for (const child of childNodes(node)) literalTemplateRefs(child, out); +} + +function checkTemplateGraph(text: string, provider: Provider, path: readonly string[]): void { + const refs: { ref: string; optional: boolean }[] = []; + literalTemplateRefs(parseJinja(text), refs); + for (const { ref, optional } of refs) { + if (path.includes(ref)) throw new Error(`partial cycle: ${[...path, ref].join(" -> ")}`); + if (path.length >= MAX_DEPTH) throw new Error(`partial depth exceeded ${MAX_DEPTH}: ${ref}`); + const body = provider.resolve(ref); + if (body === undefined) { + if (optional) continue; + throw new Error(`unresolved partial: ${ref}`); + } + if (partialEngineMismatch(provider, ref, TEMPLATE_ENGINE_JINJA)) { + throw new Error(partialEngineMessage(ref, TEMPLATE_ENGINE_JINJA)); + } + checkTemplateGraph(body, provider, [...path, ref]); + } +} + +/** Render Jinja `body` (whose own ref, when it has one, is `ref`) against `payload`. */ +export function renderJinja( + body: string, + ref: string | undefined, + payload: unknown, + provider: Provider, + escaper: (s: string) => string, +): string { + checkTemplateGraph(body, provider, ref !== undefined ? [ref] : []); + const context = typeof payload === "object" && payload !== null ? payload : {}; + return renderJinjaText(body, context, provider, escaper); +} diff --git a/server/typescript/packages/render/src/jinja/verify.ts b/server/typescript/packages/render/src/jinja/verify.ts new file mode 100644 index 0000000000..1f2fc5d430 --- /dev/null +++ b/server/typescript/packages/render/src/jinja/verify.ts @@ -0,0 +1,552 @@ +// The Jinja half of the template drift check (ADR-0061). Same contract as the +// Mustache `verify`: every payload reference a template makes must exist on the +// declared payload field tree, or it is reported. It walks the PARSED nunjucks +// AST — never the text — and follows what Jinja code actually does: +// +// {{ user.name }} / {{ user["name"] }} a path into the payload, checked per segment +// {% for p in posts %}{{ p.title }} `posts` is checked, then `p.title` against +// posts' element fields +// {% set x = … %} / macro params locals, not payload — never reported +// {{ name | upper }} the argument is checked, the filter is not +// {% if %} / {{ a if b else c }} every operand is checked +// {% include "x" %} / import / extends resolved through the provider, walked +// +// A reference the check cannot settle without running the template — a dynamic key +// on an object, a computed template name — is ERR_TEMPLATE_NOT_STATIC: reported, +// never passed. The Python port (metaobjects/render/jinja_verify.py) walks the +// jinja2 AST to the same rules; both are held to fixtures/jinja-verify-conformance/. + +import { + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + MAX_DEPTH, + slotAndTagFindings, + type PayloadField, + type VerifyError, + type VerifyOptions, +} from "../verify-shared.js"; +import { TEMPLATE_ENGINE_JINJA, partialEngineMismatch } from "../engine.js"; +import { childNodes, isNode, parseJinja, refOfTemplateName, type NjNode } from "./nunjucks.js"; + +/** A value whose shape the payload declares. `path` is the expression as written. */ +interface Typed { + readonly tag: "typed"; + readonly fields: PayloadField[] | undefined; + readonly isArray: boolean; + readonly path: string; +} +/** A local, a literal or a computed value: not payload, so nothing to check on it. */ +interface Opaque { + readonly tag: "opaque"; +} +type Value = Typed | Opaque; +const OPAQUE: Opaque = { tag: "opaque" }; + +/** + * Names a template may use without the payload declaring them: the engines' own + * globals and the block helpers. Shared with the Python port, so a name is + * accepted in one port exactly when it is accepted in the other. + */ +export const JINJA_GLOBALS: ReadonlySet = new Set([ + "range", "cycler", "joiner", "namespace", "dict", "lipsum", "super", "self", +]); + +/** Filters whose result is ONE element of their argument. */ +const ELEMENT_FILTERS: ReadonlySet = new Set(["first", "last", "random"]); +/** Filters whose result has the shape of their argument. */ +const SAME_SHAPE_FILTERS: ReadonlySet = new Set([ + "sort", "reverse", "list", "unique", "selectattr", "rejectattr", "default", "d", +]); +/** + * Filters with a positional argument that names an element attribute, and its index + * among the arguments after the subject (`join(", ", "name")`, `sort(false, false, "name")`). + * The same positions in jinja2 and nunjucks; `min`, `max` and `unique` are jinja2's, + * kept so the two ports' checks agree. + */ +const ATTRIBUTE_ARG_INDEX: ReadonlyMap = new Map([ + ["selectattr", 0], ["rejectattr", 0], ["groupby", 0], ["sum", 0], + ["join", 1], ["min", 1], ["max", 1], ["unique", 1], ["sort", 2], +]); +/** The keyword argument that names an element attribute (`map(attribute="x")`). */ +const ATTRIBUTE_KWARG = "attribute"; + +interface Scope { + /** Local frames, innermost last. */ + readonly frames: Map[]; + /** The payload fields reachable by bare name (empty for an import without context). */ + readonly root: PayloadField[]; + /** The include / import / extends chain that led here (cycle guard). */ + readonly seen: readonly string[]; +} + +/** A segment of a static member chain. */ +type Segment = + | { readonly kind: "key"; readonly key: string } + | { readonly kind: "index"; readonly index: number } + | { readonly kind: "dynamic"; readonly node: NjNode }; + +function typedOf(field: PayloadField, path: string): Typed { + return { tag: "typed", fields: field.fields, isArray: field.isArray === true, path }; +} + +/** The element a `for` over `v` binds, named `path`. */ +function elementOf(v: Value, path: string): Value { + if (v.tag === "opaque") return OPAQUE; + // An array iterates its elements; a non-array object iterates its keys (strings); + // a scalar iterates its characters. Only the first has fields. + return { tag: "typed", fields: v.isArray ? v.fields : undefined, isArray: false, path }; +} + +function segmentText(s: Segment): string { + if (s.kind === "key") return `.${s.key}`; + if (s.kind === "index") return `[${s.index}]`; + return "[*]"; +} + +/** + * Check a Jinja template against a payload field tree. Same options and codes as + * `verify`; reached through `verify(text, fields, { engine: "jinja" })`. + */ +export function verifyJinja( + templateText: string, + fields: PayloadField[], + opts?: VerifyOptions, +): VerifyError[] { + const errors: VerifyError[] = []; + const provider = opts?.provider; + const referencedAtRoot = new Set(); + const staticTexts: string[] = [templateText]; + const report = (code: string, path: string): void => { + errors.push({ code, path }); + }; + + function lookupName(name: string, scope: Scope): Value | undefined { + for (let i = scope.frames.length - 1; i >= 0; i--) { + const hit = scope.frames[i]!.get(name); + if (hit !== undefined) return hit; + } + const field = scope.root.find((f) => f.name === name); + if (field !== undefined) { + if (scope.root === fields) referencedAtRoot.add(name); + return typedOf(field, name); + } + return JINJA_GLOBALS.has(name) ? OPAQUE : undefined; + } + + function bind(scope: Scope, name: string, value: Value): void { + scope.frames[scope.frames.length - 1]!.set(name, value); + } + + function pushFrame(scope: Scope): Scope { + return { ...scope, frames: [...scope.frames, new Map()] }; + } + + /** Flatten `a.b[0][k]` into its base node and segments. */ + function chainOf(node: NjNode): { base: NjNode; segments: Segment[] } { + const segments: Segment[] = []; + let cur = node; + while (cur.typename === "LookupVal") { + const val = cur["val"] as NjNode; + if (val.typename === "Literal" && typeof val["value"] === "string") { + segments.unshift({ kind: "key", key: val["value"] }); + } else if (val.typename === "Literal" && typeof val["value"] === "number") { + segments.unshift({ kind: "index", index: val["value"] }); + } else { + segments.unshift({ kind: "dynamic", node: val }); + } + cur = cur["target"] as NjNode; + } + return { base: cur, segments }; + } + + /** Evaluate a member chain: resolve the base, then descend one segment at a time. */ + function evalChain(node: NjNode, scope: Scope): Value { + const { base, segments } = chainOf(node); + for (const s of segments) if (s.kind === "dynamic") evalExpr(s.node, scope); + let value: Value; + let path: string; + if (base.typename === "Symbol") { + path = base["value"] as string; + const fullPath = path + segments.map(segmentText).join(""); + const hit = lookupName(path, scope); + if (hit === undefined) { + report(ERR_VAR_NOT_ON_PAYLOAD, fullPath); + return OPAQUE; + } + value = hit; + } else { + value = evalExpr(base, scope); + path = value.tag === "typed" ? value.path : ""; + } + const fullPath = path + segments.map(segmentText).join(""); + for (const s of segments) { + if (value.tag === "opaque") return OPAQUE; + path += segmentText(s); + if (s.kind === "key") { + const child = value.isArray ? undefined : value.fields?.find((f) => f.name === s.key); + if (child === undefined) { + report(ERR_VAR_NOT_ON_PAYLOAD, fullPath); + return OPAQUE; + } + value = typedOf(child, path); + } else if (value.isArray) { + value = { tag: "typed", fields: value.fields, isArray: false, path }; + } else if (s.kind === "dynamic" && value.fields !== undefined) { + // A computed key on an object: which field it reads is decided at render time. + report(ERR_TEMPLATE_NOT_STATIC, fullPath); + return OPAQUE; + } else { + // A literal index into an object, or any index into a string: an element or a + // character, carrying the same fields (none, for a string). + value = { tag: "typed", fields: value.fields, isArray: false, path }; + } + } + return value; + } + + /** Check an `attribute` name (dotted) against the elements of `receiver`. */ + function checkAttributeName(receiver: Value, attrNode: NjNode, scope: Scope): void { + if (receiver.tag === "opaque") { + evalExpr(attrNode, scope); + return; + } + if (attrNode.typename !== "Literal" || typeof attrNode["value"] !== "string") { + evalExpr(attrNode, scope); + report(ERR_TEMPLATE_NOT_STATIC, `${receiver.path}[*]`); + return; + } + const attr = attrNode["value"]; + let current: PayloadField[] | undefined = receiver.fields; + for (const seg of attr.split(".")) { + const hit = current?.find((f) => f.name === seg); + if (hit === undefined) { + report(ERR_VAR_NOT_ON_PAYLOAD, `${receiver.path}.${attr}`); + return; + } + current = hit.fields; + } + } + + /** Split a call/filter argument list into positional args and keyword pairs. */ + function splitArgs(args: NjNode | undefined): { positional: NjNode[]; keywords: [string, NjNode][] } { + const positional: NjNode[] = []; + const keywords: [string, NjNode][] = []; + for (const a of args === undefined ? [] : childNodes(args)) { + if (a.typename === "KeywordArgs") { + for (const pair of childNodes(a)) { + const key = pair["key"] as NjNode; + keywords.push([String(key["value"]), pair["value"] as NjNode]); + } + } else { + positional.push(a); + } + } + return { positional, keywords }; + } + + function evalFilter(node: NjNode, scope: Scope): Value { + const name = String((node["name"] as NjNode)["value"]); + const { positional, keywords } = splitArgs(node["args"] as NjNode); + const [subject, ...rest] = positional; + const receiver = subject === undefined ? OPAQUE : evalExpr(subject, scope); + rest.forEach((a, i) => { + if (i === ATTRIBUTE_ARG_INDEX.get(name)) checkAttributeName(receiver, a, scope); + else evalExpr(a, scope); + }); + for (const [key, value] of keywords) { + if (key === ATTRIBUTE_KWARG) checkAttributeName(receiver, value, scope); + else evalExpr(value, scope); + } + if (ELEMENT_FILTERS.has(name)) { + return receiver.tag === "typed" ? elementOf(receiver, `${receiver.path}|${name}`) : OPAQUE; + } + if (SAME_SHAPE_FILTERS.has(name)) return receiver; + return OPAQUE; + } + + function evalCall(node: NjNode, scope: Scope): Value { + const callee = node["name"] as NjNode; + if (callee.typename === "LookupVal") { + // A method call (`user.items()`, `name.upper()`): the receiver is a payload path + // and is checked; the method belongs to the value, not to the payload. + evalExpr(callee["target"] as NjNode, scope); + } else if (callee.typename === "Symbol") { + const name = callee["value"] as string; + if (lookupName(name, scope) === undefined) report(ERR_VAR_NOT_ON_PAYLOAD, name); + } else { + evalExpr(callee, scope); + } + const { positional, keywords } = splitArgs(node["args"] as NjNode); + positional.forEach((a) => evalExpr(a, scope)); + keywords.forEach(([, v]) => evalExpr(v, scope)); + return OPAQUE; + } + + /** Walk a macro / `{% call %}` body: its parameters are locals in a frame of their own. */ + function walkCallable(node: NjNode, scope: Scope): void { + const inner = pushFrame(scope); + for (const local of ["caller", "varargs", "kwargs"]) bind(inner, local, OPAQUE); + for (const arg of childNodes(node["args"] as NjNode)) { + if (arg.typename === "Symbol") bind(inner, arg["value"] as string, OPAQUE); + else if (arg.typename === "KeywordArgs") { + for (const pair of childNodes(arg)) { + evalExpr(pair["value"] as NjNode, scope); // a default is evaluated where it is declared + bind(inner, String((pair["key"] as NjNode)["value"]), OPAQUE); + } + } + } + walk(node["body"] as NjNode, inner); + } + + /** Resolve `{% include / import / extends %}`'s template and walk it in `scope`. */ + function followTemplate( + keyword: string, + templateNode: NjNode, + scope: Scope, + into: (scope: Scope, ref: string) => Scope, + ignoreMissing = false, + ): void { + if (templateNode.typename !== "Literal" || typeof templateNode["value"] !== "string") { + evalExpr(templateNode, scope); + report(ERR_TEMPLATE_NOT_STATIC, keyword); + return; + } + if (provider === undefined) return; // can't resolve without a provider + const ref = refOfTemplateName(templateNode["value"]); + if (scope.seen.includes(ref) || scope.seen.length >= MAX_DEPTH) return; // cycle/depth guard + const text = provider.resolve(ref); + if (text === undefined) { + if (!ignoreMissing) report(ERR_PARTIAL_UNRESOLVED, ref); + return; + } + if (partialEngineMismatch(provider, ref, TEMPLATE_ENGINE_JINJA)) { + report(ERR_TEMPLATE_ENGINE_UNSUPPORTED, ref); + return; + } + staticTexts.push(text); + let ast: NjNode; + try { + ast = parseJinja(text); + } catch (e) { + report(ERR_TEMPLATE_SYNTAX, `${ref}: ${(e as Error).message}`); + return; + } + walk(ast, into(scope, ref)); + } + + /** Bind a `for` / `set` target: a name, or a tuple of names (each opaque). */ + function bindTarget(target: NjNode, value: Value, scope: Scope): void { + if (target.typename === "Symbol") bind(scope, target["value"] as string, value); + else for (const t of childNodes(target)) bindTarget(t, OPAQUE, scope); + } + + function evalExpr(node: NjNode, scope: Scope): Value { + switch (node.typename) { + case "Symbol": + case "LookupVal": + return evalChain(node, scope); + case "Literal": + case "TemplateData": + return OPAQUE; + case "Group": { + const kids = childNodes(node); + if (kids.length === 1) return evalExpr(kids[0]!, scope); + kids.forEach((k) => evalExpr(k, scope)); + return OPAQUE; + } + case "Filter": + case "FilterAsync": + return evalFilter(node, scope); + case "FunCall": + return evalCall(node, scope); + case "Caller": + walkCallable(node, scope); + return OPAQUE; + case "Is": { + // `x is defined` / `x is divisibleby(3)`: the subject is checked, the test is not. + evalExpr(node["left"] as NjNode, scope); + const test = node["right"] as NjNode; + if (test.typename === "FunCall") { + const { positional, keywords } = splitArgs(test["args"] as NjNode); + positional.forEach((a) => evalExpr(a, scope)); + keywords.forEach(([, v]) => evalExpr(v, scope)); + } + return OPAQUE; + } + case "Pair": { + // A dict literal entry: a bare-name key is the key's own spelling, not a lookup. + const key = node["key"] as NjNode; + if (key.typename !== "Symbol") evalExpr(key, scope); + evalExpr(node["value"] as NjNode, scope); + return OPAQUE; + } + case "Capture": + walk(node["body"] as NjNode, scope); + return OPAQUE; + default: + // Operators, literals with children, inline-if, comparisons: check every operand. + for (const child of childNodes(node)) evalExpr(child, scope); + return OPAQUE; + } + } + + function walk(node: NjNode, scope: Scope): void { + switch (node.typename) { + case "Root": + case "NodeList": + for (const child of childNodes(node)) walk(child, scope); + return; + case "Output": + for (const child of childNodes(node)) evalExpr(child, scope); + return; + case "If": + case "IfAsync": { + evalExpr(node["cond"] as NjNode, scope); + // An `if` opens no scope: a `set` inside it is visible after it, as in Jinja. + walk(node["body"] as NjNode, scope); + if (isNode(node["else_"])) walk(node["else_"], scope); + return; + } + case "For": + case "AsyncEach": + case "AsyncAll": { + const iterable = evalExpr(node["arr"] as NjNode, scope); + const inner = pushFrame(scope); + bind(inner, "loop", OPAQUE); + const target = node["name"] as NjNode; + if (target.typename === "Symbol") { + const name = target["value"] as string; + bind(inner, name, elementOf(iterable, name)); + } else { + bindTarget(target, OPAQUE, inner); + } + walk(node["body"] as NjNode, inner); + if (isNode(node["else_"])) walk(node["else_"], scope); + return; + } + case "Set": { + // `targets` is a plain array of names, not a node list. + const targets = (node["targets"] as unknown[]).filter(isNode); + let value: Value = OPAQUE; + if (isNode(node["value"])) value = evalExpr(node["value"], scope); + else if (isNode(node["body"])) evalExpr(node["body"], scope); + for (const t of targets) { + const name = t["value"] as string; + bind(scope, name, value.tag === "typed" && targets.length === 1 ? { ...value, path: name } : OPAQUE); + } + return; + } + case "Macro": + bind(scope, String((node["name"] as NjNode)["value"]), OPAQUE); + walkCallable(node, scope); + return; + case "Include": + followTemplate( + "include", + node["template"] as NjNode, + scope, + (s, ref) => ({ ...pushFrame(s), seen: [...s.seen, ref] }), + node["ignoreMissing"] === true, + ); + return; + case "Import": + case "FromImport": { + const withContext = node["withContext"] === true; + followTemplate("import", node["template"] as NjNode, scope, (s, ref) => ({ + frames: [new Map()], + root: withContext ? fields : [], + seen: [...s.seen, ref], + })); + if (node.typename === "Import") { + bind(scope, String((node["target"] as NjNode)["value"]), OPAQUE); + } else { + for (const n of childNodes(node["names"] as NjNode)) { + const local = n.typename === "Pair" ? (n["value"] as NjNode) : n; + bind(scope, String(local["value"]), OPAQUE); + } + } + return; + } + case "Extends": + followTemplate("extends", node["template"] as NjNode, scope, (s, ref) => ({ + frames: [new Map()], + root: fields, + seen: [...s.seen, ref], + })); + return; + case "Block": + walk(node["body"] as NjNode, scope); + return; + case "Switch": { + evalExpr(node["expr"] as NjNode, scope); + for (const c of childNodes(node["cases"] as NjNode)) { + evalExpr(c["cond"] as NjNode, scope); + walk(c["body"] as NjNode, scope); + } + if (isNode(node["default"])) walk(node["default"], scope); + return; + } + default: + // Anything else is an expression in statement position, or a node with + // expression children: check them rather than skip them. + evalExpr(node, scope); + } + } + + let ast: NjNode; + try { + ast = parseJinja(templateText); + } catch (e) { + return [{ code: ERR_TEMPLATE_SYNTAX, path: (e as Error).message }]; + } + walk(ast, { frames: [new Map()], root: fields, seen: [] }); + errors.push(...slotAndTagFindings(referencedAtRoot, staticTexts, opts)); + return errors; +} + +/** The static spelling of a name or member chain (`p.title`, `posts[0]`, `user[*]`), or undefined. */ +function spellingOf(node: NjNode): string | undefined { + if (node.typename === "Symbol") return node["value"] as string; + if (node.typename !== "LookupVal") return undefined; + const target = spellingOf(node["target"] as NjNode); + if (target === undefined) return undefined; + const val = node["val"] as NjNode; + if (val.typename === "Literal" && typeof val["value"] === "string") return `${target}.${val["value"]}`; + if (val.typename === "Literal" && typeof val["value"] === "number") return `${target}[${val["value"]}]`; + return `${target}[*]`; +} + +/** + * The 1-based line `path` is first referenced on in Jinja `text`, for a diagnostic's + * location: the first expression spelled exactly `path`, else the first use of its head + * name. Undefined when the text does not parse or never names it here (e.g. the + * reference is in an included template). + */ +export function jinjaReferenceLine(text: string, path: string): number | undefined { + const head = path.split(/[.[|]/)[0] ?? path; + let ast: NjNode; + try { + ast = parseJinja(text); + } catch { + return undefined; + } + let exact: number | undefined; + let byHead: number | undefined; + const visit = (node: NjNode): void => { + if (exact !== undefined) return; + const spelled = spellingOf(node); + if (spelled !== undefined && node.lineno !== undefined) { + if (spelled === path) { + exact = node.lineno + 1; + return; + } + if (byHead === undefined && node.typename === "Symbol" && spelled === head) byHead = node.lineno + 1; + } + for (const child of childNodes(node)) visit(child); + }; + visit(ast); + return exact ?? byHead; +} diff --git a/server/typescript/packages/render/src/provider.ts b/server/typescript/packages/render/src/provider.ts index 4d0fef79ba..39fb0b0abf 100644 --- a/server/typescript/packages/render/src/provider.ts +++ b/server/typescript/packages/render/src/provider.ts @@ -2,15 +2,40 @@ // text. The render engine never does I/O itself — it delegates resolution to // the injected provider, so a fixed provider makes render() deterministic. +import type { TemplateEngine } from "./engine.js"; + export interface Provider { /** Resolve a `group/source` reference to its text, or undefined if absent. */ resolve(ref: string): string | undefined; + /** + * The engine `ref`'s text is written for, when the provider knows it — a + * filesystem provider knows from the file extension it found. Optional: + * a provider without it serves Mustache unless the caller passes `engine`. + */ + engineOf?(ref: string): TemplateEngine | undefined; +} + +export interface InMemoryProviderOptions { + /** The engine every body in this provider is written for. Default: unset (Mustache). */ + engine?: TemplateEngine; } /** Deterministic, in-memory provider (the conformance + test provider). */ export class InMemoryProvider implements Provider { - constructor(private readonly map: Record) {} + private readonly engine: TemplateEngine | undefined; + + constructor( + private readonly map: Record, + options: InMemoryProviderOptions = {}, + ) { + this.engine = options.engine; + } + resolve(ref: string): string | undefined { return Object.prototype.hasOwnProperty.call(this.map, ref) ? this.map[ref] : undefined; } + + engineOf(ref: string): TemplateEngine | undefined { + return this.resolve(ref) === undefined ? undefined : this.engine; + } } diff --git a/server/typescript/packages/render/src/providers.ts b/server/typescript/packages/render/src/providers.ts index 5690ea747d..f3a1b407b9 100644 --- a/server/typescript/packages/render/src/providers.ts +++ b/server/typescript/packages/render/src/providers.ts @@ -3,4 +3,4 @@ // The root entry (`@metaobjectsdev/render`) is browser-safe and must stay that // way; anything that touches the filesystem lives behind this subpath. export { FilesystemProvider, DEFAULT_TEMPLATE_EXTENSION } from "./filesystem-provider.js"; -export { type Provider, InMemoryProvider } from "./provider.js"; +export { type Provider, type InMemoryProviderOptions, InMemoryProvider } from "./provider.js"; diff --git a/server/typescript/packages/render/src/render.ts b/server/typescript/packages/render/src/render.ts index 3290c0e02d..f6ada2390b 100644 --- a/server/typescript/packages/render/src/render.ts +++ b/server/typescript/packages/render/src/render.ts @@ -3,8 +3,17 @@ import type { Provider } from "./provider.js"; import { ESCAPERS, type RenderFormat } from "./escapers.js"; import { verify, ERR_REQUIRED_SLOT_UNUSED, type PayloadField } from "./verify.js"; import { withDerivedAccessors } from "./payload-accessors.js"; +import { + TEMPLATE_ENGINE_JINJA, + TEMPLATE_ENGINE_MUSTACHE, + partialEngineMessage, + partialEngineMismatch, + templateEngineOf, + type TemplateEngine, +} from "./engine.js"; +import { renderJinja } from "./jinja/render.js"; +import { MAX_DEPTH } from "./verify-shared.js"; -const MAX_DEPTH = 32; const PARTIAL = /\{\{>\s*([^}\s]+)\s*\}\}/g; // Resolve `{{> group/source }}` partials by inlining their text, recursively, @@ -18,6 +27,9 @@ function expand(text: string, provider: Provider, path: readonly string[]): stri if (path.length >= MAX_DEPTH) throw new Error(`partial depth exceeded ${MAX_DEPTH}: ${ref}`); const t = provider.resolve(ref); if (t === undefined) throw new Error(`unresolved partial: ${ref}`); + if (partialEngineMismatch(provider, ref, TEMPLATE_ENGINE_MUSTACHE)) { + throw new Error(partialEngineMessage(ref, TEMPLATE_ENGINE_MUSTACHE)); + } return expand(t, provider, [...path, ref]); }); } @@ -44,15 +56,22 @@ export interface RenderOptions { * `maxChars` throws. (Token budgets are out of scope — model-specific tokenizer.) */ maxChars?: number; + /** + * The engine the body is written for (ADR-0061). Default: what the provider + * reports for `ref` (a `.jinja` / `.j2` file is Jinja), else Mustache. Inline + * `template` text has no file, so it is Mustache unless this says otherwise. + */ + engine?: TemplateEngine; } -/** Deterministic, logic-less render: (template + payload + provider) → string. */ +/** Deterministic render: (template + payload + provider) → string. Mustache unless the body is Jinja. */ export function render(o: RenderOptions): string { const body = o.template ?? (o.ref !== undefined ? o.provider.resolve(o.ref) : undefined); if (body === undefined) throw new Error(`unresolved ref: ${o.ref ?? "(none)"}`); + const engine = templateEngineOf(o.provider, o.template === undefined ? o.ref : undefined, o.engine); if (o.verify !== undefined) { - const drift = verify(body, o.verify, { provider: o.provider }).filter( + const drift = verify(body, o.verify, { provider: o.provider, engine }).filter( (e) => e.code !== ERR_REQUIRED_SLOT_UNUSED, ); if (drift.length > 0) { @@ -62,8 +81,20 @@ export function render(o: RenderOptions): string { } } - const expanded = expand(body, o.provider, o.ref !== undefined ? [o.ref] : []); const escaper = ESCAPERS[o.format ?? "text"]; + const result = + engine === TEMPLATE_ENGINE_JINJA + ? renderJinja(body, o.template === undefined ? o.ref : undefined, o.payload, o.provider, escaper) + : renderMustache(body, o, escaper); + + if (o.maxChars !== undefined && result.length > o.maxChars) { + throw new Error(`render exceeded maxChars budget: ${result.length} > ${o.maxChars}`); + } + return result; +} + +function renderMustache(body: string, o: RenderOptions, escaper: (s: string) => string): string { + const expanded = expand(body, o.provider, o.ref !== undefined ? [o.ref] : []); const prev = Mustache.escape; Mustache.escape = (v: unknown) => escaper(typeof v === "string" ? v : String(v)); @@ -76,9 +107,5 @@ export function render(o: RenderOptions): string { } finally { Mustache.escape = prev; } - - if (o.maxChars !== undefined && result.length > o.maxChars) { - throw new Error(`render exceeded maxChars budget: ${result.length} > ${o.maxChars}`); - } return result; } diff --git a/server/typescript/packages/render/src/verify-shared.ts b/server/typescript/packages/render/src/verify-shared.ts new file mode 100644 index 0000000000..097331f2d4 --- /dev/null +++ b/server/typescript/packages/render/src/verify-shared.ts @@ -0,0 +1,117 @@ +// What both template engines' drift checks share: the finding codes, the payload +// field-tree shape, the options, and the two post-walk checks (`@requiredSlots`, +// `@requiredTags`) that read only what the walk collected. Kept apart from +// verify.ts so the Mustache walk and the Jinja walk (jinja/verify.ts) import one +// definition without importing each other. + +import type { Provider } from "./provider.js"; +import type { TemplateEngine } from "./engine.js"; + +/** A template variable references a field the (contextual) payload does not declare. */ +export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD"; +/** A partial / `{% include %}` / `{% import %}` / `{% extends %}` ref does not resolve in the provider. */ +export const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED"; +/** A declared @requiredSlots slot is never referenced by the template (warning). */ +export const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED"; +/** A declared @requiredTags output tag is absent from the template text. */ +export const ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING"; +/** + * Jinja: a payload reference the check cannot resolve without running the template — + * a dynamic key on an object (`user[key]`), a computed `attribute=` argument, or a + * template name that is an expression (`{% include name %}`). Reported, never passed. + */ +export const ERR_TEMPLATE_NOT_STATIC = "ERR_TEMPLATE_NOT_STATIC"; +/** Jinja: the template text does not parse. path = the parser's message. */ +export const ERR_TEMPLATE_SYNTAX = "ERR_TEMPLATE_SYNTAX"; +/** + * A body is written for an engine this port does not run (Java, Kotlin and C# run + * Mustache only, so a `.jinja` template there is refused with this code). + */ +export const ERR_TEMPLATE_ENGINE_UNSUPPORTED = "ERR_TEMPLATE_ENGINE_UNSUPPORTED"; + +/** + * A plain field-tree node mirroring an `object.value` view-object's field walk. + * `fields` present → a context-pushing field (object / array-of-object); absent + * → a scalar (string/number/boolean/scalar-array). `isArray` is the field's + * effective array-ness: with `fields` it is a list of objects, without it a list + * of scalars. + */ +export interface PayloadField { + name: string; + fields?: PayloadField[]; + isArray?: boolean; +} + +export interface VerifyError { + code: string; + /** The offending variable path, partial ref, or slot name. */ + path: string; +} + +export interface VerifyOptions { + /** When given, partials / includes are resolved + their bodies recursed. */ + provider?: Provider; + /** Slots that MUST be referenced; an unused one is reported as a warning. */ + requiredSlots?: string[]; + /** + * Output tags the rendered text is contracted to contain. Each must appear as + * both an opening form (`` or whitespace, so attributes are + * allowed) and a closing `` — across the body AND resolved partials. + */ + requiredTags?: string[]; + /** The engine the text is written for. Default: Mustache. */ + engine?: TemplateEngine; +} + +/** How deep partial / include resolution recurses before it stops. */ +export const MAX_DEPTH = 32; + +// An opening tag is `` or XML whitespace, so +// attributes are allowed (``) but a longer name is not over-matched +// (`` does not satisfy `answer`). +const TAG_OPEN_DELIMS = new Set([">", " ", "\t", "\n", "\r"]); + +function hasOpenTag(text: string, tag: string): boolean { + const needle = `<${tag}`; + for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + 1)) { + const next = text[i + needle.length]; + if (next !== undefined && TAG_OPEN_DELIMS.has(next)) return true; + } + return false; +} + +// A closing tag is the exact literal ``. A self-closing `` has no such +// form, so it never satisfies a required tag — these wrap content a parser reads. +function hasCloseTag(text: string, tag: string): boolean { + return text.includes(``); +} + +/** + * The checks that run after a walk: every `requiredSlots` name must have been + * referenced at the payload root, and every `requiredTags` tag must appear (open + * and close) somewhere in the body plus its resolved partials (`staticTexts`). + */ +export function slotAndTagFindings( + referencedAtRoot: ReadonlySet, + staticTexts: readonly string[], + opts: VerifyOptions | undefined, +): VerifyError[] { + const errors: VerifyError[] = []; + for (const slot of opts?.requiredSlots ?? []) { + if (!referencedAtRoot.has(slot)) errors.push({ code: ERR_REQUIRED_SLOT_UNUSED, path: slot }); + } + const requiredTags = opts?.requiredTags ?? []; + if (requiredTags.length > 0) { + // Scan body + resolved partials as one joined string: the open and close + // forms are located independently, so a tag may legitimately straddle the + // boundary. The "\n" separator only blocks a spurious tag spliced together + // from two fragments (a real tag is always contiguous within one body). + const haystack = staticTexts.join("\n"); + for (const tag of requiredTags) { + if (!hasOpenTag(haystack, tag) || !hasCloseTag(haystack, tag)) { + errors.push({ code: ERR_OUTPUT_TAG_MISSING, path: tag }); + } + } + } + return errors; +} diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts index ffebdf9075..08e64d1896 100644 --- a/server/typescript/packages/render/src/verify.ts +++ b/server/typescript/packages/render/src/verify.ts @@ -10,51 +10,32 @@ // passes it in — keeping this engine a standalone, byte-portable module. import Mustache from "mustache"; -import type { Provider } from "./provider.js"; import { HAS_PREFIX, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js"; - -/** A `{{var}}` references a field the (contextual) payload does not declare. */ -export const ERR_VAR_NOT_ON_PAYLOAD = "ERR_VAR_NOT_ON_PAYLOAD"; -/** A `{{> ref}}` partial does not resolve in the provider. */ -export const ERR_PARTIAL_UNRESOLVED = "ERR_PARTIAL_UNRESOLVED"; -/** A declared @requiredSlots slot is never referenced by the template (warning). */ -export const ERR_REQUIRED_SLOT_UNUSED = "ERR_REQUIRED_SLOT_UNUSED"; -/** A declared @requiredTags output tag is absent from the template text. */ -export const ERR_OUTPUT_TAG_MISSING = "ERR_OUTPUT_TAG_MISSING"; - -/** - * A plain field-tree node mirroring an `object.value` view-object's field walk. - * `fields` present → a context-pushing field (object / array-of-object); absent - * → a scalar (string/number/boolean/scalar-array). `isArray` is the field's - * effective array-ness: with `fields` it is a list of objects, without it a list - * of scalars. - */ -export interface PayloadField { - name: string; - fields?: PayloadField[]; - isArray?: boolean; -} - -export interface VerifyError { - code: string; - /** The offending variable path, partial ref, or slot name. */ - path: string; -} - -export interface VerifyOptions { - /** When given, `{{> ref}}` partials are resolved + their bodies recursed. */ - provider?: Provider; - /** Slots that MUST be referenced; an unused one is reported as a warning. */ - requiredSlots?: string[]; - /** - * Output tags the rendered text is contracted to contain. Each must appear as - * both an opening form (`` or whitespace, so attributes are - * allowed) and a closing `` — across the body AND resolved partials. - */ - requiredTags?: string[]; -} - -const MAX_DEPTH = 32; +import { TEMPLATE_ENGINE_JINJA, TEMPLATE_ENGINE_MUSTACHE, partialEngineMismatch } from "./engine.js"; +import { verifyJinja } from "./jinja/verify.js"; +import { + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + MAX_DEPTH, + slotAndTagFindings, + type PayloadField, + type VerifyError, + type VerifyOptions, +} from "./verify-shared.js"; + +export { + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_REQUIRED_SLOT_UNUSED, + ERR_OUTPUT_TAG_MISSING, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, + type PayloadField, + type VerifyError, + type VerifyOptions, +} from "./verify-shared.js"; // A Mustache parse token: [type, value, start, end, subTokens?, ...]. type Token = readonly unknown[]; @@ -147,36 +128,20 @@ export function parseTemplate(text: string): readonly (readonly unknown[])[] { return parse(text); } -// An opening tag is `` or XML whitespace, so -// attributes are allowed (``) but a longer name is not over-matched -// (`` does not satisfy `answer`). -const TAG_OPEN_DELIMS = new Set([">", " ", "\t", "\n", "\r"]); - -function hasOpenTag(text: string, tag: string): boolean { - const needle = `<${tag}`; - for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + 1)) { - const next = text[i + needle.length]; - if (next !== undefined && TAG_OPEN_DELIMS.has(next)) return true; - } - return false; -} - -// A closing tag is the exact literal ``. A self-closing `` has no such -// form, so it never satisfies a required tag — these wrap content a parser reads. -function hasCloseTag(text: string, tag: string): boolean { - return text.includes(``); -} - /** - * Walk a Mustache template's tokens against a payload field tree, returning a - * list of drift errors. Context-sensitive: a section `{{#posts}}…{{/posts}}` - * over a container field checks its body against that field's element type. + * Check a template's references against a payload field tree, returning a list of + * drift errors. Mustache by default; `opts.engine: "jinja"` walks the Jinja AST + * instead (jinja/verify.ts) and reports the same codes. + * + * Mustache is context-sensitive: a section `{{#posts}}…{{/posts}}` over a + * container field checks its body against that field's element type. */ export function verify( templateText: string, fields: PayloadField[], opts?: VerifyOptions, ): VerifyError[] { + if (opts?.engine === TEMPLATE_ENGINE_JINJA) return verifyJinja(templateText, fields, opts); const errors: VerifyError[] = []; const provider = opts?.provider; const root = fields; @@ -251,6 +216,10 @@ export function verify( errors.push({ code: ERR_PARTIAL_UNRESOLVED, path: value }); break; } + if (partialEngineMismatch(provider, value, TEMPLATE_ENGINE_MUSTACHE)) { + errors.push({ code: ERR_TEMPLATE_ENGINE_UNSUPPORTED, path: value }); + break; + } staticTexts.push(text); walk(parse(text), stack, [...seen, value], positional); break; @@ -263,23 +232,6 @@ export function verify( walk(parse(templateText), [root], []); - for (const slot of opts?.requiredSlots ?? []) { - if (!referencedAtRoot.has(slot)) errors.push({ code: ERR_REQUIRED_SLOT_UNUSED, path: slot }); - } - - const requiredTags = opts?.requiredTags ?? []; - if (requiredTags.length > 0) { - // Scan body + resolved partials as one joined string: the open and close - // forms are located independently, so a tag may legitimately straddle the - // boundary. The "\n" separator only blocks a spurious tag spliced together - // from two fragments (a real tag is always contiguous within one body). - const haystack = staticTexts.join("\n"); - for (const tag of requiredTags) { - if (!hasOpenTag(haystack, tag) || !hasCloseTag(haystack, tag)) { - errors.push({ code: ERR_OUTPUT_TAG_MISSING, path: tag }); - } - } - } - + errors.push(...slotAndTagFindings(referencedAtRoot, staticTexts, opts)); return errors; } diff --git a/server/typescript/packages/render/test/jinja-conformance.test.ts b/server/typescript/packages/render/test/jinja-conformance.test.ts new file mode 100644 index 0000000000..5c38bd7ed7 --- /dev/null +++ b/server/typescript/packages/render/test/jinja-conformance.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect } from "bun:test"; +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { + render, + verify, + InMemoryProvider, + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_REQUIRED_SLOT_UNUSED, + ERR_OUTPUT_TAG_MISSING, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + type PayloadField, + type RenderFormat, + type VerifyError, + type VerifyOptions, +} from "../src/index.js"; + +// Jinja conformance corpora (ADR-0061) — the TypeScript half of the Jinja arm, shared +// with the Python port and run by no other port (Java, Kotlin and C# run Mustache only). +// +// fixtures/jinja-render-conformance// meta.json { format, note }, template.jinja, +// partials/.jinja (as `partials/`), payload.json, and expected.txt +// (byte-identical) or expected-error.txt (the exact error message). +// fixtures/jinja-verify-conformance// payload.json (the FIELD TREE), template.jinja, +// partials/, options.json { requiredSlots?, requiredTags?, provider? }, and +// expected-drift.json (a sorted multiset). An ERR_TEMPLATE_SYNTAX path is the engine's +// own parser message, so it is compared as "". +// +// Mirrors server/python/tests/render/test_jinja_conformance.py. +const FIXTURES = join(import.meta.dir, "../../../../../fixtures"); +const RENDER = join(FIXTURES, "jinja-render-conformance"); +const VERIFY = join(FIXTURES, "jinja-verify-conformance"); + +const VERIFY_CODES = new Set([ + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + ERR_REQUIRED_SLOT_UNUSED, + ERR_OUTPUT_TAG_MISSING, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, +]); + +const casesOf = (root: string): string[] => + readdirSync(root).filter((n) => statSync(join(root, n)).isDirectory()).sort(); + +function providerOf(dir: string): InMemoryProvider { + const map: Record = {}; + const pdir = join(dir, "partials"); + if (existsSync(pdir)) { + for (const f of readdirSync(pdir)) { + if (f.endsWith(".jinja")) map[`partials/${f.replace(/\.jinja$/, "")}`] = readFileSync(join(pdir, f), "utf8"); + } + } + return new InMemoryProvider(map, { engine: "jinja" }); +} + +const read = (dir: string, file: string): string => readFileSync(join(dir, file), "utf8"); + +describe("jinja-render-conformance corpus", () => { + const names = casesOf(RENDER); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + test(name, () => { + const dir = join(RENDER, name); + const meta = JSON.parse(read(dir, "meta.json")) as { format?: RenderFormat }; + const run = (): string => + render({ + template: read(dir, "template.jinja"), + payload: JSON.parse(read(dir, "payload.json")), + provider: providerOf(dir), + format: meta.format ?? "text", + engine: "jinja", + }); + if (existsSync(join(dir, "expected-error.txt"))) { + expect(run).toThrow(read(dir, "expected-error.txt").replace(/\n$/, "")); + return; + } + const actual = run(); + expect(actual).toBe(read(dir, "expected.txt")); + expect(run()).toBe(actual); // determinism + }); + } +}); + +const normalized = (xs: readonly VerifyError[]): VerifyError[] => + xs + .map((e) => ({ code: e.code, path: e.code === ERR_TEMPLATE_SYNTAX ? "" : e.path })) + .sort((a, b) => (a.code !== b.code ? a.code.localeCompare(b.code) : a.path.localeCompare(b.path))); + +describe("jinja-verify-conformance corpus", () => { + const names = casesOf(VERIFY); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + test(name, () => { + const dir = join(VERIFY, name); + const fields = JSON.parse(read(dir, "payload.json")) as PayloadField[]; + const opts = existsSync(join(dir, "options.json")) + ? (JSON.parse(read(dir, "options.json")) as { + requiredSlots?: string[]; + requiredTags?: string[]; + provider?: "with" | "without"; + }) + : {}; + const expected = JSON.parse(read(dir, "expected-drift.json")) as VerifyError[]; + for (const e of expected) expect(VERIFY_CODES.has(e.code)).toBe(true); + const mode = opts.provider ?? (existsSync(join(dir, "partials")) ? "with" : "without"); + const verifyOpts: VerifyOptions = { + engine: "jinja", + ...(mode === "with" ? { provider: providerOf(dir) } : {}), + ...(opts.requiredSlots ? { requiredSlots: opts.requiredSlots } : {}), + ...(opts.requiredTags ? { requiredTags: opts.requiredTags } : {}), + }; + const actual = verify(read(dir, "template.jinja"), fields, verifyOpts); + expect(normalized(actual)).toEqual(normalized(expected)); + }); + } +}); diff --git a/server/typescript/packages/render/test/jinja-engine.test.ts b/server/typescript/packages/render/test/jinja-engine.test.ts new file mode 100644 index 0000000000..4ed462014b --- /dev/null +++ b/server/typescript/packages/render/test/jinja-engine.test.ts @@ -0,0 +1,151 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import nunjucks from "nunjucks"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + render, + verify, + InMemoryProvider, + templateEngineOf, + engineForExtension, + jinjaReferenceLine, + ERR_VAR_NOT_ON_PAYLOAD, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, +} from "../src/index.js"; +import { FilesystemProvider } from "../src/providers.js"; + +// ADR-0061: how a body's engine is chosen, and the Jinja render path's runtime behaviour. +// What a Jinja template renders to and what the check reports are pinned by the shared +// corpora (jinja-conformance.test.ts); this file covers the selection rules around them. + +let root: string; +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), "mo-jinja-")); + mkdirSync(join(root, "p"), { recursive: true }); + writeFileSync(join(root, "p", "both.mustache"), "M {{name}}"); + writeFileSync(join(root, "p", "both.jinja"), "J {{ name }}"); + writeFileSync(join(root, "p", "only.j2"), "{% for t in tags %}{{ t }}{% if not loop.last %},{% endif %}{% endfor %}"); + writeFileSync(join(root, "p", "plain.mustache"), "{{name}}"); + writeFileSync(join(root, "p", "inc.jinja"), '{% include "p/part" %}!'); + writeFileSync(join(root, "p", "part.jinja2"), "<{{ name }}>"); + writeFileSync(join(root, "p", "mix-m.mustache"), "{{> p/only}}"); + writeFileSync(join(root, "p", "mix-j.jinja"), '{% include "p/plain" %}'); +}); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +describe("engine selection", () => { + test("an extension selects the engine; anything else is Mustache", () => { + expect(engineForExtension(".jinja")).toBe("jinja"); + expect(engineForExtension(".J2")).toBe("jinja"); + expect(engineForExtension(".jinja2")).toBe("jinja"); + expect(engineForExtension(".mustache")).toBe("mustache"); + expect(engineForExtension(".txt")).toBe("mustache"); + }); + + test("FilesystemProvider prefers Mustache, falls back to Jinja, and reports which it read", () => { + const fs = new FilesystemProvider(root); + expect(fs.resolve("p/both")).toBe("M {{name}}"); + expect(fs.engineOf("p/both")).toBe("mustache"); + expect(fs.engineOf("p/only")).toBe("jinja"); + expect(fs.engineOf("p/missing")).toBeUndefined(); + }); + + test("an explicit extension reads that extension only", () => { + const fs = new FilesystemProvider(root, ".jinja"); + expect(fs.resolve("p/both")).toBe("J {{ name }}"); + expect(fs.resolve("p/plain")).toBeUndefined(); + }); + + test("an explicit .mustache does not fall back to a Jinja file", () => { + const fs = new FilesystemProvider(root, ".mustache"); + expect(fs.resolve("p/only")).toBeUndefined(); + expect(fs.engineOf("p/only")).toBeUndefined(); + expect(fs.resolve("p/both")).toBe("M {{name}}"); + }); + + test("an explicit engine wins over the provider; no provider knowledge means Mustache", () => { + const fs = new FilesystemProvider(root); + expect(templateEngineOf(fs, "p/only", "mustache")).toBe("mustache"); + expect(templateEngineOf(new InMemoryProvider({ a: "x" }), "a")).toBe("mustache"); + expect(templateEngineOf(new InMemoryProvider({ a: "x" }, { engine: "jinja" }), "a")).toBe("jinja"); + }); +}); + +describe("render", () => { + const fs = (): FilesystemProvider => new FilesystemProvider(root); + + test("a ref that resolves to a Jinja file renders with Jinja, includes and all", () => { + expect(render({ ref: "p/only", payload: { tags: ["a", "b"] }, provider: fs() })).toBe("a,b"); + expect(render({ ref: "p/inc", payload: { name: "x&y" }, provider: fs(), format: "html" })).toBe("!"); + }); + + test("inline template text is Mustache unless the caller names the engine", () => { + const provider = new InMemoryProvider({}); + expect(render({ template: "{{ name }}", payload: { name: "a" }, provider })).toBe("a"); + expect(render({ template: "{{ name | upper }}", payload: { name: "a" }, provider, engine: "jinja" })).toBe("A"); + }); + + test("the runtime check refuses a Jinja body that drifted from its payload", () => { + expect(() => + render({ ref: "p/only", payload: { tags: [] }, provider: fs(), verify: [{ name: "labels", isArray: true }] }), + ).toThrow(`render verify failed: ${ERR_VAR_NOT_ON_PAYLOAD} (tags)`); + expect(render({ ref: "p/only", payload: { tags: ["a"] }, provider: fs(), verify: [{ name: "tags", isArray: true }] })).toBe("a"); + }); + + test("maxChars applies to a Jinja render", () => { + expect(() => + render({ template: "{{ name }}", payload: { name: "abcdef" }, provider: new InMemoryProvider({}), engine: "jinja", maxChars: 3 }), + ).toThrow("render exceeded maxChars budget: 6 > 3"); + }); + + test("a render leaves the engine's own escape function as it found it", () => { + const provider = new InMemoryProvider({}); + render({ template: "{{ a }}", payload: { a: "<" }, provider, engine: "jinja", format: "text" }); + expect(new nunjucks.Environment(null, { autoescape: true }).renderString("{{ a }}", { a: "<" })).toBe("<"); + }); +}); + +describe("an include chain stays in one engine", () => { + const fields = [{ name: "name" }, { name: "tags", isArray: true }]; + + test("a Mustache partial whose file is Jinja is refused, in render and in verify", () => { + const provider = new FilesystemProvider(root); + expect(() => render({ ref: "p/mix-m", payload: { tags: [] }, provider })).toThrow(ERR_TEMPLATE_ENGINE_UNSUPPORTED); + expect(verify("{{> p/only}}", fields, { provider })).toEqual([ + { code: ERR_TEMPLATE_ENGINE_UNSUPPORTED, path: "p/only" }, + ]); + }); + + test("a Jinja include whose file is Mustache is refused, in render and in verify", () => { + const provider = new FilesystemProvider(root); + expect(() => render({ ref: "p/mix-j", payload: { name: "x" }, provider })).toThrow(ERR_TEMPLATE_ENGINE_UNSUPPORTED); + expect(verify('{% include "p/plain" %}', fields, { provider, engine: "jinja" })).toEqual([ + { code: ERR_TEMPLATE_ENGINE_UNSUPPORTED, path: "p/plain" }, + ]); + }); +}); + +describe("explicit engine", () => { + test("an unknown engine is refused, not read as Mustache", () => { + expect(() => + render({ template: "{{ a }}", payload: {}, provider: new InMemoryProvider({}), engine: "Jinja" as never }), + ).toThrow('unknown template engine "Jinja"'); + }); +}); + +describe("verify", () => { + test("engine: jinja checks Jinja syntax; the default stays Mustache", () => { + const fields = [{ name: "user", fields: [{ name: "name" }] }]; + expect(verify("{{ user.nope }}", fields, { engine: "jinja" })).toEqual([ + { code: ERR_VAR_NOT_ON_PAYLOAD, path: "user.nope" }, + ]); + expect(verify("{{user.name}}", fields)).toEqual([]); + }); + + test("jinjaReferenceLine finds the line a name is first used on", () => { + expect(jinjaReferenceLine("a\n{% if x %}\n{{ user.nope }}\n{% endif %}", "user.nope")).toBe(3); + expect(jinjaReferenceLine("{{ a }}", "b")).toBeUndefined(); + expect(jinjaReferenceLine("{{ broken", "a")).toBeUndefined(); + }); +}); diff --git a/spec/conformance-tests.md b/spec/conformance-tests.md index fb9d62383d..29106b7b07 100644 --- a/spec/conformance-tests.md +++ b/spec/conformance-tests.md @@ -280,6 +280,9 @@ Each fixture is a directory under `fixtures/verify-conformance/`: `partials/` directory — an empty provider resolves nothing, so a `{{> ...}}` reference becomes `ERR_PARTIAL_UNRESOLVED`. +- **Jinja bodies.** The `verify` corpus above is Mustache. The Jinja bodies (ADR-0061) + have their own corpus, `fixtures/jinja-verify-conformance/`, which uses the same + `payload.json` and `expected-drift.json` shape with a `template.jinja` entry. - **`expected-drift.json`** is a JSON array of `{ "code": "ERR_*", "path": "..." }`, where `code` is one of the three verify codes in [`ERROR-CODES.json`](../fixtures/conformance/ERROR-CODES.json) diff --git a/spec/decisions/ADR-0061-per-port-template-engines.md b/spec/decisions/ADR-0061-per-port-template-engines.md new file mode 100644 index 0000000000..031bfaf518 --- /dev/null +++ b/spec/decisions/ADR-0061-per-port-template-engines.md @@ -0,0 +1,123 @@ +# ADR-0061: A template body may use a per-port engine, chosen by its file + +## Status + +**Accepted** (2026-10-28). Registers no vocabulary: `metamodelVersion` stays `1.2`. It adds a +second template engine, Jinja, to the Python and TypeScript render tiers, plus the payload check +that goes with it. Mustache stays the default and the only engine in Java, Kotlin and C#. +Closes #483. + +## Context + +Every port renders `template.prompt` and `template.output` bodies with Mustache, and the +template gate (`verify`, plus the render-helper generator at codegen time) checks every +variable a body uses against the template's declared `@payloadRef` value object +(`ERR_VAR_NOT_ON_PAYLOAD`). Mustache is logic-less on purpose. That is why the check can be +exact, and it is also why the render corpus is byte-identical across five ports. + +Teams with existing Jinja prompt templates, which are common in Python LLM stacks, could not +adopt the gate without first moving their template logic (filters, conditionals, `set`, macros, +includes) into the payload. Evaluators named this the largest migration cost for an AI-product +codebase (#483). The issue offered two options: Jinja as a second engine with the same +build-time check, at least in Python and TypeScript, or a Jinja-to-Mustache migration analyzer. +The ruling: "We should do some language specific options, I think we have that already. So +language specific templating is fine." + +## Decision + +1. **A second engine, Jinja, in Python and TypeScript.** Python renders with **jinja2**. + TypeScript renders with **nunjucks**. Both are held to two shared corpora: + `fixtures/jinja-render-conformance/` (rendered output, byte-identical between the two ports) + and `fixtures/jinja-verify-conformance/` (the drift findings). + +2. **The engine is chosen by the template FILE, never by the metadata.** A body is Jinja when + its file has a `.jinja`, `.j2` or `.jinja2` extension, or when the caller passes an explicit + `engine` (`render({ engine: "jinja" })`, `verify(text, fields, { engine: "jinja" })`, and + the Python keyword equivalents). `@textRef` stays a logical `group/source` reference and the + metamodel carries no engine attribute, so the portable vocabulary holds nothing that one + port understands and another does not. A filesystem provider looks for `.mustache` + first, then the Jinja extensions in the order above; a provider given one explicit extension + reads only that extension. A provider that cannot tell (an in-memory map) reports nothing, + and the body is Mustache unless the caller names the engine. An include chain stays in one + engine: a Mustache `{{> ref}}` whose file is Jinja, or a Jinja `include` / `import` / + `extends` whose file is Mustache, is refused with `ERR_TEMPLATE_ENGINE_UNSUPPORTED` by + `verify` and by `render`. + +3. **The same payload check, at codegen time and at runtime.** The Jinja check walks the + parsed template (the nunjucks AST in TypeScript, the jinja2 AST in Python), never the text: + - a payload path (`user.name`, `user["name"]`) is checked segment by segment; + - a `for` loop checks its iterable and binds the target to the element's fields; + - `set`, macro parameters and loop targets are locals, not payload; + - filters are not checked, but their arguments are, and a filter that names an element + attribute (`map(attribute=…)`, `selectattr`, `join`, `sort`, `groupby`) has that name + checked; + - `include`, `import` and `extends` with a literal name are resolved through the provider + and walked. + + Anything the check cannot settle without running the template (a dynamic subscript, a + computed include name) is reported as **`ERR_TEMPLATE_NOT_STATIC`**. A body that does not + parse is **`ERR_TEMPLATE_SYNTAX`**. Neither ever passes silently. `@requiredSlots` and + `@requiredTags` apply to a Jinja body exactly as to a Mustache one. The render-helper + generator runs this check at codegen time. A helper over a Jinja body passes the engine and + the payload field tree to `render`, so `render` checks the body again when it runs. (Every + TypeScript helper already passed the field tree. In Python only a Jinja helper does: a + Mustache helper there relies on the codegen check alone, as before.) + +4. **Mustache is unchanged.** It stays the default in every port. A project with no Jinja file + gets byte-identical generated helpers, and the Mustache render corpus is untouched. + +5. **Java, Kotlin and C# refuse Jinja by name.** Their filesystem providers (and the JVM + classpath provider) throw a `RenderException` carrying **`ERR_TEMPLATE_ENGINE_UNSUPPORTED`** + when a ref has only a Jinja file, or when the provider is configured with a Jinja extension. + Their `verify` commands report it as a drift line on that template. Without this a Jinja + body would be reported as an unresolved ref, or rendered as Mustache with its `{% %}` tags + printed verbatim. Adding Jinja to these ports is out of scope. + +6. **The dependency is optional in Python.** jinja2 is the `jinja` extra + (`pip install metaobjects[jinja]`) and is imported only when a Jinja body is rendered or + checked. In TypeScript, nunjucks is a dependency of `@metaobjectsdev/render`. + +### Why nunjucks for TypeScript + +nunjucks is BSD-2-Clause and is the long-standing JavaScript implementation of the Jinja +language. It is in maintenance mode, but its grammar is stable, which is what a build-time check +needs. It has `include`, `import` and `extends`, a walkable AST (`parser.parse`), and an escaping +hook, so each `@format`'s escaper (html, json, xml, text) applies to Jinja output exactly as it +does to Mustache output. `@huggingface/jinja` was considered and rejected: it has no `include`, +`import` or `extends`, and no escaping hook. + +### What the two engines do not share + +The corpora pin the shared vocabulary. Outside it, nunjucks and jinja2 differ, and a template +that must render the same in both ports should stay inside the corpus vocabulary: + +- **Truthiness of an empty list.** jinja2 treats `[]` as false; nunjucks treats it as true. Use + `{% if items | length %}`. +- **Printing a list or a dict** gives different text in each engine. Print its members. +- **Python-only syntax.** jinja2 accepts `{% with %}`, `namespace()` assignment, Python string + methods (`name.upper()`) and `True`/`False`/`None`; nunjucks accepts `true`/`false`/`none` + and JavaScript methods. The checker accepts a method call on a payload value and checks the + receiver. +- **Looping over a null.** nunjucks renders nothing; jinja2 fails the render (a + `RenderError`). Guard it with `{% if items %}`. +- **A string filter over escaped text.** What a macro, a `call` block or a block `set` + renders is safe in both engines and is not escaped again. A filter that rebuilds the + string (`upper`, `lower`, `replace`) keeps it safe in jinja2 but not in nunjucks, so a + `{% filter upper %}` block in an html render escapes its values twice in TypeScript. +- **The Mustache-only payload accessors** (the injected `has` and `-first`/`-last` list + flags) are not added for a Jinja body. Jinja has its own forms (`{% if field %}`, `loop.first`, + `loop.last`). + +Printed scalars are normalized in both ports to the Mustache renderer's form: `true`/`false`, +a whole float as an integer, and `None`/`undefined` as empty text. A lookup on a missing key +renders empty however deep it chains (`v.x.y`). + +## Consequences + +- Jinja templates can adopt the gate unchanged. The migration cost in #483 drops to renaming a + file, or to nothing for a file that already ends in `.jinja` or `.j2`. +- The render tier in Python and TypeScript now has two engines, and two corpora gate that + pair. The cross-port Mustache corpus still gates all five ports. +- A model whose templates are Jinja renders in Python and TypeScript only. That is by design: the + engine is a property of the port, and the refusal in the other three ports says so by code. +- The migration analyzer, the other option in #483, is not built. diff --git a/spec/decisions/README.md b/spec/decisions/README.md index 27b2f4547a..02ad241caf 100644 --- a/spec/decisions/README.md +++ b/spec/decisions/README.md @@ -57,6 +57,7 @@ Write one when a decision is **cross-cutting** (affects more than one language p - [ADR-0058 — One physical-naming setting, read by every port](ADR-0058-one-physical-naming-setting.md) — *Accepted* (one `naming` block in `.metaobjects/config.json` replaces the per-port column-naming flags, pom args, config keys and runtime setters) - [ADR-0059 — References resolve once, after the tree](ADR-0059-references-resolve-once-after-the-tree.md) — *Accepted* (sources keep references as written; one resolver per port binds them before freeze and the serializer writes them canonical; supersedes ADR-0032 §3–§4, amends ADR-0042) - [ADR-0060 — Java and Kotlin codegen decide overwrites from the committed hash manifest](ADR-0060-jvm-codegen-hash-manifest.md) — *Accepted* (the JVM plugin records each file it writes in `.metaobjects/.gen-state/.hashes.json`, refuses an edited one, and deletes an untouched orphan, owned per generator and output directory) +- [ADR-0061 — A template body may use a per-port engine, chosen by its file](ADR-0061-per-port-template-engines.md) — *Accepted* (Python and TypeScript render and check Jinja bodies — `.jinja` / `.j2` / `.jinja2` — against the same payload check; the metadata never names an engine; Java, Kotlin and C# refuse Jinja with `ERR_TEMPLATE_ENGINE_UNSUPPORTED`) > **Index gap — ADR-0031 through ADR-0051 are on disk but not listed above.** The index stopped > being maintained after ADR-0030; the files are authoritative, this list is not. Read