From 934502b921e3b387fe785ecd4eeed157451b2349 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 09:34:46 -0400 Subject: [PATCH 1/8] feat(render): Jinja templates in Python and TypeScript, same payload check (#483) A template.prompt or template.output body may now be a Jinja file (.jinja / .j2 / .jinja2) instead of a .mustache one. The engine comes from the file or an explicit `engine` option, never from the metadata, so @textRef and the metamodel stay language-neutral (ADR-0061). - Python renders with jinja2 (optional `jinja` extra); TypeScript with nunjucks (BSD-2; has include/import/extends, a walkable AST and an escaping hook). - verify and the render-helper generator walk the parsed template and check every payload reference: ERR_VAR_NOT_ON_PAYLOAD as before, plus ERR_TEMPLATE_NOT_STATIC for what cannot be settled statically and ERR_TEMPLATE_SYNTAX for a body that does not parse. A generated helper over a Jinja body passes the engine and field tree to render, so the check also runs at runtime. - New shared corpora: fixtures/jinja-render-conformance (12) and fixtures/jinja-verify-conformance (30), identical in both ports. - Java, Kotlin and C# render Mustache only and now refuse a Jinja-only body with ERR_TEMPLATE_ENGINE_UNSUPPORTED, in their providers and in verify. - Mustache stays the default; generated Mustache helpers are byte-identical; metamodelVersion does not move. Docs: ADR-0061, templates-and-payloads, the prompts skill references, port pages, llms.txt / llms-full.txt, CONFORMANCE (33 corpora), CHANGELOG. --- AGENTS.md | 2 +- CHANGELOG.md | 20 + .../skills/metaobjects-prompts/SKILL.md | 18 +- .../metaobjects-prompts/references/csharp.md | 6 + .../metaobjects-prompts/references/java.md | 6 + .../metaobjects-prompts/references/kotlin.md | 6 + .../metaobjects-prompts/references/python.md | 26 +- .../references/typescript.md | 12 + bun.lock | 12 + docs/CONFORMANCE.md | 17 +- docs/features/templates-and-payloads.md | 62 +- docs/llms/llms-full.txt | 4 +- docs/llms/llms.txt | 2 +- docs/ports/csharp.md | 2 +- docs/ports/java.md | 2 +- docs/ports/kotlin.md | 2 +- docs/ports/python.md | 27 +- docs/ports/typescript.md | 22 +- fixtures/conformance/ERROR-CODES.json | 3 + .../jinja-render-conditionals/expected.txt | 5 + .../jinja-render-conditionals/meta.json | 4 + .../jinja-render-conditionals/payload.json | 10 + .../jinja-render-conditionals/template.jinja | 5 + .../expected-error.txt | 1 + .../meta.json | 4 + .../partials/a.jinja | 1 + .../partials/b.jinja | 1 + .../payload.json | 1 + .../template.jinja | 1 + .../expected-error.txt | 1 + .../meta.json | 4 + .../payload.json | 1 + .../template.jinja | 1 + .../jinja-render-escaping-html/expected.txt | 3 + .../jinja-render-escaping-html/meta.json | 4 + .../jinja-render-escaping-html/payload.json | 9 + .../jinja-render-escaping-html/template.jinja | 3 + .../jinja-render-escaping-json/expected.txt | 1 + .../jinja-render-escaping-json/meta.json | 4 + .../jinja-render-escaping-json/payload.json | 5 + .../jinja-render-escaping-json/template.jinja | 1 + .../jinja-render-filters/expected.txt | 4 + .../jinja-render-filters/meta.json | 4 + .../jinja-render-filters/payload.json | 18 + .../jinja-render-filters/template.jinja | 4 + .../jinja-render-include-import/expected.txt | 4 + .../jinja-render-include-import/meta.json | 4 + .../partials/footer.jinja | 1 + .../partials/header.jinja | 1 + .../partials/macros.jinja | 1 + .../jinja-render-include-import/payload.json | 6 + .../template.jinja | 4 + .../jinja-render-loops/expected.txt | 5 + .../jinja-render-loops/meta.json | 4 + .../jinja-render-loops/payload.json | 22 + .../jinja-render-loops/template.jinja | 3 + .../expected.txt | 2 + .../jinja-render-raw-and-comments/meta.json | 4 + .../payload.json | 1 + .../template.jinja | 2 + .../jinja-render-set-and-macros/expected.txt | 3 + .../jinja-render-set-and-macros/meta.json | 4 + .../jinja-render-set-and-macros/payload.json | 6 + .../template.jinja | 3 + .../jinja-render-variables/expected.txt | 4 + .../jinja-render-variables/meta.json | 4 + .../jinja-render-variables/payload.json | 16 + .../jinja-render-variables/template.jinja | 4 + .../expected.txt | 7 + .../jinja-render-whitespace-control/meta.json | 4 + .../payload.json | 6 + .../template.jinja | 8 + .../expected-drift.json | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 5 + .../jinja-verify-conditionals/payload.json | 50 ++ .../jinja-verify-conditionals/template.jinja | 1 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 4 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 4 + .../partials/base.jinja | 1 + .../jinja-verify-extends-blocks/payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../jinja-verify-filter-element/payload.json | 50 ++ .../template.jinja | 1 + .../jinja-verify-filters/expected-drift.json | 7 + .../jinja-verify-filters/payload.json | 50 ++ .../jinja-verify-filters/template.jinja | 3 + .../expected-drift.json | 5 + .../jinja-verify-for-element/payload.json | 50 ++ .../jinja-verify-for-element/template.jinja | 2 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../partials/macros.jinja | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../partials/macros.jinja | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../options.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../partials/footer.jinja | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 4 + .../partials/footer.jinja | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../partials/item.jinja | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../options.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 4 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 5 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 4 + .../options.json | 10 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 3 + .../jinja-verify-set-locals/payload.json | 50 ++ .../jinja-verify-set-locals/template.jinja | 1 + .../expected-drift.json | 3 + .../jinja-verify-syntax-error/payload.json | 50 ++ .../jinja-verify-syntax-error/template.jinja | 1 + .../expected-drift.json | 3 + .../payload.json | 50 ++ .../template.jinja | 1 + .../expected-drift.json | 1 + .../jinja-verify-var-clean/payload.json | 50 ++ .../jinja-verify-var-clean/template.jinja | 1 + .../expected-drift.json | 6 + .../jinja-verify-var-unknown/payload.json | 50 ++ .../jinja-verify-var-unknown/template.jinja | 1 + .../VerifySubverbTests.cs | 15 + .../csharp/MetaObjects.Cli/VerifyCommand.cs | 12 +- .../TemplateEngineTests.cs | 42 ++ .../MetaObjects.Render/FilesystemProvider.cs | 11 +- .../MetaObjects.Render/TemplateEngines.cs | 39 ++ .../generator/verify/TemplateVerify.java | 12 +- .../generator/verify/TemplateVerifyTest.java | 17 + .../main/java/com/metaobjects/ErrorCode.java | 9 + .../render/ClasspathResourceProvider.java | 8 +- .../render/FilesystemProvider.java | 14 +- .../metaobjects/render/TemplateEngines.java | 51 ++ .../render/TemplateEnginesTest.java | 45 ++ server/python/README.md | 4 +- server/python/pyproject.toml | 7 + server/python/src/metaobjects/cli.py | 2 + .../generators/render_helper_generator.py | 68 ++- server/python/src/metaobjects/errors.py | 5 + .../python/src/metaobjects/render/__init__.py | 25 +- .../python/src/metaobjects/render/engine.py | 57 ++ .../metaobjects/render/filesystem_provider.py | 63 +- .../src/metaobjects/render/jinja_render.py | 106 ++++ .../src/metaobjects/render/jinja_verify.py | 545 ++++++++++++++++++ .../python/src/metaobjects/render/renderer.py | 46 +- .../python/src/metaobjects/render/verify.py | 63 +- .../tests/codegen/test_cli_verify_subverbs.py | 24 + .../codegen/test_render_helper_generator.py | 121 +++- .../tests/render/test_jinja_conformance.py | 135 +++++ .../python/tests/render/test_jinja_engine.py | 131 +++++ server/python/uv.lock | 148 ++++- .../packages/cli/src/commands/verify.ts | 19 +- .../packages/cli/src/lib/file-provider.ts | 30 +- .../cli/test/integration/verify.test.ts | 34 ++ .../src/generators/template-doc-builder.ts | 11 +- .../src/render-engine/framework-provider.ts | 27 +- .../codegen-ts/src/templates/render-helper.ts | 34 +- .../render-engine/framework-provider.test.ts | 25 + .../test/render-helper-codegen.test.ts | 38 ++ .../packages/metadata/src/errors.ts | 6 +- server/typescript/packages/render/README.md | 13 +- .../typescript/packages/render/package.json | 7 +- .../typescript/packages/render/src/engine.ts | 47 ++ .../render/src/filesystem-provider.ts | 52 +- .../typescript/packages/render/src/index.ts | 16 +- .../packages/render/src/jinja/nunjucks.ts | 98 ++++ .../packages/render/src/jinja/render.ts | 51 ++ .../packages/render/src/jinja/verify.ts | 544 +++++++++++++++++ .../packages/render/src/provider.ts | 27 +- .../packages/render/src/providers.ts | 2 +- .../typescript/packages/render/src/render.ts | 31 +- .../packages/render/src/verify-shared.ts | 117 ++++ .../typescript/packages/render/src/verify.ts | 117 +--- .../render/test/jinja-conformance.test.ts | 119 ++++ .../packages/render/test/jinja-engine.test.ts | 112 ++++ .../ADR-0061-per-port-template-engines.md | 113 ++++ spec/decisions/README.md | 1 + 226 files changed, 5425 insertions(+), 222 deletions(-) create mode 100644 fixtures/jinja-render-conformance/jinja-render-conditionals/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-conditionals/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-conditionals/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-conditionals/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/expected-error.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/a.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/partials/b.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-cycle/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/expected-error.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-error-include-unresolved/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-html/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-html/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-html/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-html/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-json/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-json/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-json/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-json/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-filters/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-filters/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-filters/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-filters/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/partials/footer.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/partials/header.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/partials/macros.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-include-import/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-loops/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-loops/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-loops/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-loops/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-raw-and-comments/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-raw-and-comments/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-raw-and-comments/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-raw-and-comments/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-set-and-macros/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-set-and-macros/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-set-and-macros/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-set-and-macros/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-variables/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-variables/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-variables/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-variables/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-whitespace-control/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-whitespace-control/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-whitespace-control/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-whitespace-control/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-call-block-caller-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-conditionals/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-conditionals/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-conditionals/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dict-literal-values/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-attribute-argument/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-dynamic-subscript/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/partials/base.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-extends-blocks/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-element/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-element/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-element/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filters/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filters/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filters/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-element/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-element/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-element/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-else-outer-scope/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-for-tuple-target-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/partials/macros.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-with-context-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-without-context/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-without-context/partials/macros.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-without-context/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-import-without-context/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-dynamic-name/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/options.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-no-provider/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/partials/footer.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/partials/footer.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-resolved-drift/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/partials/item.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-sees-loop-local/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/options.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-include-unresolved/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-loop-variable-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-macro-params-local/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-member-on-scalar-or-list/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-method-call-receiver/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/options.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-required-slots-and-tags/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-in-if-visible-after/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-locals/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-locals/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-set-locals/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-syntax-error/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-syntax-error/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-syntax-error/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-unknown-function/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-unknown-function/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-unknown-function/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-clean/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-clean/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-clean/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-unknown/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-unknown/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-var-unknown/template.jinja create mode 100644 server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs create mode 100644 server/csharp/MetaObjects.Render/TemplateEngines.cs create mode 100644 server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java create mode 100644 server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java create mode 100644 server/python/src/metaobjects/render/engine.py create mode 100644 server/python/src/metaobjects/render/jinja_render.py create mode 100644 server/python/src/metaobjects/render/jinja_verify.py create mode 100644 server/python/tests/render/test_jinja_conformance.py create mode 100644 server/python/tests/render/test_jinja_engine.py create mode 100644 server/typescript/packages/render/src/engine.ts create mode 100644 server/typescript/packages/render/src/jinja/nunjucks.ts create mode 100644 server/typescript/packages/render/src/jinja/render.ts create mode 100644 server/typescript/packages/render/src/jinja/verify.ts create mode 100644 server/typescript/packages/render/src/verify-shared.ts create mode 100644 server/typescript/packages/render/test/jinja-conformance.test.ts create mode 100644 server/typescript/packages/render/test/jinja-engine.test.ts create mode 100644 spec/decisions/ADR-0061-per-port-template-engines.md diff --git a/AGENTS.md b/AGENTS.md index 9ef0ff6950..34a3e0263c 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; 31 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; 33 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 874f7d0283..c86b5cce3b 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/` (12) and `fixtures/jinja-verify-conformance/` + (30), 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. + - **Java, Kotlin and C# can compile and match `scope` patterns.** `com.metaobjects.config.Scope` (Java; Kotlin inherits it) and `MetaObjects.Config.Scope` (C#) port the scope-pattern grammar from TypeScript (`compilePattern` / `compileScope` / `matchesScope`), and both run the shared 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 1b9ca0549a..e85993bdad 100644 --- a/bun.lock +++ b/bun.lock @@ -368,9 +368,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", }, @@ -836,6 +838,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=="], @@ -848,6 +852,8 @@ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "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=="], @@ -874,6 +880,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=="], @@ -1246,6 +1254,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=="], @@ -1616,6 +1626,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=="], "randexp/ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 000ea2e354..2a5e9b6add 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **31 shared conformance corpora** under +The MetaObjects standard ships **33 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) | 12 | ✓ (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) | 30 | ✓ | 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) | @@ -76,6 +78,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), @@ -322,6 +326,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/` (12) and `fixtures/jinja-verify-conformance/` (30) + +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 10 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/templates-and-payloads.md b/docs/features/templates-and-payloads.md index c3f6f03b97..d87394beec 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,53 @@ 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. +- **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. 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: @@ -652,7 +701,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 +778,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/) — 12 cases: variables, loops, conditionals, `set` and macros, `include` and `import`, filters, html and json escaping, 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/) — 30 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 8a3f52e180..4d39b8f983 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 cd3ea282b3..e2b0ab8577 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 Prisma, JPA or EF Core models untouched. 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 563466ac1d..a27c26ea6d 100644 --- a/docs/ports/java.md +++ b/docs/ports/java.md @@ -889,7 +889,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 a60159907b..982f8016f4 100644 --- a/docs/ports/kotlin.md +++ b/docs/ports/kotlin.md @@ -869,7 +869,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 b7d4eb8767..a27bd3bdd6 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -750,6 +750,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 @@ -890,7 +908,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) | @@ -898,7 +916,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/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-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-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..bb5d443b23 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 (e.Message.StartsWith(TemplateEngines.ErrTemplateEngineUnsupported, StringComparison.Ordinal)) + { + // 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..a5f25b70a4 --- /dev/null +++ b/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs @@ -0,0 +1,42 @@ +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_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..1d097f54b4 --- /dev/null +++ b/server/csharp/MetaObjects.Render/TemplateEngines.cs @@ -0,0 +1,39 @@ +// 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()); + + 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/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..d001700cca 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 (!e.getMessage().startsWith(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED)) 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 bb9b7c1e48..27897940f9 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..fe100c4987 --- /dev/null +++ b/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java @@ -0,0 +1,51 @@ +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)); + } + + /** 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/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..411817c816 --- /dev/null +++ b/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java @@ -0,0 +1,45 @@ +package com.metaobjects.render; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +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 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..c286f9c097 --- /dev/null +++ b/server/python/src/metaobjects/render/engine.py @@ -0,0 +1,57 @@ +"""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 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", + "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..2856428668 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.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.suffix) - if not resolved.is_file(): + def _find(self, ref: str | None) -> Path | 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 + 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..0ed6cac078 --- /dev/null +++ b/server/python/src/metaobjects/render/jinja_render.py @@ -0,0 +1,106 @@ +"""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/``. Three 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, as in nunjucks; +* 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 + +from typing import Any + +from . import escapers +from .jinja_verify import jinja2_module, parse_jinja, ref_of_template_name +from .verify import Provider + +_MAX_DEPTH = 32 + + +class JinjaRenderError(Exception): + """A Jinja render failed before or during template evaluation.""" + + +def _stringify(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _normalize(payload: Any) -> Any: + """A Pydantic model (a generated payload model, ADR-0056) is rendered as its JSON-mode + dump, as the Mustache path does: field names as keys, enums as their wire values.""" + dump = getattr(payload, "model_dump", None) + if callable(dump) and not isinstance(payload, type): + return dump(mode="json") + return payload + + +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}") + _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 finalize(value: Any) -> str: + if isinstance(value, Markup): + return str(value) + if value is None or isinstance(value, jinja2.Undefined): + return "" + return escapers.escape(format_, _stringify(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.Undefined, + ) + context = _normalize(payload) + if not isinstance(context, dict): + context = {} + return str(env.from_string(body).render(**context)) + + +__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..0b287f4821 --- /dev/null +++ b/server/python/src/metaobjects/render/jinja_verify.py @@ -0,0 +1,545 @@ +"""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 +from .verify import ( + ERR_PARTIAL_UNRESOLVED, + ERR_TEMPLATE_NOT_STATIC, + ERR_TEMPLATE_SYNTAX, + ERR_VAR_NOT_ON_PAYLOAD, + PayloadField, + Provider, + VerifyError, + slot_and_tag_findings, +) + +#: How deep include / import / extends resolution recurses before it stops. +_MAX_DEPTH = 32 + +#: 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. +_ATTRIBUTE_ARG_INDEX = {"selectattr": 0, "rejectattr": 0, "groupby": 0, "join": 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 + 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() + + +def _spelling_of(node: Any, nodes: Any) -> str | None: + """The static spelling of a name or member chain (``p.title``, ``posts[0]``, + ``user[*]``), or ``None``.""" + if isinstance(node, nodes.Name): + return str(node.name) + if isinstance(node, nodes.Getattr): + target = _spelling_of(node.node, nodes) + return None if target is None else f"{target}.{node.attr}" + if isinstance(node, nodes.Getitem) and not isinstance(node.arg, nodes.Slice): + target = _spelling_of(node.node, nodes) + if target is None: + return None + arg = node.arg + if isinstance(arg, nodes.Const) and isinstance(arg.value, str): + return f"{target}.{arg.value}" + if isinstance(arg, nodes.Const) and isinstance(arg.value, int) and not isinstance(arg.value, bool): + return f"{target}[{arg.value}]" + return f"{target}[*]" + return None + + +def jinja_reference_line(text: str, path: str) -> int | None: + """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. ``None`` when it does not parse or is not named here.""" + jinja2 = jinja2_module() + nodes = jinja2.nodes + head = path.split(".")[0].split("[")[0].split("|")[0] + try: + ast = parse_jinja(text) + except jinja2.TemplateSyntaxError: + return None + by_head: int | None = None + + def visit(node: Any) -> int | None: + nonlocal by_head + spelled = _spelling_of(node, nodes) + if spelled == path: + return int(node.lineno) + if by_head is None and isinstance(node, nodes.Name) and spelled == head: + by_head = int(node.lineno) + for child in node.iter_child_nodes(): + hit = visit(child) + if hit is not None: + return hit + return None + + return visit(ast) or by_head + + +__all__ = [ + "JINJA_GLOBALS", + "jinja2_module", + "jinja_reference_line", + "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..be9625c652 100644 --- a/server/python/src/metaobjects/render/renderer.py +++ b/server/python/src/metaobjects/render/renderer.py @@ -23,8 +23,15 @@ from typing import Any from . import escapers +from .engine import TEMPLATE_ENGINE_JINJA, template_engine_of from .payload_accessors import with_derived_accessors -from .verify import InMemoryProvider, Provider +from .verify import ( + ERR_REQUIRED_SLOT_UNUSED, + InMemoryProvider, + PayloadField, + Provider, +) +from .verify import verify as verify_template MAX_DEPTH = 32 @@ -46,6 +53,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 +68,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 diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py index bb32646403..854845421a 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -27,12 +27,22 @@ #: 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" +#: 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" _MAX_DEPTH = 32 #: An opening tag is ```` or XML whitespace, so @@ -70,20 +80,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 +224,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 == "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() @@ -287,10 +320,23 @@ def walk( 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 +346,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..a5ae12d5ac --- /dev/null +++ b/server/python/tests/render/test_jinja_engine.py @@ -0,0 +1,131 @@ +"""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 pathlib import Path + +import pytest + +from metaobjects.render import ( + ERR_VAR_NOT_ON_PAYLOAD, + FilesystemProvider, + InMemoryProvider, + PayloadField, + engine_for_extension, + template_engine_of, + verify, +) +from metaobjects.render.jinja_verify import jinja_reference_line +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 }}>") + 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_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_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) == [] + + +def test_reference_line_prefers_the_exact_path() -> None: + text = "a\n{% for p in posts %}\n{{ p.nope }}\n{% endfor %}" + assert jinja_reference_line(text, "p.nope") == 3 + assert jinja_reference_line("{{ a }}", "b") is None + assert jinja_reference_line("{{ broken", "a") is 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/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..774ea27799 --- /dev/null +++ b/server/typescript/packages/render/src/engine.ts @@ -0,0 +1,47 @@ +// 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; +} + +/** + * 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) 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..044d6da223 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. Left at the default, the provider + * reads `.mustache` and falls back to the Jinja extensions; given + * explicitly, it reads only that extension. + */ + constructor(root: string, extension: string = DEFAULT_TEMPLATE_EXTENSION) { this.root = resolve(root); + this.extensions = extension === DEFAULT_TEMPLATE_EXTENSION + ? [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..6d7d8a80e2 --- /dev/null +++ b/server/typescript/packages/render/src/jinja/nunjucks.ts @@ -0,0 +1,98 @@ +// 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 NunjucksInternals { + parser: { parse(src: string): NjNode }; + lib: { escape(value: string): string }; +} + +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; +} + +/** + * 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); + const prev = internals.lib.escape; + internals.lib.escape = (v: unknown) => escaper(typeof v === "string" ? v : String(v)); + try { + return env.renderString(text, context); + } finally { + internals.lib.escape = prev; + } +} 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..cef83f4d72 --- /dev/null +++ b/server/typescript/packages/render/src/jinja/render.ts @@ -0,0 +1,51 @@ +// 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 { 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}`); + } + 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..c0f465aaec --- /dev/null +++ b/server/typescript/packages/render/src/jinja/verify.ts @@ -0,0 +1,544 @@ +// 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, + MAX_DEPTH, + slotAndTagFindings, + type PayloadField, + type VerifyError, + type VerifyOptions, +} from "../verify-shared.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. + */ +const ATTRIBUTE_ARG_INDEX: ReadonlyMap = new Map([ + ["selectattr", 0], ["rejectattr", 0], ["groupby", 0], ["join", 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; + } + 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..9b36216067 100644 --- a/server/typescript/packages/render/src/render.ts +++ b/server/typescript/packages/render/src/render.ts @@ -3,6 +3,8 @@ 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, templateEngineOf, type TemplateEngine } from "./engine.js"; +import { renderJinja } from "./jinja/render.js"; const MAX_DEPTH = 32; const PARTIAL = /\{\{>\s*([^}\s]+)\s*\}\}/g; @@ -44,15 +46,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 +71,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 +97,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..b44655dc72 100644 --- a/server/typescript/packages/render/src/verify.ts +++ b/server/typescript/packages/render/src/verify.ts @@ -10,51 +10,31 @@ // 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 } from "./engine.js"; +import { verifyJinja } from "./jinja/verify.js"; +import { + ERR_VAR_NOT_ON_PAYLOAD, + ERR_PARTIAL_UNRESOLVED, + 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 +127,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; @@ -263,23 +227,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..048a8c08f9 --- /dev/null +++ b/server/typescript/packages/render/test/jinja-engine.test.ts @@ -0,0 +1,112 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +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, +} 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 }}>"); +}); +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 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: "html" }); + expect(render({ template: "{{ a }}", payload: { a: "<" }, provider, engine: "jinja", format: "text" })).toBe("<"); + }); +}); + +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/decisions/ADR-0061-per-port-template-engines.md b/spec/decisions/ADR-0061-per-port-template-engines.md new file mode 100644 index 0000000000..7ca46f71af --- /dev/null +++ b/spec/decisions/ADR-0061-per-port-template-engines.md @@ -0,0 +1,113 @@ +# 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. + +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. +- **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. + +## 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 From f0bb3c37431b6286ae5733a657220822a42783cd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 09:42:14 -0400 Subject: [PATCH 2/8] refactor(render): share MAX_DEPTH and _stringify; explicit .mustache reads only .mustache Pre-gate simplifier pass: one MAX_DEPTH constant per port, the Jinja renderer reuses the Mustache renderer's scalar printer, and verify names the engine by its constant. Fix found in review: the TypeScript FilesystemProvider used the default extension as its 'not given' sentinel, so an explicit ".mustache" still fell back to Jinja files, against its own doc comment and the Python provider. It now falls back only when no extension is passed. Regression test in both ports. --- .../src/metaobjects/render/jinja_render.py | 17 ++++------------- .../src/metaobjects/render/jinja_verify.py | 6 ++---- .../python/src/metaobjects/render/renderer.py | 3 +-- server/python/src/metaobjects/render/verify.py | 9 ++++++--- server/python/tests/render/test_jinja_engine.py | 7 +++++++ .../packages/render/src/filesystem-provider.ts | 10 +++++----- server/typescript/packages/render/src/render.ts | 2 +- .../packages/render/test/jinja-engine.test.ts | 7 +++++++ 8 files changed, 33 insertions(+), 28 deletions(-) diff --git a/server/python/src/metaobjects/render/jinja_render.py b/server/python/src/metaobjects/render/jinja_render.py index 0ed6cac078..3512d21b90 100644 --- a/server/python/src/metaobjects/render/jinja_render.py +++ b/server/python/src/metaobjects/render/jinja_render.py @@ -18,23 +18,14 @@ from . import escapers from .jinja_verify import jinja2_module, parse_jinja, ref_of_template_name -from .verify import Provider - -_MAX_DEPTH = 32 +from .renderer import _stringify +from .verify import MAX_DEPTH, Provider class JinjaRenderError(Exception): """A Jinja render failed before or during template evaluation.""" -def _stringify(value: Any) -> str: - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, float) and value.is_integer(): - return str(int(value)) - return str(value) - - def _normalize(payload: Any) -> Any: """A Pydantic model (a generated payload model, ADR-0056) is rendered as its JSON-mode dump, as the Mustache path does: field names as keys, enums as their wire values.""" @@ -59,8 +50,8 @@ def _check_template_graph(text: str, provider: Provider, path: list[str]) -> Non 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}") + if len(path) >= MAX_DEPTH: + raise JinjaRenderError(f"partial depth exceeded {MAX_DEPTH}: {ref}") body = provider.resolve(ref) if body is None: if optional: diff --git a/server/python/src/metaobjects/render/jinja_verify.py b/server/python/src/metaobjects/render/jinja_verify.py index 0b287f4821..f35a5ac08d 100644 --- a/server/python/src/metaobjects/render/jinja_verify.py +++ b/server/python/src/metaobjects/render/jinja_verify.py @@ -32,15 +32,13 @@ ERR_TEMPLATE_NOT_STATIC, ERR_TEMPLATE_SYNTAX, ERR_VAR_NOT_ON_PAYLOAD, + MAX_DEPTH, PayloadField, Provider, VerifyError, slot_and_tag_findings, ) -#: How deep include / import / extends resolution recurses before it stops. -_MAX_DEPTH = 32 - #: 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( @@ -308,7 +306,7 @@ def follow_template( if provider is None: return ref = ref_of_template_name(template_node.value) - if ref in scope.seen or len(scope.seen) >= _MAX_DEPTH: + if ref in scope.seen or len(scope.seen) >= MAX_DEPTH: return text = provider.resolve(ref) if text is None: diff --git a/server/python/src/metaobjects/render/renderer.py b/server/python/src/metaobjects/render/renderer.py index be9625c652..cd866b7389 100644 --- a/server/python/src/metaobjects/render/renderer.py +++ b/server/python/src/metaobjects/render/renderer.py @@ -27,14 +27,13 @@ from .payload_accessors import with_derived_accessors from .verify import ( ERR_REQUIRED_SLOT_UNUSED, + MAX_DEPTH, InMemoryProvider, PayloadField, Provider, ) from .verify import verify as verify_template -MAX_DEPTH = 32 - # {{> name }} — captures the reference between the marker and the closing }}. _PARTIAL_PATTERN = re.compile(r"\{\{>\s*([^\s}]+)\s*\}\}") diff --git a/server/python/src/metaobjects/render/verify.py b/server/python/src/metaobjects/render/verify.py index 854845421a..d32bff5a2e 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from typing import Protocol +from .engine import TEMPLATE_ENGINE_JINJA from .payload_accessors import is_boolean_accessor, is_positional_accessor #: A ``{{var}}`` references a field the (contextual) payload does not declare. @@ -44,7 +45,9 @@ #: Mustache only, so a ``.jinja`` template there is refused with this code). ERR_TEMPLATE_ENGINE_UNSUPPORTED = "ERR_TEMPLATE_ENGINE_UNSUPPORTED" -_MAX_DEPTH = 32 +#: 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``). @@ -233,7 +236,7 @@ def verify( Mustache is context-sensitive: a section ``{{#posts}}…{{/posts}}`` over a container field checks its body against that field's element type. """ - if engine == "jinja": + if engine == TEMPLATE_ENGINE_JINJA: from .jinja_verify import verify_jinja return verify_jinja( @@ -309,7 +312,7 @@ 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: diff --git a/server/python/tests/render/test_jinja_engine.py b/server/python/tests/render/test_jinja_engine.py index a5ae12d5ac..78a8e82107 100644 --- a/server/python/tests/render/test_jinja_engine.py +++ b/server/python/tests/render/test_jinja_engine.py @@ -59,6 +59,13 @@ def test_explicit_extension_reads_only_that_extension(root: Path) -> None: 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" diff --git a/server/typescript/packages/render/src/filesystem-provider.ts b/server/typescript/packages/render/src/filesystem-provider.ts index 044d6da223..16539e2af1 100644 --- a/server/typescript/packages/render/src/filesystem-provider.ts +++ b/server/typescript/packages/render/src/filesystem-provider.ts @@ -27,13 +27,13 @@ export class FilesystemProvider implements Provider { private readonly extensions: readonly string[]; /** - * @param extension the one extension to read. Left at the default, the provider - * reads `.mustache` and falls back to the Jinja extensions; given - * explicitly, it reads only that 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 = DEFAULT_TEMPLATE_EXTENSION) { + constructor(root: string, extension?: string) { this.root = resolve(root); - this.extensions = extension === DEFAULT_TEMPLATE_EXTENSION + this.extensions = extension === undefined ? [DEFAULT_TEMPLATE_EXTENSION, ...JINJA_EXTENSIONS] : [extension]; } diff --git a/server/typescript/packages/render/src/render.ts b/server/typescript/packages/render/src/render.ts index 9b36216067..34e746b9a4 100644 --- a/server/typescript/packages/render/src/render.ts +++ b/server/typescript/packages/render/src/render.ts @@ -5,8 +5,8 @@ import { verify, ERR_REQUIRED_SLOT_UNUSED, type PayloadField } from "./verify.js import { withDerivedAccessors } from "./payload-accessors.js"; import { TEMPLATE_ENGINE_JINJA, 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, diff --git a/server/typescript/packages/render/test/jinja-engine.test.ts b/server/typescript/packages/render/test/jinja-engine.test.ts index 048a8c08f9..39549df5b3 100644 --- a/server/typescript/packages/render/test/jinja-engine.test.ts +++ b/server/typescript/packages/render/test/jinja-engine.test.ts @@ -53,6 +53,13 @@ describe("engine selection", () => { 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"); From 6dfdbea2ebdbb78eb1cc6566b34fb6c8ab3bf6b0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 12:00:43 -0400 Subject: [PATCH 3/8] docs(codegen-ts): prisma-schema header names its real options The reference header listed a 'prismaChecksFileName' option. That is the helper that derives the checks file's name from 'fileName', not an option of PrismaSchemaFileOptions. The header now names the options the type declares: filter, target, fileName, checks, provider and header. --- .../packages/codegen-ts/src/reference/prisma-schema.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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`, From a711e0be93bd95e7cac02e9a9f8085447dce2347 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 12:17:59 -0400 Subject: [PATCH 4/8] =?UTF-8?q?fix(render):=20Jinja=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20escaping,=20cross-engine=20partials,=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the pre-gate code review (each reproduced first): - Escaping. Python escaped a macro's or call block's output a second time, and both ports did so for a block `set`. Rendered blocks now print as already escaped (Python: a marked concat; TypeScript: the capture is compiled as a safe string). New corpus case jinja-render-escaping-blocks. - One engine per include chain. A Mustache partial whose file is Jinja was spliced in as text, and a Jinja include of a Mustache file was read as Jinja. Both are now refused with ERR_TEMPLATE_ENGINE_UNSUPPORTED in verify and at render time, in TypeScript and Python. - Java and C#: a Mustache partial that exists only as a Jinja file crashed verify with the provider's exception; it is now that finding. - Python: a chained lookup on a missing key renders empty (as nunjucks), any jinja2 failure is a RenderError, and a dataclass or plain-object payload is read by its fields instead of being replaced with an empty context. - TypeScript: an unknown explicit `engine` is refused instead of falling back to Mustache, matching Python. - Verify: positional attribute arguments of sum, min, max and unique are checked, as the keyword form already was. New corpus case jinja-verify-filter-attribute-positional. The two remaining engine differences (looping over null; a string filter over already-escaped text) are listed in ADR-0061 and the templates page. Corpus counts are now 13 render and 31 verify cases. --- CHANGELOG.md | 4 +- docs/CONFORMANCE.md | 6 +- docs/features/templates-and-payloads.md | 16 +++-- .../jinja-render-escaping-blocks/expected.txt | 4 ++ .../jinja-render-escaping-blocks/meta.json | 4 ++ .../jinja-render-escaping-blocks/payload.json | 4 ++ .../template.jinja | 4 ++ .../expected-drift.json | 6 ++ .../payload.json | 50 ++++++++++++++++ .../template.jinja | 2 + .../csharp/MetaObjects.Cli/VerifyCommand.cs | 2 +- .../TemplateEngineTests.cs | 9 +++ .../MetaObjects.Render/TemplateEngines.cs | 4 ++ server/csharp/MetaObjects.Render/Verify.cs | 9 ++- .../generator/verify/TemplateVerify.java | 2 +- .../metaobjects/render/TemplateEngines.java | 5 ++ .../java/com/metaobjects/render/Verify.java | 10 +++- .../render/TemplateEnginesTest.java | 11 ++++ .../python/src/metaobjects/render/engine.py | 22 +++++++ .../src/metaobjects/render/jinja_render.py | 54 +++++++++++++---- .../src/metaobjects/render/jinja_verify.py | 20 ++++++- .../python/src/metaobjects/render/renderer.py | 10 +++- .../python/src/metaobjects/render/verify.py | 5 +- .../python/tests/render/test_jinja_engine.py | 58 +++++++++++++++++++ .../typescript/packages/render/src/engine.ts | 27 ++++++++- .../packages/render/src/jinja/nunjucks.ts | 33 ++++++++++- .../packages/render/src/jinja/render.ts | 4 ++ .../packages/render/src/jinja/verify.ts | 12 +++- .../typescript/packages/render/src/render.ts | 12 +++- .../typescript/packages/render/src/verify.ts | 7 ++- .../packages/render/test/jinja-engine.test.ts | 31 ++++++++++ .../ADR-0061-per-port-template-engines.md | 14 ++++- 32 files changed, 422 insertions(+), 39 deletions(-) create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-blocks/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-blocks/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-blocks/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escaping-blocks/template.jinja create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/expected-drift.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/payload.json create mode 100644 fixtures/jinja-verify-conformance/jinja-verify-filter-attribute-positional/template.jinja diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b2217596..5e1f1cf19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ here.** 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/` (12) and `fixtures/jinja-verify-conformance/` - (30), hold the two ports to identical output and findings. Java, Kotlin and C# render + corpora, `fixtures/jinja-render-conformance/` (13) 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 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 172b184ea4..532294da43 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -31,8 +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) | 12 | ✓ (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) | 30 | ✓ | refuses Jinja | refuses, via Java | refuses Jinja | ✓ | +| [`fixtures/jinja-render-conformance/`](../fixtures/jinja-render-conformance/) (ADR-0061 — Jinja bodies render the same in jinja2 and nunjucks) | 13 | ✓ (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) | @@ -328,7 +328,7 @@ 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/` (12) and `fixtures/jinja-verify-conformance/` (30) +### `fixtures/jinja-render-conformance/` (13) 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 diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index d87394beec..170a7fb7f3 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -210,7 +210,12 @@ with jinja2 (`pip install "metaobjects[jinja]"`), TypeScript with nunjucks, whic 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. + 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. @@ -226,7 +231,10 @@ 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. The Mustache-only accessors (`has`, `-first`, `-last`) are not +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 @@ -780,8 +788,8 @@ for the per-port pass/skip ledger. **Jinja bodies (Python and TypeScript, ADR-0061)** -- [`fixtures/jinja-render-conformance/`](../../fixtures/jinja-render-conformance/) — 12 cases: variables, loops, conditionals, `set` and macros, `include` and `import`, filters, html and json escaping, 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/) — 30 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). +- [`fixtures/jinja-render-conformance/`](../../fixtures/jinja-render-conformance/) — 13 cases: variables, loops, conditionals, `set` and macros, `include` and `import`, filters, html and json escaping, 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 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-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/server/csharp/MetaObjects.Cli/VerifyCommand.cs b/server/csharp/MetaObjects.Cli/VerifyCommand.cs index bb5d443b23..2c775f05e0 100644 --- a/server/csharp/MetaObjects.Cli/VerifyCommand.cs +++ b/server/csharp/MetaObjects.Cli/VerifyCommand.cs @@ -564,7 +564,7 @@ public static Outcome Run(LoadResult load, string templatesRoot) { text = provider.Resolve(reference); } - catch (RenderException e) when (e.Message.StartsWith(TemplateEngines.ErrTemplateEngineUnsupported, StringComparison.Ordinal)) + 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)); diff --git a/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs b/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs index a5f25b70a4..a219f59837 100644 --- a/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs +++ b/server/csharp/MetaObjects.Render.Tests/TemplateEngineTests.cs @@ -33,6 +33,15 @@ public void A_mustache_body_beside_a_jinja_one_still_resolves() 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() { diff --git a/server/csharp/MetaObjects.Render/TemplateEngines.cs b/server/csharp/MetaObjects.Render/TemplateEngines.cs index 1d097f54b4..47f205fd92 100644 --- a/server/csharp/MetaObjects.Render/TemplateEngines.cs +++ b/server/csharp/MetaObjects.Render/TemplateEngines.cs @@ -19,6 +19,10 @@ public static class TemplateEngines 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)) 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 d001700cca..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 @@ -159,7 +159,7 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) { text = provider.resolve(ref); } catch (RenderException e) { // ADR-0061: a Jinja-only body is refused by name (this port renders Mustache only). - if (!e.getMessage().startsWith(TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED)) throw e; + if (!TemplateEngines.isEngineRefusal(e)) throw e; errors.add(new Drift(tmpl.getName(), kind, TemplateEngines.ERR_TEMPLATE_ENGINE_UNSUPPORTED, ref)); continue; } 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 index fe100c4987..bc10b66c52 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java +++ b/server/java/render/src/main/java/com/metaobjects/render/TemplateEngines.java @@ -27,6 +27,11 @@ 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)) { 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 index 411817c816..cf36a7fadb 100644 --- a/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java +++ b/server/java/render/src/test/java/com/metaobjects/render/TemplateEnginesTest.java @@ -5,6 +5,7 @@ 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; @@ -34,6 +35,16 @@ public void aMustacheBodyBesideAJinjaOneStillResolves() throws IOException { 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-"); diff --git a/server/python/src/metaobjects/render/engine.py b/server/python/src/metaobjects/render/engine.py index c286f9c097..5cbf0c1b49 100644 --- a/server/python/src/metaobjects/render/engine.py +++ b/server/python/src/metaobjects/render/engine.py @@ -30,6 +30,26 @@ def engine_for_extension(extension: str) -> str: 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 @@ -53,5 +73,7 @@ def template_engine_of(provider: Any, ref: str | None, explicit: str | None = No "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/jinja_render.py b/server/python/src/metaobjects/render/jinja_render.py index 3512d21b90..5ecee22c1b 100644 --- a/server/python/src/metaobjects/render/jinja_render.py +++ b/server/python/src/metaobjects/render/jinja_render.py @@ -2,11 +2,15 @@ format-driven escapers as the Mustache path. Output matches the TypeScript port (nunjucks) for the shared Jinja vocabulary — gated by -``fixtures/jinja-render-conformance/``. Three settings make that hold: +``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, 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:``). @@ -14,9 +18,12 @@ 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 @@ -26,13 +33,30 @@ class JinjaRenderError(Exception): """A Jinja render failed before or during template evaluation.""" -def _normalize(payload: Any) -> Any: - """A Pydantic model (a generated payload model, ADR-0056) is rendered as its JSON-mode - dump, as the Mustache path does: field names as keys, enums as their wire values.""" +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): - return dump(mode="json") - return payload + 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]]: @@ -57,6 +81,8 @@ def _check_template_graph(text: str, provider: Provider, path: list[str]) -> Non 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]) @@ -71,7 +97,7 @@ def render_jinja(body: str, ref: str | None, payload: Any, provider: Provider, f raise JinjaRenderError(f"template syntax error: {e.message}") from e def finalize(value: Any) -> str: - if isinstance(value, Markup): + if isinstance(value, (Markup, _Rendered)): return str(value) if value is None or isinstance(value, jinja2.Undefined): return "" @@ -86,12 +112,16 @@ def load(name: str) -> tuple[str, None, Any] | None: autoescape=False, keep_trailing_newline=True, finalize=finalize, - undefined=jinja2.Undefined, + undefined=jinja2.ChainableUndefined, ) - context = _normalize(payload) - if not isinstance(context, dict): - context = {} - return str(env.from_string(body).render(**context)) + env.concat = _concat + 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 index f35a5ac08d..e55c7e926c 100644 --- a/server/python/src/metaobjects/render/jinja_verify.py +++ b/server/python/src/metaobjects/render/jinja_verify.py @@ -26,9 +26,10 @@ from dataclasses import dataclass, field, replace from typing import Any -from .engine import JINJA_EXTENSIONS +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, @@ -51,8 +52,18 @@ ) #: 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. -_ATTRIBUTE_ARG_INDEX = {"selectattr": 0, "rejectattr": 0, "groupby": 0, "join": 1, "sort": 2} +#: 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" @@ -313,6 +324,9 @@ def follow_template( 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) diff --git a/server/python/src/metaobjects/render/renderer.py b/server/python/src/metaobjects/render/renderer.py index cd866b7389..feb23860d5 100644 --- a/server/python/src/metaobjects/render/renderer.py +++ b/server/python/src/metaobjects/render/renderer.py @@ -23,7 +23,13 @@ from typing import Any from . import escapers -from .engine import TEMPLATE_ENGINE_JINJA, template_engine_of +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 ( ERR_REQUIRED_SLOT_UNUSED, @@ -147,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 d32bff5a2e..ed6fb9cbda 100644 --- a/server/python/src/metaobjects/render/verify.py +++ b/server/python/src/metaobjects/render/verify.py @@ -23,7 +23,7 @@ from dataclasses import dataclass from typing import Protocol -from .engine import TEMPLATE_ENGINE_JINJA +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. @@ -318,6 +318,9 @@ def walk( 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) diff --git a/server/python/tests/render/test_jinja_engine.py b/server/python/tests/render/test_jinja_engine.py index 78a8e82107..e0be8d6b86 100644 --- a/server/python/tests/render/test_jinja_engine.py +++ b/server/python/tests/render/test_jinja_engine.py @@ -7,11 +7,13 @@ 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, @@ -34,6 +36,8 @@ def root(tmp_path: Path) -> Path: (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 @@ -136,3 +140,57 @@ def test_reference_line_prefers_the_exact_path() -> None: assert jinja_reference_line(text, "p.nope") == 3 assert jinja_reference_line("{{ a }}", "b") is None assert jinja_reference_line("{{ broken", "a") is None + + +_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/typescript/packages/render/src/engine.ts b/server/typescript/packages/render/src/engine.ts index 774ea27799..7dd78467ac 100644 --- a/server/typescript/packages/render/src/engine.ts +++ b/server/typescript/packages/render/src/engine.ts @@ -29,6 +29,26 @@ export function engineForExtension(extension: string): TemplateEngine { : 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. @@ -38,7 +58,12 @@ export function templateEngineOf( ref: string | undefined, explicit?: TemplateEngine, ): TemplateEngine { - if (explicit !== undefined) return explicit; + 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; diff --git a/server/typescript/packages/render/src/jinja/nunjucks.ts b/server/typescript/packages/render/src/jinja/nunjucks.ts index 6d7d8a80e2..bc73ba2f71 100644 --- a/server/typescript/packages/render/src/jinja/nunjucks.ts +++ b/server/typescript/packages/render/src/jinja/nunjucks.ts @@ -17,9 +17,15 @@ export interface NjNode { 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; @@ -59,6 +65,21 @@ export function refOfTemplateName(name: string): string { 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 — @@ -88,11 +109,19 @@ export function renderJinjaText( escaper: (s: string) => string, ): string { const env = environment(provider); - const prev = internals.lib.escape; + // 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 = prev; + 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 index cef83f4d72..6b4ba2b49b 100644 --- a/server/typescript/packages/render/src/jinja/render.ts +++ b/server/typescript/packages/render/src/jinja/render.ts @@ -8,6 +8,7 @@ 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"]); @@ -33,6 +34,9 @@ function checkTemplateGraph(text: string, provider: Provider, path: readonly str 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]); } } diff --git a/server/typescript/packages/render/src/jinja/verify.ts b/server/typescript/packages/render/src/jinja/verify.ts index c0f465aaec..1f2fc5d430 100644 --- a/server/typescript/packages/render/src/jinja/verify.ts +++ b/server/typescript/packages/render/src/jinja/verify.ts @@ -21,12 +21,14 @@ import { 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. */ @@ -61,10 +63,12 @@ const SAME_SHAPE_FILTERS: ReadonlySet = new Set([ /** * 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. + * 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], ["join", 1], ["sort", 2], + ["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"; @@ -318,6 +322,10 @@ export function verifyJinja( 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 { diff --git a/server/typescript/packages/render/src/render.ts b/server/typescript/packages/render/src/render.ts index 34e746b9a4..f6ada2390b 100644 --- a/server/typescript/packages/render/src/render.ts +++ b/server/typescript/packages/render/src/render.ts @@ -3,7 +3,14 @@ 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, templateEngineOf, type TemplateEngine } from "./engine.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"; @@ -20,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]); }); } diff --git a/server/typescript/packages/render/src/verify.ts b/server/typescript/packages/render/src/verify.ts index b44655dc72..08e64d1896 100644 --- a/server/typescript/packages/render/src/verify.ts +++ b/server/typescript/packages/render/src/verify.ts @@ -11,11 +11,12 @@ import Mustache from "mustache"; import { HAS_PREFIX, LAST_ACCESSOR, hasAccessorName } from "./payload-accessors.js"; -import { TEMPLATE_ENGINE_JINJA } from "./engine.js"; +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, @@ -215,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; diff --git a/server/typescript/packages/render/test/jinja-engine.test.ts b/server/typescript/packages/render/test/jinja-engine.test.ts index 39549df5b3..9f6f5ca1c6 100644 --- a/server/typescript/packages/render/test/jinja-engine.test.ts +++ b/server/typescript/packages/render/test/jinja-engine.test.ts @@ -10,6 +10,7 @@ import { engineForExtension, jinjaReferenceLine, ERR_VAR_NOT_ON_PAYLOAD, + ERR_TEMPLATE_ENGINE_UNSUPPORTED, } from "../src/index.js"; import { FilesystemProvider } from "../src/providers.js"; @@ -27,6 +28,8 @@ beforeAll(() => { 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 })); @@ -102,6 +105,34 @@ describe("render", () => { }); }); +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" }] }]; diff --git a/spec/decisions/ADR-0061-per-port-template-engines.md b/spec/decisions/ADR-0061-per-port-template-engines.md index 7ca46f71af..031bfaf518 100644 --- a/spec/decisions/ADR-0061-per-port-template-engines.md +++ b/spec/decisions/ADR-0061-per-port-template-engines.md @@ -38,7 +38,10 @@ language specific templating is fine." 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. + 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: @@ -95,12 +98,19 @@ that must render the same in both ports should stay inside the corpus vocabulary 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 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 From b06be54edabe8eb76fa5ada5cf752c82969c2b69 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 12:30:04 -0400 Subject: [PATCH 5/8] no-mistakes(review): Fix Jinja escape filters, symlink engine, restore-test oracle --- CHANGELOG.md | 2 +- docs/CONFORMANCE.md | 6 +-- docs/features/templates-and-payloads.md | 2 +- .../expected.txt | 4 ++ .../jinja-render-escape-filter-html/meta.json | 4 ++ .../payload.json | 4 ++ .../template.jinja | 4 ++ .../expected.txt | 1 + .../jinja-render-escape-filter-json/meta.json | 4 ++ .../payload.json | 3 ++ .../template.jinja | 1 + .../metaobjects/render/filesystem_provider.py | 8 +-- .../src/metaobjects/render/jinja_render.py | 18 ++++--- .../src/metaobjects/render/jinja_verify.py | 51 ------------------- .../python/tests/render/test_jinja_engine.py | 20 +++++--- .../packages/render/test/jinja-engine.test.ts | 5 +- 16 files changed, 61 insertions(+), 76 deletions(-) create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-html/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-html/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-html/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-html/template.jinja create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-json/expected.txt create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-json/meta.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-json/payload.json create mode 100644 fixtures/jinja-render-conformance/jinja-render-escape-filter-json/template.jinja diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1f1cf19e..dfeb4696eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ here.** 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/` (13) and `fixtures/jinja-verify-conformance/` + 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 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 532294da43..87d16fdec3 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -31,7 +31,7 @@ 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) | 13 | ✓ (nunjucks) | refuses Jinja (`ERR_TEMPLATE_ENGINE_UNSUPPORTED`) | refuses, via Java | refuses Jinja (`ERR_TEMPLATE_ENGINE_UNSUPPORTED`) | ✓ (jinja2) | +| [`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 | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -328,12 +328,12 @@ 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/` (13) and `fixtures/jinja-verify-conformance/` (31) +### `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 10 output cases and 2 error +`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 diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index 170a7fb7f3..a05318394c 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -788,7 +788,7 @@ for the per-port pass/skip ledger. **Jinja bodies (Python and TypeScript, ADR-0061)** -- [`fixtures/jinja-render-conformance/`](../../fixtures/jinja-render-conformance/) — 13 cases: variables, loops, conditionals, `set` and macros, `include` and `import`, filters, html and json escaping, whitespace control, `raw` and comments, plus an include cycle and an unresolved include. Byte-identical output from jinja2 and nunjucks. +- [`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 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/server/python/src/metaobjects/render/filesystem_provider.py b/server/python/src/metaobjects/render/filesystem_provider.py index 2856428668..ea287f54ef 100644 --- a/server/python/src/metaobjects/render/filesystem_provider.py +++ b/server/python/src/metaobjects/render/filesystem_provider.py @@ -39,16 +39,16 @@ def resolve(self, ref: str | None) -> str | None: if found is None: return None try: - return found.read_text(encoding="utf-8") + return found[0].read_text(encoding="utf-8") except OSError: 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.suffix) + return None if found is None else engine_for_extension(found[1]) - def _find(self, ref: str | None) -> Path | None: + def _find(self, ref: str | None) -> tuple[Path, str] | None: # Build // from the slash-separated ref, dropping empties. if not ref: return None @@ -66,5 +66,5 @@ def _find(self, ref: str | None) -> Path | None: if resolved != self._root and self._root not in resolved.parents: continue if resolved.is_file(): - return resolved + 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 index 5ecee22c1b..a283dc215d 100644 --- a/server/python/src/metaobjects/render/jinja_render.py +++ b/server/python/src/metaobjects/render/jinja_render.py @@ -6,7 +6,8 @@ * 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, as in nunjucks; + ``| 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 @@ -96,12 +97,16 @@ def render_jinja(body: str, ref: str | None, payload: Any, provider: Provider, f except jinja2.TemplateSyntaxError as e: raise JinjaRenderError(f"template syntax error: {e.message}") from e - def finalize(value: Any) -> str: - if isinstance(value, (Markup, _Rendered)): - return str(value) + def forceescape(value: Any) -> Markup: if value is None or isinstance(value, jinja2.Undefined): - return "" - return escapers.escape(format_, _stringify(value)) + 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)) @@ -115,6 +120,7 @@ def load(name: str) -> tuple[str, None, Any] | None: 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)) diff --git a/server/python/src/metaobjects/render/jinja_verify.py b/server/python/src/metaobjects/render/jinja_verify.py index e55c7e926c..cfe42a2666 100644 --- a/server/python/src/metaobjects/render/jinja_verify.py +++ b/server/python/src/metaobjects/render/jinja_verify.py @@ -497,60 +497,9 @@ class _LocalMarker: _LOCAL: Any = _LocalMarker() -def _spelling_of(node: Any, nodes: Any) -> str | None: - """The static spelling of a name or member chain (``p.title``, ``posts[0]``, - ``user[*]``), or ``None``.""" - if isinstance(node, nodes.Name): - return str(node.name) - if isinstance(node, nodes.Getattr): - target = _spelling_of(node.node, nodes) - return None if target is None else f"{target}.{node.attr}" - if isinstance(node, nodes.Getitem) and not isinstance(node.arg, nodes.Slice): - target = _spelling_of(node.node, nodes) - if target is None: - return None - arg = node.arg - if isinstance(arg, nodes.Const) and isinstance(arg.value, str): - return f"{target}.{arg.value}" - if isinstance(arg, nodes.Const) and isinstance(arg.value, int) and not isinstance(arg.value, bool): - return f"{target}[{arg.value}]" - return f"{target}[*]" - return None - - -def jinja_reference_line(text: str, path: str) -> int | None: - """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. ``None`` when it does not parse or is not named here.""" - jinja2 = jinja2_module() - nodes = jinja2.nodes - head = path.split(".")[0].split("[")[0].split("|")[0] - try: - ast = parse_jinja(text) - except jinja2.TemplateSyntaxError: - return None - by_head: int | None = None - - def visit(node: Any) -> int | None: - nonlocal by_head - spelled = _spelling_of(node, nodes) - if spelled == path: - return int(node.lineno) - if by_head is None and isinstance(node, nodes.Name) and spelled == head: - by_head = int(node.lineno) - for child in node.iter_child_nodes(): - hit = visit(child) - if hit is not None: - return hit - return None - - return visit(ast) or by_head - - __all__ = [ "JINJA_GLOBALS", "jinja2_module", - "jinja_reference_line", "parse_jinja", "ref_of_template_name", "verify_jinja", diff --git a/server/python/tests/render/test_jinja_engine.py b/server/python/tests/render/test_jinja_engine.py index e0be8d6b86..bfe146bac6 100644 --- a/server/python/tests/render/test_jinja_engine.py +++ b/server/python/tests/render/test_jinja_engine.py @@ -22,7 +22,6 @@ template_engine_of, verify, ) -from metaobjects.render.jinja_verify import jinja_reference_line from metaobjects.render.renderer import RenderError, RenderRequest, render @@ -57,6 +56,18 @@ def test_filesystem_provider_prefers_mustache_and_reports_engine(root: Path) -> 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 }}" @@ -135,13 +146,6 @@ def test_verify_engine_jinja_and_default_mustache() -> None: assert verify("{{user.name}}", fields) == [] -def test_reference_line_prefers_the_exact_path() -> None: - text = "a\n{% for p in posts %}\n{{ p.nope }}\n{% endfor %}" - assert jinja_reference_line(text, "p.nope") == 3 - assert jinja_reference_line("{{ a }}", "b") is None - assert jinja_reference_line("{{ broken", "a") is None - - _MIX_FIELDS = [PayloadField("name"), PayloadField("tags", is_array=True)] diff --git a/server/typescript/packages/render/test/jinja-engine.test.ts b/server/typescript/packages/render/test/jinja-engine.test.ts index 9f6f5ca1c6..4ed462014b 100644 --- a/server/typescript/packages/render/test/jinja-engine.test.ts +++ b/server/typescript/packages/render/test/jinja-engine.test.ts @@ -1,4 +1,5 @@ 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"; @@ -100,8 +101,8 @@ describe("render", () => { 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: "html" }); - expect(render({ template: "{{ a }}", payload: { a: "<" }, provider, engine: "jinja", format: "text" })).toBe("<"); + render({ template: "{{ a }}", payload: { a: "<" }, provider, engine: "jinja", format: "text" }); + expect(new nunjucks.Environment(null, { autoescape: true }).renderString("{{ a }}", { a: "<" })).toBe("<"); }); }); From 614da76eb0a5a3537ba3fdae1d784f752c616573 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 13:28:07 -0400 Subject: [PATCH 6/8] no-mistakes(test): Regenerate stale agent-context-conformance prompt-skill goldens --- .../skills/metaobjects-prompts/SKILL.md | 18 +++++++++++-- .../metaobjects-prompts/references/java.md | 6 +++++ .../metaobjects-prompts/references/kotlin.md | 6 +++++ .../skills/metaobjects-prompts/SKILL.md | 18 +++++++++++-- .../metaobjects-prompts/references/java.md | 6 +++++ .../skills/metaobjects-prompts/SKILL.md | 18 +++++++++++-- .../metaobjects-prompts/references/python.md | 26 ++++++++++++++++++- .../skills/metaobjects-prompts/SKILL.md | 18 +++++++++++-- .../references/typescript.md | 12 +++++++++ .../skills/metaobjects-prompts/SKILL.md | 18 +++++++++++-- .../references/typescript.md | 12 +++++++++ 11 files changed, 147 insertions(+), 11 deletions(-) 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 From a13a372c6e5bb0b62c8848bbd4398976e2e44d95 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 13:53:42 -0400 Subject: [PATCH 7/8] no-mistakes(document): Jinja change docs were already mostly current. Fixed three stale spots: the Python render line in templates-and-payloads.md, the verify --templates row in cli.md, and a Jinja pointer in spec/conformance-tests.md. Nothing unresolved. --- docs/features/cli.md | 2 +- docs/features/templates-and-payloads.md | 3 ++- spec/conformance-tests.md | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) 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 a05318394c..a225b77afa 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -427,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 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) From b57b650e73adab0d10fbd808196dc885b14853a8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 13:56:33 -0400 Subject: [PATCH 8/8] chore(site): refresh site payload corpus count to 34 --- examples/showcase/site-payload.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 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": {