Skip to content

Commit 19f29a5

Browse files
dmealingclaude
andcommitted
fix(python-cli): verify --templates enforces @requiredSlots/@requiredTags (#230)
Python's `verify --templates` never passed required_slots/required_tags to the render verify() engine, so a `template.prompt` whose body omits a `@requiredTags` output tag passed verify (exit 0) while TS/Java/C# failed it — a cross-port parity gap (surfaced during the #193 review). The `if e.code == ERR_REQUIRED_SLOT_UNUSED: continue` branch could never even fire. Now the prompt path threads @requiredSlots/@requiredTags (resolving read, ADR-0039 — an inherited value is honored) into render_verify: a missing required output tag → ERR_OUTPUT_TAG_MISSING (drift, fails); an unused required slot → ERR_REQUIRED_SLOT_UNUSED (warning). Kept prompt-only — the email/document output gate does not apply them per-part (the #193 cross-port consensus). Error line generalized to `{code}: {path}` (was payload-VO-specific, wrong for a missing-tag finding). Regression tests: a @requiredTags prompt missing the tag fails; present passes. verify-subverbs 14/14. Closes #230. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent 7811a9f commit 19f29a5

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

server/python/src/metaobjects/cli.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,16 @@ def _verify_codegen(args: argparse.Namespace) -> int:
573573
)
574574

575575

576+
def _attr_str_list(raw: object) -> list[str] | None:
577+
"""Coerce a string-array attr (a list, or a single string) to ``list[str]``; ``None``
578+
when absent — the shape :func:`render.verify` expects for required_slots/required_tags."""
579+
if isinstance(raw, str):
580+
return [raw]
581+
if isinstance(raw, (list, tuple)):
582+
return [str(x) for x in raw]
583+
return None
584+
585+
576586
def _verify_templates(args: argparse.Namespace) -> int:
577587
"""``verify --templates`` — the template/prompt ``{{field}}`` ↔ payload drift gate.
578588
@@ -659,6 +669,16 @@ def _verify_templates(args: argparse.Namespace) -> int:
659669
error_count += 1
660670
continue
661671

672+
# #193/#230: @requiredSlots/@requiredTags are a template.prompt concept — enforced ONLY on
673+
# the prompt path (the email/document output gate does not apply them per-part; the #193
674+
# cross-port consensus). A prompt whose body omits a required output tag →
675+
# ERR_OUTPUT_TAG_MISSING (drift, fails); an unused required slot → ERR_REQUIRED_SLOT_UNUSED
676+
# (warning). This brings Python to parity with TS/Java/C# (previously it enforced neither).
677+
is_prompt = tmpl.sub_type == tc.TEMPLATE_SUBTYPE_PROMPT
678+
# ADR-0039: resolving read — an inherited @requiredSlots/@requiredTags is honored.
679+
req_slots = _attr_str_list(tmpl.get_meta_attr(tc.TEMPLATE_ATTR_REQUIRED_SLOTS)) if is_prompt else None
680+
req_tags = _attr_str_list(tmpl.get_meta_attr(tc.TEMPLATE_ATTR_REQUIRED_TAGS)) if is_prompt else None
681+
662682
for ref in refs:
663683
text = provider.resolve(ref)
664684
if text is None:
@@ -670,12 +690,14 @@ def _verify_templates(args: argparse.Namespace) -> int:
670690
error_count += 1
671691
continue
672692
checked += 1
673-
for e in render_verify(text, fields, provider=provider):
693+
for e in render_verify(
694+
text, fields, provider=provider,
695+
required_slots=req_slots, required_tags=req_tags,
696+
):
674697
if e.code == ERR_REQUIRED_SLOT_UNUSED:
675-
continue # warning, not drift
698+
continue # an unused required slot is a warning, not drift
676699
print(
677-
f"error: [{tmpl.name}] ref '{ref}' — {e.code}: "
678-
f"{{{{{e.path}}}}} not on payload VO '{payload_ref}'.",
700+
f"error: [{tmpl.name}] ref '{ref}' — {e.code}: {e.path}",
679701
file=sys.stderr,
680702
)
681703
error_count += 1

server/python/tests/codegen/test_cli_verify_subverbs.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,55 @@ def test_templates_email_body_drift_is_caught(tmp_path: Path, capsys) -> None:
196196
assert "WelcomeEmail" in err
197197

198198

199+
# --- #230: template.prompt @requiredTags / @requiredSlots enforcement (parity w/ TS/Java/C#) ---
200+
201+
_META_PROMPT_TAGS = """\
202+
{
203+
"metadata.root": {
204+
"package": "acme::ai",
205+
"children": [
206+
{
207+
"object.value": {
208+
"name": "Welcome",
209+
"children": [
210+
{ "field.string": { "name": "name", "@required": true } }
211+
]
212+
}
213+
},
214+
{
215+
"template.prompt": {
216+
"name": "TaggedPrompt",
217+
"@payloadRef": "Welcome",
218+
"@textRef": "pages/welcome",
219+
"@requiredTags": ["answer"]
220+
}
221+
}
222+
]
223+
}
224+
}
225+
"""
226+
227+
228+
def test_templates_prompt_required_tag_missing_is_drift(tmp_path: Path, capsys) -> None:
229+
# #230: a template.prompt @requiredTags whose rendered body omits the <answer> tag must FAIL
230+
# verify. Python previously passed required_slots/required_tags to render_verify NOWHERE, so it
231+
# enforced neither — a divergence from TS/Java/C#.
232+
meta_dir = _meta_dir_with(tmp_path, _META_PROMPT_TAGS)
233+
troot = _templates_dir(tmp_path, "Hello {{name}}") # no <answer>…</answer> tag
234+
rc = main(["verify", "--templates", meta_dir, "--templates-root", troot])
235+
assert rc != 0
236+
err = capsys.readouterr().err
237+
assert "ERR_OUTPUT_TAG_MISSING" in err
238+
assert "answer" in err
239+
240+
241+
def test_templates_prompt_required_tag_present_passes(tmp_path: Path) -> None:
242+
meta_dir = _meta_dir_with(tmp_path, _META_PROMPT_TAGS)
243+
troot = _templates_dir(tmp_path, "Hello {{name}} <answer>{{name}}</answer>")
244+
rc = main(["verify", "--templates", meta_dir, "--templates-root", troot])
245+
assert rc == 0
246+
247+
199248
# --- 3. bare verify = codegen (back-compat) + the subverb note --------------
200249

201250

0 commit comments

Comments
 (0)