diff --git a/CHANGELOG.md b/CHANGELOG.md index f91a84999c..3631b5903c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,23 @@ here.** new command. `examples/brownfield/` holds a runnable project for each, and the recipe ships in the `metaobjects-authoring` skill. +### Changed + +- **A prompt or output template that names a variable not on its payload now raises at render time.** + Behaviour change: a generated render helper used to render `Hi {{nmae}}` as `Hi ` with no error + when the payload declares `name`; it now raises `ERR_VAR_NOT_ON_PAYLOAD (nmae)` (`RenderError` in + Python, `RenderException` in Java, Kotlin and C#, an `Error` in TypeScript). The build-time check + already caught this when the helper was generated; the gap was text edited or served afterwards. + The silent paths were the TypeScript `promptRender()` handle, the Python helper on a Mustache body + (only Jinja was checked), and the Java and Kotlin helpers, whose `Renderer` accepted the payload + field tree and never read it. The C# helper and the TypeScript `render-helper` already raised. All + five ports are held to it by `fixtures/template-output-render-conformance/runtime-drift/`. A call + to `render()` with no declared payload is unchanged: it has nothing to compare against, so it keeps + the engine's default and checks only when you pass `verify` (`verify:` in TypeScript, `verify=` in + Python, `Verify` in C#, the `verify` field of `RenderRequest` in Java and Kotlin). If a template + already named a missing variable, regenerate and fix the name; the generated helper text changes + (TypeScript `prompts.ts` and Python helpers gain a `verify` argument). + ### Fixed - **Python: an array field's `default` generated a string.** A model with diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index fb6d8316a3..faef254739 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -714,6 +714,17 @@ and checks each one exists on the payload VO. If a template references body (Python and TypeScript) is checked the same way; see [Jinja templates](#jinja-templates-python-and-typescript). +**The same check runs at render time in every generated render helper** (the `template.output` +helper and the `template.prompt` handle, in all five ports). The helper carries its payload's +field tree, so text edited or served after codegen that names a variable the payload lacks +(`{{nmae}}` for `name`) raises `ERR_VAR_NOT_ON_PAYLOAD` instead of rendering as an empty +string. Before this, the TypeScript `promptRender()` handle, the Python helper on a Mustache +body, and the Java and Kotlin helpers rendered it silently. Calling the engine's `render()` +directly with no declared payload keeps the engine's default, since there is nothing to compare +against; ask for the check by passing the field tree (`verify` in TypeScript, Java, Kotlin and +C#; `verify=` in Python). Pinned by +[`fixtures/template-output-render-conformance/runtime-drift/`](../../fixtures/template-output-render-conformance/runtime-drift/). + A bare `verify` also checks generated code and the requirement ledger; name the template check to run it alone. Java and Python skip this check with a note when no template root is configured; C# does not skip, and a bare run with no `prompts/` or `templates/` directory diff --git a/examples/showcase/.metaobjects/.gen-state/.hashes.json b/examples/showcase/.metaobjects/.gen-state/.hashes.json index 8a7a4f6bb9..919238f96d 100644 --- a/examples/showcase/.metaobjects/.gen-state/.hashes.json +++ b/examples/showcase/.metaobjects/.gen-state/.hashes.json @@ -16,6 +16,6 @@ "generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96", "generated/ts/SubscriberBlurbPayload.ts": "83272130c428ad0cc68190cb63e32b418a0bcfd05345804def9bf22e79b55fcc", "generated/ts/index.ts": "2c915565fd8711503dbcd0b0139c657bb927c7f0f502d8d2a8b51e026b257694", - "generated/ts/prompts.ts": "67365bc5504dbb19fafbf6c654afa822f7fe6af4e5f24fd246d69657698db606", + "generated/ts/prompts.ts": "9eec821ec8cb6063f98b52b78ae1758cfd22c81a71cbb90b0555dee347a687af", "generated/ts/requirements/subscriberCanBePausedWithoutErasingHistory.field.enum.test.ts": "a5d2494ce5340673da43f2ee3ef13d7505ee349724e8d85f377a84eafeea50ae" } diff --git a/examples/showcase/generated/ts/prompts.ts b/examples/showcase/generated/ts/prompts.ts index 137833aadd..40b6bcf37e 100644 --- a/examples/showcase/generated/ts/prompts.ts +++ b/examples/showcase/generated/ts/prompts.ts @@ -4,5 +4,5 @@ import { render, type Provider } from "@metaobjectsdev/render"; import type { SubscriberBlurbPayload } from "./SubscriberBlurbPayload"; export function renderSubscriberBlurb(payload: SubscriberBlurbPayload, provider: Provider): string { - return render({ ref: "subscriber/blurb", payload, format: "text", provider }); + return render({ ref: "subscriber/blurb", payload, format: "text", provider, verify: [{"name":"name"},{"name":"status"}] }); } diff --git a/fixtures/conformance/template-prompt-simple/expected/prompts.ts b/fixtures/conformance/template-prompt-simple/expected/prompts.ts index d3471649eb..7a47069288 100644 --- a/fixtures/conformance/template-prompt-simple/expected/prompts.ts +++ b/fixtures/conformance/template-prompt-simple/expected/prompts.ts @@ -4,5 +4,5 @@ import { render, type Provider } from "@metaobjectsdev/render"; import type { NpcPromptPayload } from "./NpcPromptPayload.js"; export function renderNpcTurn(payload: NpcPromptPayload, provider: Provider): string { - return render({ ref: "npc/turn", payload, format: "xml", provider }); + return render({ ref: "npc/turn", payload, format: "xml", provider, verify: [{"name":"name"},{"name":"mood"}] }); } diff --git a/fixtures/template-output-render-conformance/README.md b/fixtures/template-output-render-conformance/README.md index a15d450588..802f77b963 100644 --- a/fixtures/template-output-render-conformance/README.md +++ b/fixtures/template-output-render-conformance/README.md @@ -217,3 +217,16 @@ error code `ERR_VAR_NOT_ON_PAYLOAD`, and the message must name the offending field (`missing`), the template (`WelcomePage`), and the ref (`pages/bad`). This is the build-time gate that stops a template from drifting away from its payload contract. + +## Runtime drift — `runtime-drift/` + +A variable that is not on the payload must RAISE at render time, never render as empty. The +build-time gate cannot see this case: the helper is generated against clean text, and the text is +edited or served by another provider afterwards. `runtime-drift/meta.json` declares the `Welcome` +payload plus a `template.output` (`WelcomePage`) and a `template.prompt` (`WelcomePrompt`) over the +same ref `pages/welcome`. Each port: + +1. generates both helpers with the CLEAN `templates/` (the codegen gate passes); +2. renders with `templates/` → `"Hello Ada"`; +3. renders with `runtime-drift/templates/` (`pages/welcome.mustache` = `Hello {{nmae}}`) → the + helper throws, and the message contains `ERR_VAR_NOT_ON_PAYLOAD (nmae)`. diff --git a/fixtures/template-output-render-conformance/runtime-drift/meta.json b/fixtures/template-output-render-conformance/runtime-drift/meta.json new file mode 100644 index 0000000000..fccbd58864 --- /dev/null +++ b/fixtures/template-output-render-conformance/runtime-drift/meta.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "acme::ai", + "children": [ + { + "object.value": { + "name": "Welcome", + "children": [ + { "field.string": { "name": "name", "@required": true } } + ] + } + }, + { + "template.output": { + "name": "WelcomePage", + "@kind": "document", + "@payloadRef": "Welcome", + "@textRef": "pages/welcome", + "@format": "text" + } + }, + { + "template.prompt": { + "name": "WelcomePrompt", + "@payloadRef": "Welcome", + "@textRef": "pages/welcome", + "@format": "text" + } + } + ] + } +} diff --git a/fixtures/template-output-render-conformance/runtime-drift/templates/pages/welcome.mustache b/fixtures/template-output-render-conformance/runtime-drift/templates/pages/welcome.mustache new file mode 100644 index 0000000000..af61ac2fad --- /dev/null +++ b/fixtures/template-output-render-conformance/runtime-drift/templates/pages/welcome.mustache @@ -0,0 +1 @@ +Hello {{nmae}} \ No newline at end of file diff --git a/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs index 7e8199dbd8..8faf3131d2 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/RenderHelperConformanceTests.cs @@ -291,6 +291,35 @@ public void Drift_case_FAILS_codegen() Assert.Contains("missing", ex.Message); } + // --------------------------------------------------------------------- + // runtime-drift/ → a variable not on the payload RAISES at render time. The helpers are + // generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for + // name (text edited after codegen, which the build-time gate cannot see). Covers a + // template.output and a template.prompt over the same payload. + // --------------------------------------------------------------------- + + [Theory] + [InlineData("WelcomePage", "Acme.Generated.WelcomePageRenderHelper")] + [InlineData("WelcomePrompt", "Acme.Generated.WelcomePromptRenderHelper")] + public void Runtime_drift_variable_not_on_payload_raises(string template, string helperType) + { + var corpus = Corpus(); + var root = LoadFromFile(Path.Combine(corpus, "runtime-drift", "meta.json")); + var clean = Path.Combine(corpus, "templates"); + var drifted = Path.Combine(corpus, "runtime-drift", "templates"); + + var file = Assert.Single(new RenderHelperGenerator(clean).Generate(Ctx(root)), f => f.Path == template + ".render.cs"); + var asm = CompileToAssembly([file.Content, .. GeneratedValueObjects.Sources(root)]); + + var render = asm.GetType(helperType)!.GetMethod("Render")!; + var payload = MakeWelcome(asm, "Ada"); + Assert.Equal("Hello Ada", (string)render.Invoke(null, [payload, new FilesystemProvider(clean)])!); + + var ex = Assert.Throws( + () => render.Invoke(null, [payload, new FilesystemProvider(drifted)])); + Assert.Contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)", ex.InnerException!.Message); + } + // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects.Render/Renderer.cs b/server/csharp/MetaObjects.Render/Renderer.cs index 5423aa6f07..1833c96599 100644 --- a/server/csharp/MetaObjects.Render/Renderer.cs +++ b/server/csharp/MetaObjects.Render/Renderer.cs @@ -6,7 +6,8 @@ // owned (the format escaper is installed as Stubble's encoding function, the // equivalent of overriding mustache.js's Mustache.escape). `{{{x}}}` bypasses it. // -// The render-time `verify` guard (FR-004 Plan #3) is intentionally not wired yet. +// The render-time `verify` guard (FR-004 Plan #3) is wired: a request carrying the payload field +// tree verifies the resolved body before it renders, and a mismatch throws RenderException. using Stubble.Core.Builders; using Stubble.Core.Settings; diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt index 377800334b..9a579e1cf8 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinRenderHelperConformanceTest.kt @@ -88,6 +88,40 @@ class KotlinRenderHelperConformanceTest { } } + // ------------------------------------------------------------------------- + // runtime-drift/ → a variable not on the payload RAISES at render time. The helpers are + // generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for + // name (text edited after codegen, which the build-time gate cannot see). Covers a + // template.output and a template.prompt over the same payload. + // ------------------------------------------------------------------------- + @Test fun `runtime drift raises for output and prompt helpers`() { + val outDir = Files.createTempDirectory("krhc-rdrift-") + val clean = corpus.resolve("templates") + val drifted = corpus.resolve("runtime-drift").resolve("templates") + try { + generate(corpus.resolve("runtime-drift").resolve("meta.json"), outDir, clean) + val result = compile(outDir) + assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) + val cl = result.classLoader + + val payloadClass = cl.loadClass("acme.ai.Welcome") + val payload = payloadClass.getDeclaredConstructor(String::class.java).newInstance("Ada") + val providerClass = cl.loadClass("com.metaobjects.render.Provider") + for (name in listOf("WelcomePageRenderHelper", "WelcomePromptRenderHelper")) { + val helperClass = cl.loadClass("acme.ai.prompts.$name") + val instance = helperClass.getDeclaredField("INSTANCE").get(null) + val render = helperClass.getDeclaredMethod("render", payloadClass, providerClass) + assertEquals("Hello Ada", render.invoke(instance, payload, fsProvider(cl, clean)), name) + val ex = kotlin.test.assertFailsWith(name) { + render.invoke(instance, payload, fsProvider(cl, drifted)) + } + assertTrue(ex.cause?.message?.contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)") == true, "$name: ${ex.cause?.message}") + } + } finally { + outDir.toFile().deleteRecursively() + } + } + // ------------------------------------------------------------------------- // email → EmailDocument // ------------------------------------------------------------------------- diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedRenderHelperConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedRenderHelperConformanceTest.java index 8699bf2317..b3e0d5eb7b 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedRenderHelperConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedRenderHelperConformanceTest.java @@ -276,6 +276,44 @@ public void driftCaseFailsCodegen() throws Exception { // Helpers // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + // runtime-drift/ -> a variable not on the payload RAISES at render time. The helpers are + // generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for + // name (text edited after codegen, which the build-time gate cannot see). Covers a + // template.output and a template.prompt over the same payload. + // ------------------------------------------------------------------------- + @Test + public void runtimeDriftRaisesForOutputAndPromptHelpers() throws Exception { + assertNotNull("corpus must be reachable", CORPUS); + Path clean = CORPUS.resolve("templates"); + Path drifted = CORPUS.resolve("runtime-drift").resolve("templates"); + MetaDataLoader loader = loadCorpus(CORPUS.resolve("runtime-drift").resolve("meta.json"), "rdrift"); + + Path gen = tmp.newFolder("rdrift-gen").toPath(); + generate(loader, gen, clean); + + Path classes = compile(collectSources(gen)); + try (URLClassLoader cl = new URLClassLoader( + new URL[]{ classes.toUri().toURL() }, getClass().getClassLoader())) { + Class payloadClass = cl.loadClass("acme.ai.Welcome"); + Object payload = payloadClass.getConstructor(String.class).newInstance("Ada"); + Class providerClass = Class.forName("com.metaobjects.render.Provider"); + + for (String helper : new String[]{ "WelcomePageRenderHelper", "WelcomePromptRenderHelper" }) { + Method render = cl.loadClass("acme.ai.prompts." + helper) + .getMethod("render", payloadClass, providerClass); + assertEquals(helper, "Hello Ada", render.invoke(null, payload, newFilesystemProvider(clean))); + try { + Object out = render.invoke(null, payload, newFilesystemProvider(drifted)); + fail(helper + " rendered a variable not on the payload instead of raising: \"" + out + "\""); + } catch (java.lang.reflect.InvocationTargetException e) { + String msg = String.valueOf(e.getCause().getMessage()); + assertTrue(helper + ": " + msg, msg.contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)")); + } + } + } + } + private MetaDataLoader loadCorpus(Path metaJson, String baseName) throws Exception { assertTrue("corpus meta.json must exist: " + metaJson, Files.exists(metaJson)); Path ws = tmp.newFolder("ws-" + baseName).toPath(); diff --git a/server/java/render/src/main/java/com/metaobjects/render/Renderer.java b/server/java/render/src/main/java/com/metaobjects/render/Renderer.java index baf1f4b674..6376f5bc8d 100644 --- a/server/java/render/src/main/java/com/metaobjects/render/Renderer.java +++ b/server/java/render/src/main/java/com/metaobjects/render/Renderer.java @@ -16,7 +16,8 @@ /** * Mustache-driven render pipeline for the {@code template.*} metatype (FR-004). * - *

