A zero-cost gate on every PR¶
The problem. Prompts and templates rot silently. A variable gets renamed and stops substituting; a section separator leaks into the output; a refactor drops the authorization framing. None of it is caught by a type checker, and none of it is worth an LLM bill to catch.
The shape. A suite that renders every template your system owns and grades the result with deterministic assertions only. No API key, no network, seconds to run — so it can be a required check on every pull request rather than a nightly job people learn to ignore.
1. Make your system renderable¶
The system under test runs in "render" mode: given a template id, produce the rendered text and print it. That is an exec provider — one JSON request in, one JSON response out.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# A render-health suite: it exercises an external "system under test" (here
# ../echo-provider.py) and grades its output with deterministic, zero-LLM
# assertions. No API key and no toolchain required — just python3.
version: 1
project: examples
suite: render-health
providers:
- id: renderer
type: exec
command: ["python3", "../echo-provider.py"]
timeout_ms: 30000
# A rebuilt binary should not serve stale cache; bump this to a git SHA / hash.
cache_salt: "dev"
tests:
# A literal SSTI payload that must NEVER be interpolated: !raw keeps it verbatim.
- id: adversarial/ssti-literal
tags: [adversarial]
vars:
user_input: !raw "{{7*7}} {% for x in range(9) %}x{% endfor %}"
assert:
- type: not-contains
value: "49"
- id: greeting/basic
tags: [smoke]
vars:
user_input: "hello world"
# A deterministic template filter in action: slugify normalizes free text
# into a URL/id-safe token ("Hello, World!" -> "hello-world").
slug: "{{ 'Hello, World!' | slugify }}"
assert:
- type: contains
value: "hello"
- type: length
max: 200000
defaults:
# A deterministic guard applied to every case: the rendered output must never
# be unreasonably large (a cheap catch for a runaway template).
assert:
- type: length
max: 100000
Make the salt move with the build
Rendering is cheap, and a stale render is worse than a re-render — but omitting cache_salt does not get you that. The key hashes what the provider sends, never the bytes of the program sending it, so a rebuilt renderer replays yesterday's output and the gate passes on a template it never rendered.
Pin the salt to something that moves with the build — cache_salt: "${env:GITHUB_SHA}" in CI — or run this one gate with --no-cache. It costs seconds. See caching.md.
2. Assert the structural invariants¶
These are the ones worth having, in rough order of how often they catch something:
| Assertion | Catches |
|---|---|
not-regex: "\\{\\{.*\\}\\}" |
An unsubstituted variable — a render hole. |
not-contains on your separator |
Internal metadata or a user/system delimiter leaking through. |
length: {min: 1} |
An empty render, usually a missing template. |
length: {max: N} |
A runaway loop, before it becomes a token bill. |
contains on required framing |
A refactor dropping a policy or authorization line. |
Put the size ceiling in defaults.assert so it applies to every case without repetition.
3. Make coverage automatic¶
Listing templates by hand guarantees that the one added last Tuesday has no test. A generator enumerates your registry instead, so a new template gets a render test the day it lands:
Back it with a unit test in your own codebase asserting the registry and the manifest agree. Otherwise a template can escape the enumeration and the coverage gap is invisible.
4. Wire it into CI¶
That is the whole step. Exit 0 passes, 1 means an assertion failed, 3 means the harness broke. No secrets, so it runs on fork pull requests too — which is exactly where you want a cheap check.
Verify it actually gates¶
A gate nobody has seen fail is a gate nobody should trust. Break something on purpose:
$ # temporarily remove a variable from a template, then:
$ domarinn run eval/render-health.yaml
$ echo $?
1
If that prints 0, your assertions are not asserting. Example 18 is a suite that is red by design, kept green-in-CI precisely so the failure path stays exercised.
See also¶
- Example 12 — the suite above, runnable.
- Example 11 — generators in detail.
- Guide 06 — the graded layer that runs beside this one.