Skip to content

Commit ef11989

Browse files
dmealingclaude
andcommitted
merge: prompt snapshot harness — meta prompt-snapshot [--check] [FR-004]
TS-first #4: deterministic rendered-prompt goldens under .metaobjects/snapshots/, with a --check CI drift gate. Spec + plan + command + helpers + tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 parents 4ac9d07 + 4889ecf commit ef11989

10 files changed

Lines changed: 1352 additions & 0 deletions

File tree

docs/superpowers/plans/2026-05-23-prompt-snapshot-harness.md

Lines changed: 707 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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).
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// `meta prompt-snapshot` — deterministic rendered-prompt goldens (FR-004 #4).
2+
//
3+
// For each template.* node with a committed fixture payload, render its @textRef
4+
// text against that payload (same engine, provider, and @format escaping prod
5+
// uses) and snapshot the byte-exact output under .metaobjects/snapshots/<name>/.
6+
// Write mode (default) overwrites output.snap; --check compares against the
7+
// committed golden and exits 1 on drift (never writes). Closes the gap the
8+
// template's own git history misses: a shared partial or payload-shape change
9+
// that silently alters the rendered prompt.
10+
11+
import { join } from "node:path";
12+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
13+
import { parsePromptSnapshotArgs } from "../lib/args.js";
14+
import { log } from "../lib/log.js";
15+
import { FileProvider } from "../lib/file-provider.js";
16+
import { snapshotPaths, unifiedDiff } from "../lib/snapshot.js";
17+
import { loadMemory } from "@metaobjectsdev/sdk";
18+
import { TYPE_TEMPLATE, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT } from "@metaobjectsdev/metadata";
19+
import { render, ESCAPERS, type RenderFormat } from "@metaobjectsdev/render";
20+
21+
const DEFAULT_PROMPTS_DIR = "prompts";
22+
23+
export async function promptSnapshotCommand(args: string[], cwd: string): Promise<number> {
24+
let flags;
25+
try {
26+
flags = parsePromptSnapshotArgs(args);
27+
} catch (err) {
28+
log.error((err as Error).message);
29+
return 2;
30+
}
31+
32+
let root;
33+
try {
34+
root = await loadMemory(cwd);
35+
} catch (err) {
36+
const msg = (err as Error).message;
37+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
38+
log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
39+
return 2;
40+
}
41+
log.error(`failed to load metadata: ${msg}`);
42+
return 1;
43+
}
44+
45+
const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
46+
const provider = new FileProvider(promptsDir);
47+
48+
const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
49+
if (templates.length === 0) {
50+
log.info("meta prompt-snapshot — no template.* nodes found; nothing to snapshot.");
51+
return 0;
52+
}
53+
54+
let errorCount = 0;
55+
let driftCount = 0;
56+
let wrote = 0;
57+
let checked = 0;
58+
let skipped = 0;
59+
60+
for (const tmpl of templates) {
61+
const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
62+
// Absent/typeless required attrs are a loader-schema concern, not ours.
63+
if (typeof textRef !== "string") continue;
64+
65+
const { dir, payloadPath, snapPath } = snapshotPaths(cwd, tmpl.name);
66+
if (!existsSync(payloadPath)) {
67+
log.info(`[${tmpl.name}] skipped — no payload at ${payloadPath}`);
68+
skipped++;
69+
continue;
70+
}
71+
72+
let payload: unknown;
73+
try {
74+
payload = JSON.parse(readFileSync(payloadPath, "utf8"));
75+
} catch (err) {
76+
log.error(`[${tmpl.name}] invalid payload.json: ${(err as Error).message}`);
77+
errorCount++;
78+
continue;
79+
}
80+
81+
// @format is loader-validated against the template format vocabulary; narrow
82+
// against the render engine's own escaper registry so the RenderFormat cast is
83+
// a checked narrowing (never a TypeError on an unknown format), and omit the
84+
// key entirely when absent (exactOptionalPropertyTypes forbids `format: undefined`).
85+
const fmtAttr = tmpl.ownAttr(TEMPLATE_ATTR_FORMAT);
86+
const format =
87+
typeof fmtAttr === "string" && fmtAttr in ESCAPERS ? (fmtAttr as RenderFormat) : undefined;
88+
89+
let rendered: string;
90+
try {
91+
rendered = render({ ref: textRef, payload, provider, ...(format ? { format } : {}) });
92+
} catch (err) {
93+
log.error(`[${tmpl.name}] render failed: ${(err as Error).message}`);
94+
errorCount++;
95+
continue;
96+
}
97+
98+
if (flags.check) {
99+
checked++;
100+
if (!existsSync(snapPath)) {
101+
log.error(
102+
`[${tmpl.name}] no committed snapshot at ${snapPath}; run 'meta prompt-snapshot' to create it`,
103+
);
104+
driftCount++;
105+
continue;
106+
}
107+
const golden = readFileSync(snapPath, "utf8");
108+
if (golden !== rendered) {
109+
log.error(`[${tmpl.name}] snapshot drift:\n${unifiedDiff(golden, rendered)}`);
110+
log.error(`[${tmpl.name}] run 'meta prompt-snapshot' to accept the change`);
111+
driftCount++;
112+
}
113+
} else {
114+
mkdirSync(dir, { recursive: true });
115+
writeFileSync(snapPath, rendered, "utf8");
116+
log.info(`[${tmpl.name}] wrote ${snapPath}`);
117+
wrote++;
118+
}
119+
}
120+
121+
if (flags.check) {
122+
if (errorCount > 0 || driftCount > 0) {
123+
log.error(
124+
`meta prompt-snapshot --check — ${driftCount} drifted, ${errorCount} error(s) across ${checked} checked.`,
125+
);
126+
return 1;
127+
}
128+
log.info(
129+
`meta prompt-snapshot --check — ${checked} snapshot(s) clean${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
130+
);
131+
return 0;
132+
}
133+
134+
if (errorCount > 0) {
135+
log.error(`meta prompt-snapshot — ${errorCount} error(s); ${wrote} snapshot(s) written.`);
136+
return 1;
137+
}
138+
log.info(
139+
`meta prompt-snapshot — ${wrote} snapshot(s) written${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
140+
);
141+
return 0;
142+
}

server/typescript/packages/cli/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ COMMANDS:
4040
gen [<entity>...] Codegen TS targets from metaobjects/ entities
4141
export Flatten loaded metadata to one canonical JSON artifact
4242
verify Check template.* text against its payload (drift gate)
43+
prompt-snapshot Snapshot rendered template.* output; --check gates drift
4344
migrate Diff metadata vs live DB; emit migration SQL files
4445
--version, -v Print version
4546
--help, -h Print this help
@@ -58,6 +59,10 @@ EXPORT FLAGS:
5859
VERIFY FLAGS:
5960
--prompts <dir> Directory of provider-resolved template text (default: prompts)
6061
62+
PROMPT-SNAPSHOT FLAGS:
63+
--check Compare against committed snapshots; exit 1 on drift (CI gate)
64+
--prompts <dir> Directory of provider-resolved template text (default: prompts)
65+
6166
MIGRATE FLAGS:
6267
--db <url> DB connection URL (required, or set DATABASE_URL or config)
6368
Supports: file:, libsql:, postgres:, postgresql:
@@ -129,6 +134,10 @@ export async function run(argv: string[]): Promise<number> {
129134
const { verifyCommand } = await import("./commands/verify.js");
130135
return verifyCommand(rest, cwd);
131136
}
137+
case "prompt-snapshot": {
138+
const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
139+
return promptSnapshotCommand(rest, cwd);
140+
}
132141
case "migrate": {
133142
const { migrateCommand } = await import("./commands/migrate.js");
134143
return migrateCommand(rest, cwd);

server/typescript/packages/cli/src/lib/args.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,33 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
100100
};
101101
}
102102

103+
// ---------------------------------------------------------------------------
104+
// prompt-snapshot flags
105+
// ---------------------------------------------------------------------------
106+
107+
export interface PromptSnapshotFlags {
108+
/** Compare against committed snapshots and fail on drift; never write. */
109+
check: boolean;
110+
/** Directory (relative to cwd) holding provider-resolved template text. */
111+
prompts: string | undefined;
112+
}
113+
114+
export function parsePromptSnapshotArgs(argv: string[]): PromptSnapshotFlags {
115+
const { values } = parseArgs({
116+
args: argv,
117+
options: {
118+
check: { type: "boolean", default: false },
119+
prompts: { type: "string" },
120+
},
121+
strict: true,
122+
allowPositionals: false,
123+
});
124+
return {
125+
check: !!values.check,
126+
prompts: values.prompts,
127+
};
128+
}
129+
103130
// ---------------------------------------------------------------------------
104131
// migrate flags
105132
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)