Skip to content

Commit a187cbe

Browse files
dmealingclaude
andcommitted
feat(cli): meta prompt-snapshot write mode + dispatcher [FR-004]
Adds `meta prompt-snapshot` command: for each template.* node with a committed payload.json fixture, renders the @textRef text and writes the byte-exact output to .metaobjects/snapshots/<name>/output.snap. Skips templates without a payload, exits 1 on render errors, exits 2 on missing metaobjects/. Help text and help-output snapshot updated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3f0adc5 commit a187cbe

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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 (a later task) diffs and
7+
// fails on drift. Closes the gap the template's own git history misses: a shared
8+
// partial or payload-shape change that silently alters the rendered prompt.
9+
10+
import { join } from "node:path";
11+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
12+
import { parsePromptSnapshotArgs } from "../lib/args.js";
13+
import { log } from "../lib/log.js";
14+
import { FileProvider } from "../lib/file-provider.js";
15+
import { snapshotPaths } from "../lib/snapshot.js";
16+
import { loadMemory } from "@metaobjectsdev/sdk";
17+
import { TYPE_TEMPLATE, TEMPLATE_ATTR_TEXT_REF, TEMPLATE_ATTR_FORMAT } from "@metaobjectsdev/metadata";
18+
import { render, type RenderFormat } from "@metaobjectsdev/render";
19+
20+
const DEFAULT_PROMPTS_DIR = "prompts";
21+
22+
export async function promptSnapshotCommand(args: string[], cwd: string): Promise<number> {
23+
let flags;
24+
try {
25+
flags = parsePromptSnapshotArgs(args);
26+
} catch (err) {
27+
log.error((err as Error).message);
28+
return 2;
29+
}
30+
31+
let root;
32+
try {
33+
root = await loadMemory(cwd);
34+
} catch (err) {
35+
const msg = (err as Error).message;
36+
if (msg.includes("ENOENT") || msg.includes("no such") || msg.includes("cannot read")) {
37+
log.error(`no metaobjects/ found in ${cwd}; run 'meta init' to scaffold`);
38+
return 2;
39+
}
40+
log.error(`failed to load metadata: ${msg}`);
41+
return 1;
42+
}
43+
44+
const promptsDir = join(cwd, flags.prompts ?? DEFAULT_PROMPTS_DIR);
45+
const provider = new FileProvider(promptsDir);
46+
47+
const templates = root.ownChildren().filter((c) => c.type === TYPE_TEMPLATE);
48+
if (templates.length === 0) {
49+
log.info("meta prompt-snapshot — no template.* nodes found; nothing to snapshot.");
50+
return 0;
51+
}
52+
53+
let errorCount = 0;
54+
let wrote = 0;
55+
let skipped = 0;
56+
57+
for (const tmpl of templates) {
58+
const textRef = tmpl.ownAttr(TEMPLATE_ATTR_TEXT_REF);
59+
// Absent/typeless required attrs are a loader-schema concern, not ours.
60+
if (typeof textRef !== "string") continue;
61+
62+
const { dir, payloadPath, snapPath } = snapshotPaths(cwd, tmpl.name);
63+
if (!existsSync(payloadPath)) {
64+
log.info(`[${tmpl.name}] skipped — no payload at ${payloadPath}`);
65+
skipped++;
66+
continue;
67+
}
68+
69+
let payload: unknown;
70+
try {
71+
payload = JSON.parse(readFileSync(payloadPath, "utf8"));
72+
} catch (err) {
73+
log.error(`[${tmpl.name}] invalid payload.json: ${(err as Error).message}`);
74+
errorCount++;
75+
continue;
76+
}
77+
78+
const fmtAttr = tmpl.ownAttr(TEMPLATE_ATTR_FORMAT);
79+
const format = typeof fmtAttr === "string" ? (fmtAttr as RenderFormat) : undefined;
80+
81+
let rendered: string;
82+
try {
83+
rendered = render({ ref: textRef, payload, provider, ...(format ? { format } : {}) });
84+
} catch (err) {
85+
log.error(`[${tmpl.name}] render failed: ${(err as Error).message}`);
86+
errorCount++;
87+
continue;
88+
}
89+
90+
mkdirSync(dir, { recursive: true });
91+
writeFileSync(snapPath, rendered, "utf8");
92+
log.info(`[${tmpl.name}] wrote ${snapPath}`);
93+
wrote++;
94+
}
95+
96+
if (errorCount > 0) {
97+
log.error(`meta prompt-snapshot — ${errorCount} error(s); ${wrote} snapshot(s) written.`);
98+
return 1;
99+
}
100+
log.info(
101+
`meta prompt-snapshot — ${wrote} snapshot(s) written${skipped > 0 ? `, ${skipped} skipped` : ""}.`,
102+
);
103+
return 0;
104+
}

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/test/__snapshots__/cli.test.ts.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ COMMANDS:
1212
gen [<entity>...] Codegen TS targets from metaobjects/ entities
1313
export Flatten loaded metadata to one canonical JSON artifact
1414
verify Check template.* text against its payload (drift gate)
15+
prompt-snapshot Snapshot rendered template.* output; --check gates drift
1516
migrate Diff metadata vs live DB; emit migration SQL files
1617
--version, -v Print version
1718
--help, -h Print this help
@@ -30,6 +31,10 @@ EXPORT FLAGS:
3031
VERIFY FLAGS:
3132
--prompts <dir> Directory of provider-resolved template text (default: prompts)
3233
34+
PROMPT-SNAPSHOT FLAGS:
35+
--check Compare against committed snapshots; exit 1 on drift (CI gate)
36+
--prompts <dir> Directory of provider-resolved template text (default: prompts)
37+
3338
MIGRATE FLAGS:
3439
--db <url> DB connection URL (required, or set DATABASE_URL or config)
3540
Supports: file:, libsql:, postgres:, postgresql:
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { run } from "../../src/index.js";
6+
7+
// A view-object payload (Brief) + two prompts. `greeting` includes a shared
8+
// partial (the load-bearing case: editing the partial drifts the rendered
9+
// prompt while greeting.mustache itself is unchanged).
10+
const META = {
11+
"metadata.root": {
12+
package: "acme::ai",
13+
children: [
14+
{ "object.value": { name: "Brief", children: [{ "field.string": { name: "name" } }] } },
15+
{
16+
"template.prompt": {
17+
name: "greeting",
18+
"@payloadRef": "Brief",
19+
"@textRef": "prompt/greeting",
20+
"@format": "text",
21+
},
22+
},
23+
{
24+
"template.prompt": {
25+
name: "farewell",
26+
"@payloadRef": "Brief",
27+
"@textRef": "prompt/farewell",
28+
"@format": "text",
29+
},
30+
},
31+
],
32+
},
33+
};
34+
35+
function scaffold(): string {
36+
const tmp = mkdtempSync(join(tmpdir(), "meta-snap-"));
37+
mkdirSync(join(tmp, "metaobjects"), { recursive: true });
38+
writeFileSync(join(tmp, "metaobjects", "meta.ai.json"), JSON.stringify(META), "utf8");
39+
mkdirSync(join(tmp, "prompts", "shared"), { recursive: true });
40+
mkdirSync(join(tmp, "prompts", "prompt"), { recursive: true });
41+
writeFileSync(join(tmp, "prompts", "shared", "preamble.mustache"), "You are a helpful guide.\n", "utf8");
42+
writeFileSync(join(tmp, "prompts", "prompt", "greeting.mustache"), "{{> shared/preamble}}Hi {{name}}.", "utf8");
43+
writeFileSync(join(tmp, "prompts", "prompt", "farewell.mustache"), "Bye {{name}}.", "utf8");
44+
return tmp;
45+
}
46+
47+
function writePayload(tmp: string, templateName: string, payload: unknown): void {
48+
const dir = join(tmp, ".metaobjects", "snapshots", templateName);
49+
mkdirSync(dir, { recursive: true });
50+
writeFileSync(join(dir, "payload.json"), JSON.stringify(payload), "utf8");
51+
}
52+
53+
const snapPath = (tmp: string, name: string) =>
54+
join(tmp, ".metaobjects", "snapshots", name, "output.snap");
55+
56+
let out: string[];
57+
let err: string[];
58+
let origLog: typeof console.log;
59+
let origErr: typeof console.error;
60+
61+
beforeEach(() => {
62+
out = [];
63+
err = [];
64+
origLog = console.log;
65+
origErr = console.error;
66+
console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); };
67+
console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); };
68+
});
69+
afterEach(() => {
70+
console.log = origLog;
71+
console.error = origErr;
72+
});
73+
74+
describe("meta prompt-snapshot (write mode)", () => {
75+
test("writes output.snap for a template that has a payload", async () => {
76+
const tmp = scaffold();
77+
writePayload(tmp, "greeting", { name: "Ada" });
78+
try {
79+
expect(await run(["prompt-snapshot", "--cwd", tmp])).toBe(0);
80+
expect(existsSync(snapPath(tmp, "greeting"))).toBe(true);
81+
expect(readFileSync(snapPath(tmp, "greeting"), "utf8")).toBe("You are a helpful guide.\nHi Ada.");
82+
} finally {
83+
rmSync(tmp, { recursive: true, force: true });
84+
}
85+
});
86+
87+
test("skips a template that has no payload (no golden written)", async () => {
88+
const tmp = scaffold();
89+
writePayload(tmp, "greeting", { name: "Ada" }); // farewell intentionally has none
90+
try {
91+
expect(await run(["prompt-snapshot", "--cwd", tmp])).toBe(0);
92+
expect(existsSync(snapPath(tmp, "farewell"))).toBe(false);
93+
expect([...out, ...err].join("\n")).toContain("farewell");
94+
} finally {
95+
rmSync(tmp, { recursive: true, force: true });
96+
}
97+
});
98+
99+
test("exit 2 when metaobjects/ is missing", async () => {
100+
const tmp = mkdtempSync(join(tmpdir(), "meta-snap-none-"));
101+
try {
102+
expect(await run(["prompt-snapshot", "--cwd", tmp])).toBe(2);
103+
} finally {
104+
rmSync(tmp, { recursive: true, force: true });
105+
}
106+
});
107+
108+
test("exit 1 when a template's @textRef does not resolve", async () => {
109+
const tmp = scaffold();
110+
writePayload(tmp, "farewell", { name: "Ada" }); // not skipped — it has a payload
111+
rmSync(join(tmp, "prompts", "prompt", "farewell.mustache"), { force: true }); // @textRef now unresolvable
112+
try {
113+
expect(await run(["prompt-snapshot", "--cwd", tmp])).toBe(1);
114+
expect([...out, ...err].join("\n")).toContain("farewell");
115+
} finally {
116+
rmSync(tmp, { recursive: true, force: true });
117+
}
118+
});
119+
});

0 commit comments

Comments
 (0)