Skip to content

Commit e34d371

Browse files
dmealingclaude
andcommitted
fix(cli): verify --templates drift-checks template.output bodies cross-port (#193)
Java (TemplateVerify) and C# (VerifyCommand) skipped template.output body drift entirely — they did a payload-ref-resolution-only check then continue'd, so a mustache {{field}} that drifted from its @payloadRef passed `verify` and only failed later at gen time (ERR_VAR_NOT_ON_PAYLOAD); @kind=email outputs were never checked at all. Both ports now resolve every renderable body ref and run the render Verify engine per ref, mirroring the gen-time render-helper and the TS reference (4c90504): document output -> @textRef; email output -> @subjectRef + @htmlBodyRef + optional @textBodyRef; prompt -> @textRef (with required slots/tags, which stay prompt-only). C# also switches the loop's attr reads from OwnAttr to resolving Attr (ADR-0039: template attrs may be inherited via extends). Python was already correct (its _verify_templates iterates all four text-ref attrs) — added @kind=email clean/drift regression tests to lock the parity (document output was already covered). Per-port CLI tests added (email clean/drift + document body drift). The one C# test asserting "output passes without a template file on disk" encoded the pre-fix bug (a document @textRef is now walked, so its body must resolve) — corrected to write a clean body and renamed. Verified: Java TemplateVerifyTest 8/8 + MetaDataVerifyTemplatesModeTest 7/7; C# Cli.Tests 45/45; Python test_cli_verify_subverbs 12/12. Refs #193. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent d8d8e89 commit e34d371

5 files changed

Lines changed: 351 additions & 65 deletions

File tree

server/csharp/MetaObjects.Cli.Tests/VerifyCommandTests.cs

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ private void WriteTemplate(string body)
4141
File.WriteAllText(Path.Combine(dir, "main.mustache"), body);
4242
}
4343

44+
// Write a template body at an arbitrary 2-layer ref (group/source) → <ref>.mustache.
45+
private void WriteAt(string reference, string body)
46+
{
47+
var full = Path.Combine(TplDir,
48+
reference.Replace('/', Path.DirectorySeparatorChar) + ".mustache");
49+
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
50+
File.WriteAllText(full, body);
51+
}
52+
4453
[Fact]
4554
public void Clean_template_passes()
4655
{
@@ -98,11 +107,10 @@ public void Prompt_findings_are_tagged_with_prompt_kind()
98107
// -------------------- template.output (FR6, ADR-0010) --------------------
99108

100109
[Fact]
101-
public void Output_with_resolved_payload_ref_passes_without_a_template_file()
110+
public void Output_document_body_passes_when_clean()
102111
{
103-
// template.output's parser is schema-derived from the payload VO; it does
104-
// not require a @textRef-resolved template body. A clean payload-VO should
105-
// pass even when no template file is on disk.
112+
// A document template.output's @textRef body is drift-checked (parity with
113+
// prompt, #193): a clean body referencing only payload fields passes.
106114
const string outputModel = """
107115
{ "metadata.root": { "package": "acme::ai", "children": [
108116
{ "object.value": { "name": "NpcResponsePayload", "children": [
@@ -114,11 +122,66 @@ public void Output_with_resolved_payload_ref_passes_without_a_template_file()
114122
]}}
115123
""";
116124
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), outputModel);
125+
WriteAt("npc/output", "{{name}} is {{age}}");
117126
var o = VerifyCommand.Run(MetaDir, TplDir);
118127
Assert.True(o.Ok, string.Join("; ",
119128
o.LoadErrors.Concat(o.UnresolvedText).Concat(o.Errors.Select(e => $"{e.Kind}/{e.Code}({e.Path})"))));
120129
}
121130