Pipeline: resolve template text (inline or via provider) → pre-expand + *

Pipeline: resolve template text (inline or via provider) → verify it against the + * request's payload field tree when one is given → pre-expand * partials by recursive inlining (cycle-guarded, MAX_DEPTH=32) → * Mustache.java compile + execute (HTML escaping DISABLED on the factory * because the {@link Escapers} layer owns escaping) → apply format-keyed @@ -41,6 +42,22 @@ public String render(RenderRequest req) { ? req.template() : resolveOrThrow(req.provider(), req.ref()); + // Drift guard: when the request carries the payload field tree, the RESOLVED body is + // verified against it before it renders, so `{{nmae}}` for `name` raises instead of + // rendering empty. A warning-level result (a declared required slot the body never + // uses) is not drift. Matches TS render.ts and C# Renderer.Render. + if (req.verify() != null) { + List drift = Verify.check(body, req.verify(), new VerifyOptions(req.provider(), null, null)) + .stream() + .filter(e -> !Verify.ERR_REQUIRED_SLOT_UNUSED.equals(e.code())) + .toList(); + if (!drift.isEmpty()) { + throw new RenderException("render verify failed: " + drift.stream() + .map(e -> e.code() + " (" + e.path() + ")") + .collect(java.util.stream.Collectors.joining(", "))); + } + } + String expanded = preExpandPartials(body, req.provider(), new ArrayDeque<>()); // Per-variable escaping via the Mustache encode() hook — equivalent to 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 839093c2fe..4244c44d95 100644 --- a/server/python/src/metaobjects/codegen/generators/render_helper_generator.py +++ b/server/python/src/metaobjects/codegen/generators/render_helper_generator.py @@ -37,13 +37,11 @@ generator declares no payload type. A plain ``dict`` still renders — the annotation is for the caller, and the render engine reads a model and a mapping alike. -Python divergence vs TS / Java / C#: Python's ``RenderRequest`` has NO ``verify`` -field — the Python render engine does not run a runtime drift pass — so the emitted -helper does NOT pass a runtime ``verify`` field-tree. The BUILD-TIME gate that runs -here is the only, and a complete, drift guarantee: codegen cannot succeed unless -every referenced mustache verifies clean against the payload VO. The field-tree walk -is still performed (it is what ``verify`` checks against); it is simply not baked into -the emitted call. The emitted helper shape is otherwise identical to the other ports. +The emitted call bakes in the payload field tree as ``verify=``, so the render engine +checks the template it resolves at RUN time against the payload too (a ``RenderError`` +carrying ``ERR_VAR_NOT_ON_PAYLOAD``), the same as every other port. That covers text edited +or served after codegen, which the BUILD-TIME gate cannot see; without it ``{{nmae}}`` for +``name`` would render as an empty string. Both engines are covered, Mustache and Jinja. Drift message style matches the other ports exactly: ``render-helper drift: template "" ref "" — : {{}} not on payload VO`` @@ -479,20 +477,17 @@ def render_helper_generator( 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 [] + """The import every helper needs: the ``PayloadField`` type its baked-in ``verify=`` tree is + written with. (A Mustache helper used to need none, before it passed ``verify``.)""" 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)},", - ] + """Run the payload check again at render time against the field tree the build gate used, + for either engine. A Jinja body also names its engine (ADR-0061), so a runtime provider that + cannot tell, such as an in-memory one, still renders it as Jinja.""" + lines = [] + if engine == TEMPLATE_ENGINE_JINJA: + lines.append(f"{pad}engine={_py_str(TEMPLATE_ENGINE_JINJA)},") + lines.append(f"{pad}verify={_field_tree_literal(fields)},") + return lines diff --git a/server/python/tests/codegen/test_render_helper_generator.py b/server/python/tests/codegen/test_render_helper_generator.py index aae39ae178..3389f60b71 100644 --- a/server/python/tests/codegen/test_render_helper_generator.py +++ b/server/python/tests/codegen/test_render_helper_generator.py @@ -555,15 +555,14 @@ def test_jinja_not_static_fails_codegen(tmp_path) -> None: raise AssertionError("expected ERR_TEMPLATE_NOT_STATIC to fail codegen") -def test_mustache_helper_has_no_engine_or_runtime_tree(tmp_path) -> None: +def test_mustache_helper_names_no_engine_but_bakes_in_the_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 + assert "engine=" not in src # a Mustache body needs no engine named + assert 'verify=[PayloadField("name")]' in src # but it is still checked at render time def test_email_parts_may_mix_engines(tmp_path, monkeypatch) -> None: diff --git a/server/python/tests/codegen/test_render_helper_runtime_drift.py b/server/python/tests/codegen/test_render_helper_runtime_drift.py new file mode 100644 index 0000000000..71b563055e --- /dev/null +++ b/server/python/tests/codegen/test_render_helper_runtime_drift.py @@ -0,0 +1,41 @@ +"""Runtime drift (Python half): a template variable that is not on the payload raises. + +Shared corpus: ``fixtures/template-output-render-conformance/runtime-drift/``. The helpers are +generated against the clean ``templates/``; ``runtime-drift/templates/`` holds the same ref +with ``{{nmae}}`` for ``name``, standing in for text edited after codegen. The build-time gate +cannot see that, so the helper itself must raise at render time (``ERR_VAR_NOT_ON_PAYLOAD``) +instead of rendering ``Hello `` — for ``template.output`` and ``template.prompt``, Mustache. +""" +from __future__ import annotations + +import pytest + +from metaobjects.render.renderer import RenderError +from metaobjects.render.filesystem_provider import FilesystemProvider +from metaobjects.codegen.generators.render_helper_generator import RenderHelperGenerator +from importlib import import_module + +from tests.codegen.test_render_helper_conformance import CORPUS, _ctx, _load_root, _materialize_and_import, _models + +CLEAN = str(CORPUS / "templates") +DRIFTED = str(CORPUS / "runtime-drift" / "templates") + + +@pytest.mark.parametrize( + ("module", "fn"), + [ + ("welcome_page_render_helper", "render_welcome_page"), + ("welcome_prompt_render_helper", "render_welcome_prompt"), + ], +) +def test_helper_raises_on_variable_not_on_payload(tmp_path, module, fn) -> None: + root = _load_root(CORPUS / "runtime-drift" / "meta.json") + files = [f for f in RenderHelperGenerator(CLEAN).generate(_ctx(root)) if f.path == f"{module}.py"] + assert len(files) == 1 + _materialize_and_import([*files, *_models(root)], tmp_path) + helper = import_module(f"_rh_conf_pkg.{module}") + render = getattr(helper, fn) + + assert render({"name": "Ada"}, FilesystemProvider(CLEAN)) == "Hello Ada" + with pytest.raises(RenderError, match=r"ERR_VAR_NOT_ON_PAYLOAD \(nmae\)"): + render({"name": "Ada"}, FilesystemProvider(DRIFTED)) diff --git a/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts b/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts index e9b1fe3c59..e2fe13d307 100644 --- a/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts +++ b/server/typescript/packages/codegen-ts/src/generators/prompt-render-file.ts @@ -27,6 +27,7 @@ import { oncePerRun, } from "../generator.js"; import { templateSymbolBase } from "../naming.js"; +import { derivePayloadFieldTree, fieldTreeLiteral } from "../templates/render-helper.js"; import { valueObjectImport, valueObjectImportLines, type ValueObjectImport } from "../templates/value-object-import.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "../constants.js"; @@ -40,15 +41,18 @@ export interface PromptRenderOpts { const RENDER_IMPORT = `import { render, type Provider } from "@metaobjectsdev/render";`; /** One template.prompt's typed render handle, typed by its payload's own interface. */ -function renderHandle(tmpl: MetaData, payloadType: string): string { +function renderHandle(tmpl: MetaData, payloadType: string, payloadVo: MetaData): string { // ADR-0039: resolving — a template may inherit its @* refs/format via extends. const textRef = tmpl.attr(TEMPLATE_ATTR_TEXT_REF); const format = (tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text"; // One base for every symbol a template emits, shared with the parser and render helper. const fn = `render${templateSymbolBase(tmpl.name)}`; + // The payload field tree is baked in so render() checks the template it resolves at RUN time + // against it: `{{nmae}}` for `name` raises ERR_VAR_NOT_ON_PAYLOAD instead of rendering empty. + const verify = fieldTreeLiteral(derivePayloadFieldTree(payloadVo)); return [ `export function ${fn}(payload: ${payloadType}, provider: Provider): string {`, - ` return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider });`, + ` return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider, verify: ${verify} });`, `}`, ].join("\n"); } @@ -71,7 +75,7 @@ export const promptRender = function promptRender(opts?: PromptRenderOpts): Gene if (vo === undefined) continue; const imp = valueObjectImport(ctx.renderContext, vo, outFile, ctx.config.extStyle ?? "js"); imports.push(imp); - handles.push(renderHandle(t, imp.name)); + handles.push(renderHandle(t, imp.name, vo)); } if (handles.length === 0) return []; diff --git a/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts b/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts index e2e0f1ce19..ad6e0b6f85 100644 --- a/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts +++ b/server/typescript/packages/codegen-ts/src/reference/prompt-render.ts @@ -44,6 +44,7 @@ import { GENERATED_HEADER, GENERATED_EDIT_NOTE, } from "@metaobjectsdev/codegen-ts/engine"; +import { derivePayloadFieldTree, fieldTreeLiteral } from "../templates/render-helper.js"; import { valueObjectImport, valueObjectImportLines, @@ -60,14 +61,17 @@ export interface PromptRenderOpts { const RENDER_IMPORT = `import { render, type Provider } from "@metaobjectsdev/render";`; // --- composition (OWNED) — one typed render handle per template.prompt. --- -function renderHandle(tmpl: MetaData, payloadType: string): string { +function renderHandle(tmpl: MetaData, payloadType: string, payloadVo: MetaData): string { // Resolving accessors (ADR-0039): a template may inherit its refs/format via extends. const textRef = tmpl.attr(TEMPLATE_ATTR_TEXT_REF); const format = (tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text"; const fn = `render${templateSymbolBase(tmpl.name)}`; + // The payload field tree is baked in so render() checks the template it resolves at RUN time + // against it: `{{nmae}}` for `name` raises ERR_VAR_NOT_ON_PAYLOAD instead of rendering empty. + const verify = fieldTreeLiteral(derivePayloadFieldTree(payloadVo)); return [ `export function ${fn}(payload: ${payloadType}, provider: Provider): string {`, - ` return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider });`, + ` return render({ ref: ${JSON.stringify(textRef)}, payload, format: ${JSON.stringify(format)}, provider, verify: ${verify} });`, "}", ].join("\n"); } @@ -88,7 +92,7 @@ export const promptRender = function promptRender(opts?: PromptRenderOpts): Gene if (vo === undefined) continue; const imp = valueObjectImport(ctx.renderContext, vo, outFile, ctx.config.extStyle ?? "js"); imports.push(imp); - handles.push(renderHandle(t, imp.name)); + handles.push(renderHandle(t, imp.name, vo)); } if (handles.length === 0) return []; 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 bfa8abc344..c6056d15df 100644 --- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts @@ -90,7 +90,7 @@ import type { ${payloadTypeName} } from ${JSON.stringify(payloadSpecifier)};`; * the same small walk lives here against the metadata constants codegen-ts * already imports. */ -function derivePayloadFieldTree( +export function derivePayloadFieldTree( vo: MetaData | undefined, seen: ReadonlySet = new Set(), ): PayloadField[] { @@ -112,7 +112,7 @@ function derivePayloadFieldTree( /** Serialize a PayloadField[] as a stable, deterministic TS array literal so the * emitted render() call runs the same runtime drift check the build gate ran. */ -function fieldTreeLiteral(fields: PayloadField[]): string { +export function fieldTreeLiteral(fields: PayloadField[]): string { // JSON.stringify is deterministic for this shape (no functions/cycles by // construction) and produces valid TS object/array literal syntax. return JSON.stringify(fields); diff --git a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts index 72942cfbce..164cf6c9cb 100644 --- a/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts +++ b/server/typescript/packages/codegen-ts/test/fr004-verify-demo.test.ts @@ -78,6 +78,7 @@ const RENDER_STUB = `declare module "@metaobjectsdev/render" { export interface Provider { resolve(ref: string): string | undefined; } export function render(o: { ref?: string; template?: string; payload: unknown; provider: Provider; format?: string; + verify?: ReadonlyArray<{ name: string; fields?: unknown; isArray?: boolean }>; }): string; } `; diff --git a/server/typescript/packages/codegen-ts/test/runtime-drift.test.ts b/server/typescript/packages/codegen-ts/test/runtime-drift.test.ts new file mode 100644 index 0000000000..ef1bde8e95 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/runtime-drift.test.ts @@ -0,0 +1,70 @@ +// Runtime drift: a template that references a variable the payload does not declare +// (`{{nmae}}` for `name`) must RAISE at render time, never render the variable as empty. +// +// The build-time gate cannot see this case: the helper is generated against a clean template, +// and the template text is then edited (or served by a different provider) afterwards. The +// shared corpus is fixtures/template-output-render-conformance/runtime-drift/ — its `templates/` +// holds the drifted text; the clean text the helpers are generated from is the parent corpus's +// `templates/`. +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { FileSystemProvider } from "../src/render-engine/framework-provider.js"; +import { renderRenderHelper } from "../src/templates/render-helper.js"; +import { promptRender } from "../src/generators/prompt-render-file.js"; + +const CORPUS = resolve(import.meta.dir, "../../../../../fixtures/template-output-render-conformance"); +const TEMP_DIRS: string[] = []; +afterAll(() => { + for (const d of TEMP_DIRS) rmSync(d, { recursive: true, force: true }); +}); + +async function load() { + const json = readFileSync(join(CORPUS, "runtime-drift", "meta.json"), "utf-8"); + const res = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + expect(res.errors).toEqual([]); + return res.root; +} + +const clean = () => new FileSystemProvider(join(CORPUS, "templates")); +const drifted = () => new FileSystemProvider(join(CORPUS, "runtime-drift", "templates")); +const PAYLOADS = "export interface Welcome { name: string; }\n"; + +describe("runtime drift — a variable not on the payload raises at render time", () => { + test("render-helper (template.output)", async () => { + const root = await load(); + const src = renderRenderHelper(root, "WelcomePage", clean()); + const dir = mkdtempSync(join(import.meta.dir, "rd-helper-")); + TEMP_DIRS.push(dir); + writeFileSync(join(dir, "payloads.ts"), PAYLOADS); + writeFileSync(join(dir, "WelcomePage.render.ts"), src); + const mod = await import(join(dir, "WelcomePage.render.ts")); + expect(mod.renderWelcomePage({ name: "Ada" }, clean())).toBe("Hello Ada"); + expect(() => mod.renderWelcomePage({ name: "Ada" }, drifted())).toThrow(/ERR_VAR_NOT_ON_PAYLOAD \(nmae\)/); + }); + + test("promptRender() handle (template.prompt)", async () => { + const root = await load(); + const gen = promptRender(); + const out = await gen.generate({ + entities: root.objects(), + loadedRoot: root, + matches: () => true, + projectRoot: "/tmp", + config: { outDir: "/tmp", extStyle: "js", dbImport: "~/db", dialect: "sqlite" } as never, + renderContext: undefined as never, + warn: () => {}, + } as never); + expect(out).toHaveLength(1); + const dir = mkdtempSync(join(import.meta.dir, "rd-prompt-")); + TEMP_DIRS.push(dir); + // The handle imports the payload from the value object's own module. + const imp = /from "(\.[^"]+)"/.exec(out[0]!.content)?.[1] ?? "./Welcome.js"; + writeFileSync(join(dir, imp.replace(/\.js$/, "") + ".ts"), PAYLOADS); + writeFileSync(join(dir, "prompts.ts"), out[0]!.content); + const mod = await import(join(dir, "prompts.ts")); + expect(mod.renderWelcomePrompt({ name: "Ada" }, clean())).toBe("Hello Ada"); + expect(() => mod.renderWelcomePrompt({ name: "Ada" }, drifted())).toThrow(/ERR_VAR_NOT_ON_PAYLOAD \(nmae\)/); + }); +});