|
| 1 | +# Prompt snapshot harness: deterministic byte-snapshot of rendered prompts |
| 2 | + |
| 3 | +**Status:** Design / plan-of-record. Not yet implemented. |
| 4 | +**Depends on:** the shipped FR-004 render engine + `meta verify` CLI + `FileProvider`; and the |
| 5 | +output-tag/`@requiredTags` slice (`docs/superpowers/specs/2026-05-23-prompt-verify-extensions-design.md`). |
| 6 | +**Roadmap:** this is feature **#4** from the prompt-verify-extensions doc ("Prompt snapshot harness"), |
| 7 | +broken out into its own first slice. |
| 8 | + |
| 9 | +## Why (the gap the template's own history does not cover) |
| 10 | + |
| 11 | +A rendered prompt is a **composition**: `template text + provider-resolved partials + payload shape + |
| 12 | +@format escaping`. The git history of any single `.mustache` file shows only changes to *that* file — |
| 13 | +it is blind to the two compositional inputs that most often change the prompt a model actually receives: |
| 14 | + |
| 15 | +- **Shared partials.** When many prompts include a common preamble/rules partial |
| 16 | + (`{{> shared/system-preamble}}`), an edit to that partial changes the rendered output of *every* |
| 17 | + consuming prompt — while the consuming prompts' own files (and histories) show nothing. If that |
| 18 | + preamble is the prompt-cache prefix, the edit silently busts caching across all of them. |
| 19 | +- **Payload / projection shape.** The prompt depends on the shape of the view-object it renders |
| 20 | + against. When that projection gains a field (or an array starts coming back populated), the rendered |
| 21 | + prompt grows while the `.mustache` file is untouched. |
| 22 | + |
| 23 | +For a **standalone single-file template** with no partials and no payload-shape dependence, `git log -p` |
| 24 | +on that file is genuinely most of the value, and this feature adds little beyond being a *pre-merge gate* |
| 25 | +rather than after-the-fact forensics. The feature earns its keep precisely when prompts are **composed** |
| 26 | +(shared partials and/or evolving projections) — the common case for a downstream consumer that assembles |
| 27 | +many prompts from a shared preamble. |
| 28 | + |
| 29 | +What this buys, concretely: |
| 30 | + |
| 31 | +- **Cache-stability tripwire.** A whitespace/reordering change anywhere in the composition (including a |
| 32 | + shared partial) that would silently break exact-prefix prompt-cache hits becomes a reviewable diff and |
| 33 | + a CI failure, instead of a cost/latency regression discovered days later on a dashboard. |
| 34 | +- **Payload-bloat-as-diff.** A projection change that enlarges the prompt shows up as added lines in the |
| 35 | + snapshot diff — invisible token creep becomes a reviewable line-count change. |
| 36 | +- **"Exactly what prod ships."** The golden is the final rendered text (partials expanded, escaping |
| 37 | + applied), so a reviewer reads what the model receives instead of mentally rendering the source. |
| 38 | + |
| 39 | +**Relationship to the `@requiredTags` slice (#2).** Complementary. `@requiredTags` is the cheap, |
| 40 | +always-on static guard ("the rendered text still contains `<answer>`/`</answer>`"); the snapshot is the |
| 41 | +full-fidelity guard ("here is the byte-exact diff of everything the model receives"). #2 needs no fixture; |
| 42 | +#4 costs one representative payload per prompt and in return makes the entire rendered prompt a |
| 43 | +checked-in artifact that diffs like code. |
| 44 | + |
| 45 | +## Scope (first slice) |
| 46 | + |
| 47 | +Resolved during brainstorming: |
| 48 | + |
| 49 | +- **TS-only, reference-first.** Build the CLI command + the on-disk convention in TypeScript and prove |
| 50 | + it. The convention is deliberately language-neutral so a C# port adopts it identically as a later |
| 51 | + slice. Python and Java are out (neither has a prompt-side `render()` yet). |
| 52 | +- **Incremental adoption.** A template with no committed fixture payload is skipped (with a notice), so a |
| 53 | + project snapshots only the prompts it has authored a payload for. |
| 54 | +- **No new library helper.** The existing `render()` already returns the deterministic string a consumer |
| 55 | + can fold into their own test runner's snapshot matcher; a dedicated `snapshotPrompt()` would be |
| 56 | + near-identical sugar. Ship the CLI command; document `render()` for the in-test path. |
| 57 | +- **Out of scope:** semantic / LLM-as-judge eval (external tools); `@maxTokens` (model-specific |
| 58 | + tokenizer); multiple payload cases per template (the layout leaves room — see below); the C#/Python/Java |
| 59 | + ports. |
| 60 | + |
| 61 | +## Design |
| 62 | + |
| 63 | +### CLI command |
| 64 | + |
| 65 | +A new `meta` subcommand mirroring `meta verify`: |
| 66 | + |
| 67 | +``` |
| 68 | +meta prompt-snapshot [--check] [--prompts <dir>] [--cwd <dir>] |
| 69 | +``` |
| 70 | + |
| 71 | +It reuses `meta verify`'s machinery: load metadata (`loadMemory`), build a `FileProvider` over the |
| 72 | +prompts dir (default `prompts/`, overridable with `--prompts`), and iterate the root's own `template.*` |
| 73 | +children, reading each node's `@textRef` and `@format`. |
| 74 | + |
| 75 | +Two modes: |
| 76 | + |
| 77 | +- **Write mode (default, no `--check`).** For each template that has a committed `payload.json`, render |
| 78 | + the prompt and **write/overwrite** its `output.snap`. Templates without a `payload.json` are skipped |
| 79 | + with a notice. Human-driven: you run this when you author a new prompt or intentionally change one, |
| 80 | + review the resulting `git diff`, and commit the updated golden alongside the change. Exit 0 (unless a |
| 81 | + load error, unresolved `@textRef`, or render error occurs). |
| 82 | +- **Check mode (`--check`).** For each template that has a committed `payload.json`, render and **diff** |
| 83 | + against the committed `output.snap`. Never writes. This is the CI gate. |
| 84 | + |
| 85 | +### On-disk layout (committed) |
| 86 | + |
| 87 | +``` |
| 88 | +.metaobjects/ |
| 89 | + config.json # committed (existing) |
| 90 | + .gen-state/ # gitignored (existing) |
| 91 | + snapshots/ # COMMITTED — golden snapshots |
| 92 | + <TemplateName>/ |
| 93 | + payload.json # the fixture input (author-owned) |
| 94 | + output.snap # the golden rendered output (tool-managed, byte-exact) |
| 95 | +``` |
| 96 | + |
| 97 | +`.metaobjects/` is the tool-owned directory; only `.gen-state/` is gitignored, so `snapshots/` is |
| 98 | +committed by default (consistent with `config.json`). The path is language-neutral (no JS-world |
| 99 | +`__snapshots__/` convention), so the identical layout is portable to a future C# port. The binding key is |
| 100 | +the template node's `name` (matching how `meta verify` already reports `[tmpl.name]`); this is |
| 101 | +collision-safe because the loader already rejects same-name siblings (`ERR_DUPLICATE_NAME`), and |
| 102 | +templates are siblings under `metadata.root`. The per-template |
| 103 | +**subdirectory** groups input + golden and leaves room to add multiple payload cases later |
| 104 | +(`<TemplateName>/<case>.payload.json` + `<case>.snap`) without a rename; this first slice writes exactly |
| 105 | +one `payload.json` + one `output.snap` per template. |
| 106 | + |
| 107 | +### Rendering |
| 108 | + |
| 109 | +For each in-scope template: |
| 110 | + |
| 111 | +1. Resolve `@textRef` to template text via the `FileProvider`. If it does not resolve → error (exactly as |
| 112 | + `meta verify` treats an unresolved `@textRef`). |
| 113 | +2. Read `.metaobjects/snapshots/<name>/payload.json`. If absent → skip with a notice. |
| 114 | +3. Render: `render({ ref: <@textRef>, payload, provider, format: <@format> })`. This goes through the |
| 115 | + full partial expansion + escaper pipeline, identical to production. The render engine's `maxChars` |
| 116 | + budget guard is **not** applied here — a snapshot records the raw rendered output; budget enforcement |
| 117 | + is a separate runtime concern. |
| 118 | + |
| 119 | +The rendered string is the golden. Determinism is inherent: `render()` is logic-less and the |
| 120 | +`FileProvider` is deterministic, so the same `payload.json` + template text + partials yield identical |
| 121 | +bytes every run. |
| 122 | + |
| 123 | +### Snapshot format |
| 124 | + |
| 125 | +`output.snap` is the **raw rendered string, byte-exact**, written verbatim — no `@generated` header or |
| 126 | +other decoration, because the file's purpose is to equal what prod ships. The tool owns the file |
| 127 | +(humans never hand-edit it), so byte-exactness (including any absence of a trailing newline) is safe. |
| 128 | +`--check` compares the freshly rendered string to the committed bytes exactly; no normalization, since |
| 129 | +normalization would mask the very whitespace drift the feature exists to catch. |
| 130 | + |
| 131 | +### Check-mode failure conditions and diff output |
| 132 | + |
| 133 | +`--check` exits 1 if, for any template that has a `payload.json`: |
| 134 | + |
| 135 | +- the rendered output differs from the committed `output.snap` (byte drift), or |
| 136 | +- no `output.snap` is committed yet (a payload exists but its golden was never written). |
| 137 | + |
| 138 | +On failure it prints, per offending template, the template name and a compact line-diff of |
| 139 | +golden-vs-rendered, plus a one-line hint to run `meta prompt-snapshot` (write mode) to accept the change. |
| 140 | +A clean run prints a one-line summary and exits 0. |
| 141 | + |
| 142 | +### Exit codes (mirroring `meta verify`) |
| 143 | + |
| 144 | +- No `metaobjects/` found → 2. |
| 145 | +- Unresolved `@textRef`, a render error, or (in `--check`) any drift / missing golden → 1. |
| 146 | +- Clean → 0. |
| 147 | + |
| 148 | +### Relationship to `meta verify` |
| 149 | + |
| 150 | +Independent and complementary commands; neither invokes the other. `verify` is the static field-drift |
| 151 | +gate (template variable ↔ payload field tree); `prompt-snapshot` is the dynamic rendered-output gate. A |
| 152 | +project runs both in CI. |
| 153 | + |
| 154 | +## Cross-language strategy |
| 155 | + |
| 156 | +TS-first, mirroring how the `@requiredTags` slice was delivered. The on-disk layout and command behavior |
| 157 | +are the language-neutral contract; a later C# slice implements the identical command over the identical |
| 158 | +layout. Because rendered output is already byte-identical across ports (guaranteed by the |
| 159 | +`fixtures/render-conformance` corpus), the **same committed `output.snap` goldens are checkable by either |
| 160 | +the TS or the C# `meta` CLI**, yielding the same verdict — the polyglot-portability payoff. Python and |
| 161 | +Java are gated on first having a prompt-side `render()` (Python has only `verify` today; Java has no |
| 162 | +render/verify tier), exactly as they are for the rest of FR-004. |
| 163 | + |
| 164 | +## Testing (TDD) |
| 165 | + |
| 166 | +Integration tests mirroring `cli/test/integration/verify.test.ts`, against a scaffolded temp project |
| 167 | +(metaobjects + a prompts dir + `.metaobjects/snapshots/`): |
| 168 | + |
| 169 | +- write mode renders a template that has a `payload.json` and creates its `output.snap`; |
| 170 | +- `--check` passes clean immediately after a write; |
| 171 | +- mutating the template text (or a shared partial) makes `--check` exit 1 and report the drift diff; |
| 172 | +- a template without a `payload.json` is skipped (no golden written, no failure); |
| 173 | +- `--check` against a payload-having template with no committed `output.snap` exits 1; |
| 174 | +- missing `metaobjects/` exits 2; an unresolved `@textRef` exits 1. |
| 175 | + |
| 176 | +Plus a focused test that a shared-partial edit is caught (the load-bearing case the template's own |
| 177 | +history misses). |
| 178 | + |
| 179 | +## Open questions / future slices |
| 180 | + |
| 181 | +- **C# port** — implement the identical command over the identical layout; share the goldens. |
| 182 | +- **Multiple payload cases per template** — the `<TemplateName>/` subdir already supports it; add when a |
| 183 | + consumer needs representative "empty / typical / large" payloads for one prompt. |
| 184 | +- **Skeleton-payload scaffolding** — optionally, write mode could emit a placeholder `payload.json` |
| 185 | + derived from the `@payloadRef` field tree on first run, for the author to fill in. Deferred (adds |
| 186 | + scope; authors can write the fixture by hand). |
0 commit comments