131+
// ---------- #193: template.output bodies are drift-checked (parity with prompt) ----------
132+
133+
private const string EmailModel = """
134+
{ "metadata.root": { "package": "acme::ai", "children": [
135+
{ "object.value": { "name": "P", "children": [ { "field.string": { "name": "name" } } ] } },
136+
{ "template.output": { "name": "Welcome", "@kind": "email",
137+
"@payloadRef": "P", "@subjectRef": "e/subj", "@htmlBodyRef": "e/html" } }
138+
]}}
139+
""";
140+
141+
[Fact]
142+
public void Output_email_clean_passes()
143+
{
144+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EmailModel);
145+
WriteAt("e/subj", "Hello {{name}}");
146+
WriteAt("e/html", "<p>Hi {{name}}</p>");
147+
var o = VerifyCommand.Run(MetaDir, TplDir);
148+
Assert.True(o.Ok, string.Join("; ",
149+
o.LoadErrors.Concat(o.UnresolvedText).Concat(o.Errors.Select(e => $"{e.Kind}/{e.Code}({e.Path})"))));
150+
}
151+
152+
[Fact]
153+
public void Output_email_body_drift_is_caught()
154+
{
155+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), EmailModel);
156+
WriteAt("e/subj", "Hello {{name}}");
157+
WriteAt("e/html", "<p>Hi {{nope}}</p>"); // {{nope}} is not on payload P
158+
var o = VerifyCommand.Run(MetaDir, TplDir);
159+
Assert.False(o.Ok);
160+
Assert.Contains(o.Errors, d =>
161+
d.Kind == VerifyCommand.KIND_OUTPUT &&
162+
d.Code == Render.Verify.ERR_VAR_NOT_ON_PAYLOAD &&
163+
d.Path == "nope");
164+
}
165+
166+
[Fact]
167+
public void Output_document_body_drift_is_caught()
168+
{
169+
const string docModel = """
170+
{ "metadata.root": { "package": "acme::ai", "children": [
171+
{ "object.value": { "name": "P", "children": [ { "field.string": { "name": "name" } } ] } },
172+
{ "template.output": { "name": "Doc", "@format": "json", "@payloadRef": "P", "@textRef": "t/main" } }
173+
]}}
174+
""";
175+
File.WriteAllText(Path.Combine(MetaDir, "meta.ai.json"), docModel);
176+
WriteTemplate("spec for {{nope}}"); // {{nope}} is not on payload P → document-body drift
177+
var o = VerifyCommand.Run(MetaDir, TplDir);
178+
Assert.False(o.Ok);
179+
Assert.Contains(o.Errors, d =>
180+
d.Kind == VerifyCommand.KIND_OUTPUT &&
181+
d.Code == Render.Verify.ERR_VAR_NOT_ON_PAYLOAD &&
182+
d.Path == "nope");
183+
}
184+
122185
[Fact]
123186
public void Output_with_unresolved_payload_ref_is_flagged_as_output_drift()
124187
{

server/csharp/MetaObjects.Cli/VerifyCommand.cs

Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
// extended to template.output by FR6, ADR-0010).
33
//
44
// Loads metadata from a directory; for each template node derives the
5-
// @payloadRef view-object's field tree and dispatches on subtype:
5+
// @payloadRef view-object's field tree, resolves every renderable body ref via a
6+
// filesystem provider, and runs the engine's Verify on each (template variable ↔
7+
// payload field drift, unresolved partials, and — prompts only — missing output
8+
// tags). The body refs are subtype-/kind-aware, mirroring the gen-time
9+
// render-helper so verify and gen agree (#193):
610
//
7-
// template.prompt — resolves @textRef via a filesystem provider and runs the
8-
// engine's Verify (template variable ↔ payload field drift,
9-
// unresolved partials, missing output tags).
10-
// template.output — payload-VO resolution check only (the generator derives
11-
// the parser schema from the same VO, so any field-tree
12-
// drift surfaces here as well as at gen time).
11+
// template.prompt — @textRef (WITH @requiredSlots/@requiredTags).
12+
// template.output document (default) — @textRef.
13+
// template.output @kind=email — @subjectRef + @htmlBodyRef + optional @textBodyRef.
1314
//
1415
// Runs at the last fixed point before serve, never on the request path.
1516

@@ -244,6 +245,12 @@ private static Codegen.CodegenDrift.Result RunCodegenDrift(Options opts)
244245
_ => [],
245246
};
246247

