src/frontmatter.py grows from a single-key reader into the full machine
contract: reader (read_frontmatter_value, unchanged), one parse primitive
(parse_frontmatter), writer (render/write_frontmatter), schema validator
(validate_schema/REQUIRED_FIELDS, warning-only by default) and a shared
strip_frontmatter helper. The five verdict gates (check_reviewer_verdict,
_parse_tests_verdict, _parse_deploy_status, _parse_staging_status,
parse_security_status) now read through the single parse_frontmatter point
instead of duplicated ad-hoc YAML logic; review_parse._strip_frontmatter and
security_gate.extract_security_findings reuse the shared helper.
Strictly backward compatible + never-raise: STAGE_TRANSITIONS, the QG_CHECKS
composition, verdict semantics (incl. ORCH-047 three-field tester + negative
token priority), reason-strings and worktree->origin/main fallback are 1:1.
The schema validator never influences a gate verdict by default; hard-fail is
reserved behind the frontmatter_validation_strict kill-switch (default False).
New formal handoff spec docs/_standards/HANDOFF_PROTOCOL.md ("stage -> required
output" + required frontmatter schema), aligned 1:1 with PIPELINE_DOCS.md.
Tests: test_frontmatter.py (TC-01..07), test_qg_verdicts.py (TC-08..15),
test_security_gate.py (TC-12), test_stages_invariants.py (TC-16). Full
tests/ green (1212).
Refs: ORCH-076
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
10 KiB
Python
245 lines
10 KiB
Python
"""ORCH-076 (ORCH-52c): unit tests for the unified frontmatter contract.
|
|
|
|
Covers TC-01..TC-07 of docs/work-items/ORCH-076/04-test-plan.yaml:
|
|
* writer (render/round-trip), validator (full / partial schema, strict on/off),
|
|
* reader contract preserved (read_frontmatter_value), never-raise on bad input.
|
|
|
|
The whole module honours a never-raise contract (NFR-2): no input shape may raise.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
|
|
from src import frontmatter as fm # noqa: E402
|
|
from src.frontmatter import ( # noqa: E402
|
|
REQUIRED_FIELDS,
|
|
FrontmatterParse,
|
|
SchemaValidation,
|
|
maybe_warn_schema,
|
|
parse_frontmatter,
|
|
parse_frontmatter_dict,
|
|
read_frontmatter,
|
|
read_frontmatter_value,
|
|
render_frontmatter,
|
|
strip_frontmatter,
|
|
validate_schema,
|
|
write_frontmatter,
|
|
)
|
|
|
|
|
|
def _full_schema():
|
|
return {
|
|
"work_item": "ORCH-076",
|
|
"stage": "review",
|
|
"author_agent": "reviewer",
|
|
"status": "APPROVED",
|
|
"created_at": "2026-06-09",
|
|
"model_used": "claude-opus-4-8",
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-01 — writer serialises a mapping into canonical leading YAML-frontmatter
|
|
# readable by the existing parsers (split("---", 2) + yaml.safe_load).
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc01_render_frontmatter_is_canonical_and_reparseable():
|
|
out = render_frontmatter({"verdict": "APPROVED", "work_item": "ORCH-076"}, "body text")
|
|
assert out.startswith("---\n")
|
|
# Existing parser shape: split on '---' into 3 segments + yaml.safe_load.
|
|
parts = out.split("---", 2)
|
|
assert len(parts) == 3
|
|
import yaml
|
|
data = yaml.safe_load(parts[1])
|
|
assert data["verdict"] == "APPROVED"
|
|
assert data["work_item"] == "ORCH-076"
|
|
# Body is preserved verbatim after the closing fence.
|
|
assert parts[2].lstrip("\n") == "body text"
|
|
# And our own primitive round-trips it.
|
|
assert parse_frontmatter(out).data == {"verdict": "APPROVED", "work_item": "ORCH-076"}
|
|
|
|
|
|
def test_tc01_render_empty_body_default():
|
|
out = render_frontmatter({"a": 1})
|
|
assert out == "---\na: 1\n---\n"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-02 — round-trip: writer -> reader read_frontmatter_value yields same values.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc02_write_then_read_roundtrip():
|
|
data = _full_schema()
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "12-review.md")
|
|
assert write_frontmatter(path, data, "# Review body") is True
|
|
for key, val in data.items():
|
|
assert read_frontmatter_value(path, key) == val
|
|
# Whole-mapping read matches too.
|
|
assert read_frontmatter(path) == data
|
|
|
|
|
|
def test_tc02_render_parse_dict_roundtrip():
|
|
data = _full_schema()
|
|
rendered = render_frontmatter(data, "body")
|
|
assert parse_frontmatter_dict(rendered) == data
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-03 — validator: full schema -> valid=True, no missing fields.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc03_validate_full_schema_valid():
|
|
res = validate_schema(_full_schema())
|
|
assert isinstance(res, SchemaValidation)
|
|
assert res.valid is True
|
|
assert res.missing == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-04 — validator: partial schema -> valid=False with the missing list,
|
|
# WITHOUT raising (warning-only by default).
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc04_validate_partial_schema_lists_missing():
|
|
res = validate_schema({"work_item": "ORCH-076", "stage": "review"})
|
|
assert res.valid is False
|
|
# The four absent required fields are reported (order = REQUIRED_FIELDS).
|
|
assert set(res.missing) == set(REQUIRED_FIELDS) - {"work_item", "stage"}
|
|
assert res.missing == [f for f in REQUIRED_FIELDS if f in res.missing]
|
|
|
|
|
|
def test_tc04_blank_and_none_count_as_missing():
|
|
data = _full_schema()
|
|
data["status"] = "" # blank -> missing
|
|
data["model_used"] = None # None -> missing
|
|
res = validate_schema(data)
|
|
assert res.valid is False
|
|
assert set(res.missing) == {"status", "model_used"}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-05 — never-raise: writer + validator on broken input return a safe value.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc05_validate_non_mapping_never_raises():
|
|
for bad in (None, "not a mapping", 123, ["a", "b"]):
|
|
res = validate_schema(bad) # type: ignore[arg-type]
|
|
assert res.valid is False
|
|
assert set(res.missing) == set(REQUIRED_FIELDS)
|
|
|
|
|
|
def test_tc05_parse_broken_inputs_never_raise():
|
|
# No frontmatter.
|
|
p = parse_frontmatter("just prose, no fence")
|
|
assert p == FrontmatterParse()
|
|
assert p.data == {} and p.has_block is False
|
|
# Unterminated block.
|
|
p = parse_frontmatter("---\nkey: val\nno closing fence")
|
|
assert p.has_block is True and p.malformed is True and p.data == {}
|
|
# Bad YAML.
|
|
p = parse_frontmatter("---\nkey: : :\n bad\n---\n")
|
|
assert p.has_block is True and p.yaml_error is not None and p.data == {}
|
|
# Non-mapping scalar frontmatter.
|
|
p = parse_frontmatter("---\njust a string\n---\nbody")
|
|
assert p.has_block is True and p.data == {}
|
|
# Non-string input.
|
|
assert parse_frontmatter(None).data == {} # type: ignore[arg-type]
|
|
assert parse_frontmatter_dict(12345) == {} # type: ignore[arg-type]
|
|
|
|
|
|
def test_tc05_write_to_unwritable_path_returns_false():
|
|
# A path under a non-existent directory cannot be opened -> False, no raise.
|
|
ok = write_frontmatter("/nonexistent-dir-xyz/cannot/12-review.md", {"a": 1})
|
|
assert ok is False
|
|
|
|
|
|
def test_tc05_render_unserialisable_degrades_to_body():
|
|
class Bad:
|
|
pass
|
|
|
|
out = render_frontmatter({"x": Bad()}, "fallback-body")
|
|
# yaml cannot serialise an arbitrary object -> degrade to the body, never raise.
|
|
assert out == "fallback-body"
|
|
|
|
|
|
def test_tc05_read_missing_file_returns_empty():
|
|
assert read_frontmatter("/no/such/file.md") == {}
|
|
assert read_frontmatter_value("/no/such/file.md", "verdict") is None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-06 — reader read_frontmatter_value keeps its previous contract.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc06_reader_contract_preserved():
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "doc.md")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write("---\nverdict: Approved\nempty:\n---\nbody\n")
|
|
# strip + case preserved.
|
|
assert read_frontmatter_value(path, "verdict") == "Approved"
|
|
# empty value -> None.
|
|
assert read_frontmatter_value(path, "empty") is None
|
|
# absent key -> None.
|
|
assert read_frontmatter_value(path, "missing") is None
|
|
|
|
# No frontmatter -> None.
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "doc.md")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write("no frontmatter here\n")
|
|
assert read_frontmatter_value(path, "verdict") is None
|
|
|
|
|
|
def test_tc06_reader_strips_whitespace():
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "doc.md")
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write('---\nverdict: " PASS "\n---\n')
|
|
assert read_frontmatter_value(path, "verdict") == "PASS"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-07 — kill-switch: strict False (default) is inert; strict True signals
|
|
# invalidity. maybe_warn_schema never changes a verdict either way.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_tc07_maybe_warn_schema_default_warning_only(monkeypatch, caplog):
|
|
monkeypatch.setattr(fm, "logger", fm.logger)
|
|
from src.config import settings
|
|
monkeypatch.setattr(settings, "frontmatter_validation_strict", False)
|
|
incomplete = render_frontmatter({"verdict": "APPROVED"}, "body")
|
|
res = maybe_warn_schema(incomplete, "review report")
|
|
# Validation still reports invalidity (the data IS incomplete)...
|
|
assert res.valid is False
|
|
assert "model_used" in res.missing
|
|
# ...but the helper is inert: it returns a value, it does not raise / block.
|
|
|
|
|
|
def test_tc07_strict_flag_visible_to_helper(monkeypatch):
|
|
from src.config import settings
|
|
# Full schema -> valid regardless of the flag.
|
|
monkeypatch.setattr(settings, "frontmatter_validation_strict", True)
|
|
res_full = maybe_warn_schema(render_frontmatter(_full_schema(), "b"), "doc")
|
|
assert res_full.valid is True
|
|
# Incomplete -> invalid; strict True does not raise, just signals.
|
|
res_partial = maybe_warn_schema(render_frontmatter({"stage": "review"}, "b"), "doc")
|
|
assert res_partial.valid is False
|
|
|
|
|
|
def test_tc07_maybe_warn_schema_on_garbage_is_inert():
|
|
# Never-raise: a non-string / no-frontmatter input returns a SchemaValidation
|
|
# (reporting the missing fields) WITHOUT raising — the gate verdict is untouched.
|
|
for bad in ("no frontmatter", None, 123):
|
|
res = maybe_warn_schema(bad, "doc") # type: ignore[arg-type]
|
|
assert isinstance(res, SchemaValidation)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# strip_frontmatter helper parity.
|
|
# --------------------------------------------------------------------------- #
|
|
def test_strip_frontmatter_parity():
|
|
assert strip_frontmatter("---\na: 1\n---\nbody") == "\nbody"
|
|
# No well-formed block -> unchanged.
|
|
assert strip_frontmatter("no fence") == "no fence"
|
|
assert strip_frontmatter("---\nunterminated") == "---\nunterminated"
|
|
# Never-raise on non-string.
|
|
assert strip_frontmatter(None) is None # type: ignore[arg-type]
|