248+
/// <summary>Append a non-blank string attr value to a ref list.</summary>
249+
private static void AddIfPresent(List<string> refs, object? attr)
250+
{
251+
if (attr is string s && s.Length > 0) refs.Add(s);
252+
}
253+
247254
public static Outcome Run(string metadataDir, string templatesRoot, bool strict = true)
248255
{
249256
var load = MetaDataLoader.FromDirectory(metadataDir, strict: strict);
@@ -256,11 +263,13 @@ public static Outcome Run(string metadataDir, string templatesRoot, bool strict
256263

257264
foreach (var tmpl in load.Root.OwnChildren().Where(c => c.Type == TYPE_TEMPLATE))
258265
{
259-
// Missing @payloadRef is already a load error (template schema requires it).
260-
if (tmpl.OwnAttr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
266+
// ADR-0039: @payloadRef may be inherited via an abstract template (templates
267+
// CAN extend) → resolving Attr, not OwnAttr. Missing is a load error already.
268+
if (tmpl.Attr(TEMPLATE_ATTR_PAYLOAD_REF) is not string payloadRef)
261269
continue;
262270

263-
var kind = tmpl.SubType == TEMPLATE_SUBTYPE_OUTPUT ? KIND_OUTPUT : KIND_PROMPT;
271+
var isOutput = tmpl.SubType == TEMPLATE_SUBTYPE_OUTPUT;
272+
var kind = isOutput ? KIND_OUTPUT : KIND_PROMPT;
264273

265274
// Both subtypes: @payloadRef must resolve to a loaded object.value (=>
266275
// non-empty derived field tree). Catches a renamed VO before codegen.
@@ -271,40 +280,62 @@ public static Outcome Run(string metadataDir, string templatesRoot, bool strict
271280
continue;
272281
}
273282

274-
if (tmpl.SubType == TEMPLATE_SUBTYPE_OUTPUT)
283+
// Collect every renderable body ref, mirroring the gen-time render-helper
284+
// gate so verify and gen agree (#193):
285+
// template.prompt → @textRef (WITH required slots/tags).
286+
// template.output document (default) → @textRef.
287+
// template.output @kind=email → @subjectRef + @htmlBodyRef + optional @textBodyRef.
288+
var refs = new List<string>();
289+
IReadOnlyList<string> requiredSlots = [];
290+
IReadOnlyList<string> requiredTags = [];
291+
if (isOutput)
275292
{
276-
// Output's parser schema is derived from the same VO that drives prompt
277-
// rendering — payload-VO resolution above covers FR6's drift contract.
278-
// No @textRef walk: output templates may not carry one (the parser is
279-
// schema-driven), and the generator surfaces gen-time issues directly.
280-
continue;
293+
var outKind = ((tmpl.Attr(TEMPLATE_ATTR_KIND) as string) ?? TEMPLATE_KIND_DEFAULT).ToLowerInvariant();
294+
if (outKind == TEMPLATE_KIND_EMAIL)
295+
{
296+
AddIfPresent(refs, tmpl.Attr(TEMPLATE_ATTR_SUBJECT_REF));
297+
AddIfPresent(refs, tmpl.Attr(TEMPLATE_ATTR_HTML_BODY_REF));
298+
AddIfPresent(refs, tmpl.Attr(TEMPLATE_ATTR_TEXT_BODY_REF));
299+
}
300+
else
301+
{
302+
AddIfPresent(refs, tmpl.Attr(TEMPLATE_ATTR_TEXT_REF));
303+
}
281304
}
282-
283-
// template.prompt branch — existing Mustache + tag/slot checks.
284-
if (tmpl.OwnAttr(TEMPLATE_ATTR_TEXT_REF) is not string textRef)
285-
continue;
286-
287-
var text = provider.Resolve(textRef);
288-
if (text is null)
305+
else
289306
{
290-
unresolved.Add($"template \"{tmpl.Name}\": @textRef \"{textRef}\" did not resolve under {templatesRoot}");
291-
continue;
307+
AddIfPresent(refs, tmpl.Attr(TEMPLATE_ATTR_TEXT_REF));
308+
// Slot/tag requirements are a template.prompt concept; the email/document
309+
// gate does NOT apply them per part (they would false-flag a slot as
310+
// unused in each part). So only the prompt path carries them.
311+
requiredSlots = AsStringList(tmpl.Attr(TEMPLATE_ATTR_REQUIRED_SLOTS));
312+
requiredTags = AsStringList(tmpl.Attr(TEMPLATE_ATTR_REQUIRED_TAGS));
292313
}
293314

294-
var requiredSlots = AsStringList(tmpl.OwnAttr(TEMPLATE_ATTR_REQUIRED_SLOTS));
295-
var requiredTags = AsStringList(tmpl.OwnAttr(TEMPLATE_ATTR_REQUIRED_TAGS));
315+
// No renderable body ref present — a loader-schema concern, not verify's
316+
// (a well-formed output always carries one; document→@textRef, email→subject+html).
317+
if (refs.Count == 0) continue;
296318

297319
var verifyOptions = new VerifyOptions
298320
{
299321
Provider = provider,
300322
RequiredSlots = requiredSlots,
301323
RequiredTags = requiredTags,
302324
};
303-
foreach (var e in Verify.Check(text, fields, verifyOptions))
325+
foreach (var reference in refs)
304326
{
305-
var drift = new Drift(tmpl.Name, kind, e.Code, e.Path);
306-
if (e.Code == Verify.ERR_REQUIRED_SLOT_UNUSED) warnings.Add(drift);
307-
else errors.Add(drift);
327+
var text = provider.Resolve(reference);
328+
if (text is null)
329+
{
330+
unresolved.Add($"template \"{tmpl.Name}\": ref \"{reference}\" did not resolve under {templatesRoot}");
331+
continue;
332+
}
333+
foreach (var e in Verify.Check(text, fields, verifyOptions))
334+
{
335+
var drift = new Drift(tmpl.Name, kind, e.Code, e.Path);
336+
if (e.Code == Verify.ERR_REQUIRED_SLOT_UNUSED) warnings.Add(drift);
337+
else errors.Add(drift);
338+
}
308339
}
309340
}
310341

server/java/codegen-base/src/main/java/com/metaobjects/generator/verify/TemplateVerify.java

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@
3131
* <li>derives the {@code @payloadRef} value-object's {@link PayloadField} field
3232
* tree (the SAME walk {@code SpringRenderHelperGenerator} uses — object-ref
3333
* fields recurse, cycle-guarded — so the build-time gate and this check agree),</li>
34-
* <li>for {@code template.output}: a payload-resolution-only check (the parser
35-
* schema is derived from the same VO; field-tree drift surfaces here AND at
36-
* gen time),</li>
37-
* <li>for {@code template.prompt}: resolves {@code @textRef} through a
38-
* {@link FilesystemProvider} rooted at the on-disk template dir and runs the
39-
* render {@link Verify} engine — reporting {@code {{field}}}↔payload drift,
40-
* unresolved partials, and missing required output tags. {@code @requiredSlots}
41-
* that are never referenced are warnings (not failures).</li>
34+
* <li>resolves every renderable body ref through a {@link FilesystemProvider}
35+
* rooted at the on-disk template dir and runs the render {@link Verify} engine
36+
* against each — reporting {@code {{field}}}↔payload drift, unresolved partials,
37+
* and (prompts only) missing required output tags. The body refs are
38+
* subtype-/kind-aware, mirroring the gen-time render-helper (#193):
39+
* {@code template.prompt} → {@code @textRef} (with {@code @requiredSlots}/
40+
* {@code @requiredTags}); {@code template.output} document → {@code @textRef};
41+
* {@code template.output @kind=email} → {@code @subjectRef} + {@code @htmlBodyRef}
42+
* + optional {@code @textBodyRef}. {@code @requiredSlots} never referenced are
43+
* warnings (not failures).</li>
4244
* </ol>
4345
*
4446
* <p><b>Reuse, not reimplementation</b>: the actual drift logic is the render
@@ -114,33 +116,52 @@ public static Outcome run(MetaDataLoader loader, Path templateRoot) {
114116
continue;
115117
}
116118

119+
// Collect every renderable body ref for this template, mirroring the
120+
// gen-time render-helper drift gate so `verify` and `gen` agree (#193):
121+
// template.prompt → @textRef (WITH required slots/tags).
122+
// template.output document (default) → @textRef.
123+
// template.output @kind=email → @subjectRef + @htmlBodyRef + optional @textBodyRef.
124+
List<String> refs = new ArrayList<>();
125+
List<String> requiredSlots = null;
126+
List<String> requiredTags = null;
117127
if (isOutput) {
118-
// Output's parser schema is derived from the same VO that drives prompt
119-
// rendering — payload-VO resolution above covers the drift contract.
120-
// No @textRef walk: output templates may not carry one (the parser is
121-
// schema-driven), and the generator surfaces gen-time issues directly.
122-
continue;
123-
}
124-
125-
// template.prompt branch — Mustache + tag/slot checks via the render engine.
126-
String textRef = tmpl.getTextRef();
127-
if (textRef == null || textRef.isEmpty()) continue;
128-
129-
String text = provider.resolve(textRef);
130-
if (text == null) {
131-
unresolved.add("template \"" + tmpl.getName() + "\": @textRef \"" + textRef
132-
+ "\" did not resolve under " + templateRoot);
133-
continue;
128+
String outKind = attrString(tmpl, TemplateConstants.ATTR_KIND);
129+
outKind = (outKind == null ? TemplateConstants.KIND_DEFAULT : outKind)
130+
.toLowerCase(java.util.Locale.ROOT);
131+
if (TemplateConstants.KIND_EMAIL.equals(outKind)) {
132+
addIfPresent(refs, attrString(tmpl, TemplateConstants.ATTR_SUBJECT_REF));
133+
addIfPresent(refs, attrString(tmpl, TemplateConstants.ATTR_HTML_BODY_REF));
134+
addIfPresent(refs, attrString(tmpl, TemplateConstants.ATTR_TEXT_BODY_REF));
135+
} else {
136+
addIfPresent(refs, tmpl.getTextRef());
137+
}
138+
} else {
139+
addIfPresent(refs, tmpl.getTextRef());
140+
// Slot/tag requirements are a template.prompt concept; the email/document
141+
// gate does NOT apply them per part (they would false-flag a slot as
142+
// unused in each part). So only the prompt path carries them.
143+
requiredSlots = tmpl instanceof PromptTemplate p ? p.getRequiredSlots() : null;
144+
requiredTags = tmpl.getRequiredTags();
134145
}
135146

136-
List<String> requiredSlots = tmpl instanceof PromptTemplate p ? p.getRequiredSlots() : null;
137-
List<String> requiredTags = tmpl.getRequiredTags();
147+
// No renderable body ref present — a loader-schema concern, not verify's
148+
// (a well-formed output always carries one; document→@textRef, email→subject+html).
149+
if (refs.isEmpty()) continue;
138150

139151
VerifyOptions opts = new VerifyOptions(provider, requiredSlots, requiredTags);
140-
for (VerifyError e : Verify.check(text, fields, opts)) {
141-
Drift drift = new Drift(tmpl.getName(), kind, e.code(), e.path());
142-
if (Verify.ERR_REQUIRED_SLOT_UNUSED.equals(e.code())) warnings.add(drift);
143-
else errors.add(drift);
152+
for (String ref : refs) {
153+
// Render-engine drift check: mustache variables ↔ payload field names.
154+
String text = provider.resolve(ref);
155+
if (text == null) {
156+
unresolved.add("template \"" + tmpl.getName() + "\": ref \"" + ref
157+
+ "\" did not resolve under " + templateRoot);
158+
continue;
159+
}
160+
for (VerifyError e : Verify.check(text, fields, opts)) {
161+
Drift drift = new Drift(tmpl.getName(), kind, e.code(), e.path());
162+
if (Verify.ERR_REQUIRED_SLOT_UNUSED.equals(e.code())) warnings.add(drift);
163+
else errors.add(drift);
164+
}
144165
}
145166
}
146167

@@ -202,6 +223,20 @@ private static MetaObject resolveNestedObjectRef(MetaDataLoader loader, ObjectFi
202223
return null;
203224
}
204225

226+
/**
227+
* Resolving read of a string attr (ADR-0039: template attrs may be inherited via
228+
* {@code extends} — templates CAN extend). Returns null when absent or blank.
229+
*/
230+
private static String attrString(com.metaobjects.MetaData md, String name) {
231+
// Raw resolving read (blank-guarding lives in addIfPresent, matching the C# port).
232+
return md.hasMetaAttr(name) ? md.getMetaAttr(name).getValueAsString() : null;
233+
}
234+
235+
/** Append a non-blank ref to the list. */
236+
private static void addIfPresent(List<String> refs, String ref) {
237+
if (ref != null && !ref.isEmpty()) refs.add(ref);
238+
}
239+
205240
/** Resolve {@code @payloadRef} to its {@code object.value} target (rejects entities). */
206241
private static MetaObject resolveValueObject(MetaDataLoader loader, String ref) {
207242
for (MetaObject obj : loader.getMetaObjects()) {

0 commit comments

Comments
 (0)