diff --git a/roboco/foundation/policy/content/__init__.py b/roboco/foundation/policy/content/__init__.py new file mode 100644 index 00000000..5393b79d --- /dev/null +++ b/roboco/foundation/policy/content/__init__.py @@ -0,0 +1,45 @@ +"""Structured agent-content schema — the RoboCo content standard. + +Public surface: the content models, the ``CONTENT_MODELS`` registry, the +``validate_content`` entry point, and the ``ContentValidationError`` raised on +failure. See :mod:`.models` for the per-type schemas. +""" + +from __future__ import annotations + +from .enums import Severity, Verdict +from .models import ( + CONTENT_MODELS, + AcVerdict, + AuditorNote, + DeveloperNote, + DocNote, + Finding, + PrReviewContent, + QaNote, + ResumptionNote, + TaskDescription, + WorkUnit, + required_shape, + validate_content, +) +from .validators import ContentValidationError + +__all__ = [ + "CONTENT_MODELS", + "AcVerdict", + "AuditorNote", + "ContentValidationError", + "DeveloperNote", + "DocNote", + "Finding", + "PrReviewContent", + "QaNote", + "ResumptionNote", + "Severity", + "TaskDescription", + "Verdict", + "WorkUnit", + "required_shape", + "validate_content", +] diff --git a/roboco/foundation/policy/content/enums.py b/roboco/foundation/policy/content/enums.py new file mode 100644 index 00000000..b4a80841 --- /dev/null +++ b/roboco/foundation/policy/content/enums.py @@ -0,0 +1,35 @@ +"""Enumerations for structured agent content. + +These are the controlled vocabularies the content schema models +(:mod:`roboco.foundation.policy.content.models`) validate against. The cell +``Team`` vocabulary is reused from :mod:`roboco.foundation.identity` rather +than redefined here. +""" + +from __future__ import annotations + +from enum import StrEnum + + +class Severity(StrEnum): + """Severity ladder for a PR-review finding (worst → least).""" + + BLOCKER = "blocker" # must fix before merge + MAJOR = "major" # significant defect + MINOR = "minor" # small defect, fix advised + NIT = "nit" # cosmetic / preference + + +class Verdict(StrEnum): + """Outcome of a review. + + ``approved`` / ``changes_requested`` describe a PR review; ``passed`` / + ``failed`` describe a QA or in-path gate review. All four are valid + ``PrReviewContent`` verdicts; ``QaNote`` accepts only ``passed`` / + ``failed``. + """ + + APPROVED = "approved" + CHANGES_REQUESTED = "changes_requested" + PASSED = "passed" + FAILED = "failed" diff --git a/roboco/foundation/policy/content/models.py b/roboco/foundation/policy/content/models.py new file mode 100644 index 00000000..23051b47 --- /dev/null +++ b/roboco/foundation/policy/content/models.py @@ -0,0 +1,447 @@ +"""Structured content schema models. + +Every human-facing artifact an agent produces — PR-review comments, task +notes, task descriptions, resumption context — is one of these typed models. +Each model: + +- validates its named fields (non-trivial, required, controlled vocab); +- coerces a lone scalar where a list is declared (graceful, never a hard + reject of the well-intentioned single-item case); +- renders to canonical labeled markdown via ``render_markdown()``. + +The structured payload is the source of truth; the rendered markdown is the +derived mirror written to the Task's TEXT note columns and to PR comment +bodies. ``CONTENT_MODELS`` maps the gateway's content-type key to its model; +``validate_content`` is the single entry point that turns a raw payload into a +validated model (or a ``ContentValidationError``). +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) + +from roboco.foundation.identity import CELL_TEAMS, Team + +from .enums import Severity, Verdict +from .validators import ContentValidationError, coerce_to_list, reject_trivial + +_SUMMARY_MIN = 10 + + +class _Base(BaseModel): + """Shared config: drop unknown keys (graceful), validate assignment.""" + + model_config = ConfigDict(extra="ignore", validate_assignment=True) + + +class _Content(_Base): + """A top-level content type that renders to canonical markdown.""" + + def render_markdown(self) -> str: # pragma: no cover - overridden + raise NotImplementedError + + +# --------------------------------------------------------------------------- # +# Markdown helpers +# --------------------------------------------------------------------------- # + + +def _bullets(items: list[str]) -> str: + return "\n".join(f"- {i.strip()}" for i in items if i and i.strip()) + + +def _section(title: str, body: str) -> str: + body = body.strip() + return f"## {title}\n{body}" if body else "" + + +def _join(parts: list[str]) -> str: + return "\n\n".join(p for p in parts if p).strip() + + +# --------------------------------------------------------------------------- # +# Sub-models +# --------------------------------------------------------------------------- # + + +class Finding(_Base): + """One PR-review finding — file + line + expected vs actual.""" + + file: str + line: int | None = None + severity: Severity + criterion: str | None = None + expected: str + actual: str + + @field_validator("file", "expected", "actual") + @classmethod + def _nontrivial(cls, v: str, info: ValidationInfo) -> str: + return reject_trivial(v, field=info.field_name or "field") + + +class WorkUnit(_Base): + """One cell's slice of a task description.""" + + team: Team + summary: str + items: list[str] = Field(default_factory=list) + + @field_validator("items", mode="before") + @classmethod + def _coerce_items(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("team") + @classmethod + def _cell_team_only(cls, v: Team) -> Team: + if v not in CELL_TEAMS: + raise ValueError( + f"team must be a cell ({', '.join(t.value for t in CELL_TEAMS)})" + ) + return v + + @field_validator("summary") + @classmethod + def _nontrivial_summary(cls, v: str) -> str: + return reject_trivial(v, field="summary") + + @field_validator("items") + @classmethod + def _nonempty_items(cls, v: list[str]) -> list[str]: + cleaned = [i for i in v if i and i.strip()] + if not cleaned: + raise ValueError("items must list at least one work item") + return cleaned + + +class AcVerdict(_Base): + """One acceptance-criterion verdict from a QA review.""" + + criterion: str + status: Literal["verified", "failed", "na"] + how: str + + @field_validator("criterion", "how") + @classmethod + def _nontrivial(cls, v: str, info: ValidationInfo) -> str: + return reject_trivial(v, field=info.field_name or "field") + + +# --------------------------------------------------------------------------- # +# Top-level content models +# --------------------------------------------------------------------------- # + + +class PrReviewContent(_Content): + """A PR-review comment / reviewer verdict.""" + + summary: str + findings: list[Finding] = Field(default_factory=list) + verdict: Verdict + + @field_validator("findings", mode="before") + @classmethod + def _coerce_findings(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("summary") + @classmethod + def _nontrivial_summary(cls, v: str) -> str: + return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) + + @model_validator(mode="after") + def _findings_required_for_negative(self) -> PrReviewContent: + if self.verdict in (Verdict.CHANGES_REQUESTED, Verdict.FAILED) and not ( + self.findings + ): + raise ValueError( + "findings must be non-empty when verdict is changes_requested or failed" + ) + return self + + def render_markdown(self) -> str: + parts = [_section("Summary", self.summary)] + if self.findings: + rows = [ + "| File | Line | Severity | Expected → Actual |", + "| --- | --- | --- | --- |", + ] + for f in self.findings: + loc = str(f.line) if f.line is not None else "—" + crit = f" ({f.criterion})" if f.criterion else "" + rows.append( + f"| `{f.file}`{crit} | {loc} | {f.severity.value} " + f"| {f.expected} → {f.actual} |" + ) + parts.append("## Findings\n" + "\n".join(rows)) + parts.append(_section("Verdict", self.verdict.value.replace("_", " "))) + return _join(parts) + + +class TaskDescription(_Content): + """A well-formed task description (shared by PM delegate + Intake draft).""" + + objective: str + what_this_builds: list[str] = Field(default_factory=list) + the_work: list[WorkUnit] = Field(default_factory=list) + notes: list[str] = Field(default_factory=list) + acceptance_criteria: list[str] = Field(default_factory=list) + + @field_validator( + "what_this_builds", + "the_work", + "notes", + "acceptance_criteria", + mode="before", + ) + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("objective") + @classmethod + def _nontrivial_objective(cls, v: str) -> str: + return reject_trivial(v, field="objective", min_chars=_SUMMARY_MIN) + + @field_validator("the_work") + @classmethod + def _nonempty_work(cls, v: list[WorkUnit]) -> list[WorkUnit]: + if not v: + raise ValueError("the_work must contain at least one work unit") + return v + + @field_validator("acceptance_criteria") + @classmethod + def _nonempty_ac(cls, v: list[str]) -> list[str]: + cleaned = [i for i in v if i and i.strip()] + if not cleaned: + raise ValueError("acceptance_criteria must list at least one criterion") + return cleaned + + def render_markdown(self) -> str: + parts = [_section("Objective", self.objective)] + if self.what_this_builds: + parts.append(_section("What This Builds", _bullets(self.what_this_builds))) + if self.the_work: + units = [] + for u in self.the_work: + head = f"**{u.team.value.replace('_', ' ').title()}** — {u.summary}" + units.append(f"{head}\n{_bullets(u.items)}") + parts.append("## The Work\n" + "\n\n".join(units)) + if self.notes: + parts.append(_section("Notes", _bullets(self.notes))) + parts.append( + _section("Acceptance Criteria", _bullets(self.acceptance_criteria)) + ) + return _join(parts) + + +class ResumptionNote(_Content): + """The human handoff that lives in ``quick_context`` (no machine markers).""" + + done: str + next: str + where_to_look: list[str] = Field(default_factory=list) + + @field_validator("where_to_look", mode="before") + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("done", "next") + @classmethod + def _nontrivial(cls, v: str, info: ValidationInfo) -> str: + return reject_trivial(v, field=info.field_name or "field") + + def render_markdown(self) -> str: + parts = [_section("Done", self.done), _section("Next", self.next)] + if self.where_to_look: + parts.append(_section("Where to look", _bullets(self.where_to_look))) + return _join(parts) + + +class DeveloperNote(_Content): + """A developer's task note.""" + + summary: str + changes: list[str] = Field(default_factory=list) + risks: list[str] = Field(default_factory=list) + follow_ups: list[str] = Field(default_factory=list) + + @field_validator("changes", "risks", "follow_ups", mode="before") + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("summary") + @classmethod + def _nontrivial(cls, v: str) -> str: + return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) + + def render_markdown(self) -> str: + parts = [_section("Summary", self.summary)] + if self.changes: + parts.append(_section("Changes", _bullets(self.changes))) + if self.risks: + parts.append(_section("Risks", _bullets(self.risks))) + if self.follow_ups: + parts.append(_section("Follow-ups", _bullets(self.follow_ups))) + return _join(parts) + + +class QaNote(_Content): + """A QA review note — summary + per-criterion verdicts + outcome.""" + + summary: str + ac_verdicts: list[AcVerdict] = Field(default_factory=list) + verdict: Verdict + + @field_validator("ac_verdicts", mode="before") + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("summary") + @classmethod + def _nontrivial(cls, v: str) -> str: + return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) + + @field_validator("verdict") + @classmethod + def _passed_or_failed(cls, v: Verdict) -> Verdict: + if v not in (Verdict.PASSED, Verdict.FAILED): + raise ValueError("QA verdict must be 'passed' or 'failed'") + return v + + @field_validator("ac_verdicts") + @classmethod + def _nonempty(cls, v: list[AcVerdict]) -> list[AcVerdict]: + if not v: + raise ValueError("ac_verdicts must cover at least one acceptance criterion") + return v + + def render_markdown(self) -> str: + parts = [_section("Summary", self.summary)] + marks = {"verified": "✅", "failed": "❌", "na": "—"} + rows = [ + f"- {marks[a.status]} **{a.criterion}** — {a.how}" for a in self.ac_verdicts + ] + parts.append("## Acceptance Criteria\n" + "\n".join(rows)) + parts.append(_section("Verdict", self.verdict.value)) + return _join(parts) + + +class DocNote(_Content): + """A documenter's note — what was documented vs deliberately skipped.""" + + summary: str + documented: list[str] = Field(default_factory=list) + skipped: list[str] = Field(default_factory=list) + + @field_validator("documented", "skipped", mode="before") + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("summary") + @classmethod + def _nontrivial(cls, v: str) -> str: + return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) + + def render_markdown(self) -> str: + parts = [_section("Summary", self.summary)] + if self.documented: + parts.append(_section("Documented", _bullets(self.documented))) + if self.skipped: + parts.append(_section("Skipped", _bullets(self.skipped))) + return _join(parts) + + +class AuditorNote(_Content): + """An auditor's confidential observation.""" + + summary: str + concerns: list[str] = Field(default_factory=list) + severity: Literal["info", "watch", "risk"] + + @field_validator("concerns", mode="before") + @classmethod + def _coerce(cls, v: Any) -> Any: + return coerce_to_list(v) + + @field_validator("summary") + @classmethod + def _nontrivial(cls, v: str) -> str: + return reject_trivial(v, field="summary", min_chars=_SUMMARY_MIN) + + def render_markdown(self) -> str: + parts = [ + _section("Summary", self.summary), + _section("Severity", self.severity), + ] + if self.concerns: + parts.append(_section("Concerns", _bullets(self.concerns))) + return _join(parts) + + +# --------------------------------------------------------------------------- # +# Registry + single validation entry point +# --------------------------------------------------------------------------- # + +CONTENT_MODELS: dict[str, type[_Content]] = { + "pr_review": PrReviewContent, + "task_description": TaskDescription, + "resumption": ResumptionNote, + "developer": DeveloperNote, + "qa": QaNote, + "doc": DocNote, + "auditor": AuditorNote, +} + + +def validate_content(content_type: str, payload: Any) -> _Content: + """Validate ``payload`` against the model for ``content_type``. + + Returns the validated model (passed through unchanged if it is already an + instance of the right model). Raises ``ContentValidationError(field, + reason)`` — built from Pydantic's first error — on any failure, including an + unknown content type. + """ + model_cls = CONTENT_MODELS.get(content_type) + if model_cls is None: + raise ContentValidationError( + "content_type", f"unknown content type: {content_type!r}" + ) + if isinstance(payload, model_cls): + return payload + try: + return model_cls.model_validate( + payload if isinstance(payload, dict) else dict(payload or {}) + ) + except ValidationError as exc: + first = exc.errors()[0] + loc = ".".join(str(p) for p in first.get("loc", ())) or "?" + raise ContentValidationError(loc, first.get("msg", "invalid")) from exc + + +def required_shape(content_type: str) -> dict[str, str]: + """A ``{field: type-hint}`` map for a content type, for remediation hints.""" + model_cls = CONTENT_MODELS.get(content_type) + if model_cls is None: + return {} + shape: dict[str, str] = {} + for name, field in model_cls.model_fields.items(): + ann = field.annotation + shape[name] = getattr(ann, "__name__", str(ann)) + return shape diff --git a/roboco/foundation/policy/content/validators.py b/roboco/foundation/policy/content/validators.py new file mode 100644 index 00000000..afad25e8 --- /dev/null +++ b/roboco/foundation/policy/content/validators.py @@ -0,0 +1,85 @@ +"""Shared validation primitives for structured agent content. + +Two jobs: + +- ``reject_trivial`` — the non-empty / non-placeholder gate, reused by every + content model's field validators. Raises ``ValueError`` so it composes with + Pydantic field validators (which collect ``ValueError`` into a + ``ValidationError``). +- ``ContentValidationError`` — the public, gateway-facing exception. The + top-level ``validate_content`` (in :mod:`.models`) converts Pydantic's + ``ValidationError`` into this so callers get a single ``(field, reason)`` + shape to build a remediation envelope from. +""" + +from __future__ import annotations + +from typing import Any + +# Placeholder tokens that are never an acceptable whole-field value. Extends the +# commit-validator's banned single-word list (services/gateway/commit_validator). +BANNED_PHRASES: frozenset[str] = frozenset( + { + "wip", + "tmp", + "tbd", + "todo", + "asdf", + "oops", + "stuff", + "things", + "n/a", + "na", + "none", + "null", + "-", + "--", + "...", + ".", + "x", + } +) + + +class ContentValidationError(Exception): + """A structured-content payload failed validation. + + Carries the offending ``field`` and a human ``reason`` so the gateway can + return a remediable envelope (``{error, message, remediate, missing}``). + """ + + def __init__(self, field: str, reason: str) -> None: + self.field = field + self.reason = reason + super().__init__(f"{field}: {reason}") + + +def reject_trivial(value: str, *, field: str, min_chars: int = 1) -> str: + """Return the trimmed value, or raise ``ValueError`` if it is trivial. + + Trivial = empty, shorter than ``min_chars``, or a known placeholder token. + Raises ``ValueError`` (not ``ContentValidationError``) so it can be used + directly inside Pydantic field validators. + """ + text = (value or "").strip() + if not text: + raise ValueError(f"{field} must not be empty") + if len(text) < min_chars: + raise ValueError(f"{field} must be at least {min_chars} characters") + if text.lower() in BANNED_PHRASES: + raise ValueError(f"{field} must not be placeholder text (got {value!r})") + return text + + +def coerce_to_list(value: Any) -> Any: + """Wrap a lone scalar/dict into a one-element list; pass lists/None through. + + Mirrors ``api.schemas.v1.do._coerce_to_list``: an agent that passes a single + string (or dict) where a list is declared is making the well-intentioned + single-item mistake — wrap it rather than reject it. + """ + if value is None or isinstance(value, list): + return value + if isinstance(value, str | dict): + return [value] + return value diff --git a/tests/unit/foundation/policy/content/test_content_models.py b/tests/unit/foundation/policy/content/test_content_models.py new file mode 100644 index 00000000..0714fed3 --- /dev/null +++ b/tests/unit/foundation/policy/content/test_content_models.py @@ -0,0 +1,220 @@ +"""Validation tests for the structured content schema models.""" + +from __future__ import annotations + +import pytest +from roboco.foundation.policy.content import ( + AuditorNote, + ContentValidationError, + DeveloperNote, + DocNote, + PrReviewContent, + QaNote, + ResumptionNote, + TaskDescription, + validate_content, +) +from roboco.foundation.policy.content.enums import Severity, Verdict + +# --------------------------------------------------------------------------- # +# Valid construction +# --------------------------------------------------------------------------- # + + +def test_pr_review_valid() -> None: + c = PrReviewContent( + summary="The change is correct and covered by tests.", + findings=[ + { + "file": "roboco/services/git.py", + "line": 42, + "severity": "major", + "expected": "raises on 422", + "actual": "swallows the error", + } + ], + verdict="changes_requested", + ) + assert c.findings[0].severity is Severity.MAJOR + assert c.verdict is Verdict.CHANGES_REQUESTED + + +def test_qa_valid() -> None: + c = QaNote( + summary="Reviewed all acceptance criteria against the diff.", + ac_verdicts=[ + {"criterion": "AC1 returns 400", "status": "verified", "how": "test passes"} + ], + verdict="passed", + ) + assert c.ac_verdicts[0].status == "verified" + + +def test_task_description_valid() -> None: + c = TaskDescription( + objective="Add a structured PR-review comment format.", + what_this_builds=["a reviewer schema"], + the_work=[ + { + "team": "backend", + "summary": "schema + gateway", + "items": ["model", "verb"], + } + ], + acceptance_criteria=["reviewer notes land in their own slot"], + ) + assert c.the_work[0].team.value == "backend" + + +def test_resumption_valid() -> None: + c = ResumptionNote( + done="schema landed", next="wire the gateway", where_to_look=["models.py"] + ) + assert c.next == "wire the gateway" + + +# --------------------------------------------------------------------------- # +# Rejection +# --------------------------------------------------------------------------- # + + +def test_validate_content_unknown_type() -> None: + with pytest.raises(ContentValidationError) as exc: + validate_content("nonsense", {}) + assert exc.value.field == "content_type" + + +def test_pr_review_missing_summary_rejected() -> None: + with pytest.raises(ContentValidationError) as exc: + validate_content("pr_review", {"verdict": "approved"}) + assert exc.value.field == "summary" + + +def test_pr_review_trivial_summary_rejected() -> None: + with pytest.raises(ContentValidationError): + validate_content("pr_review", {"summary": "wip", "verdict": "approved"}) + + +def test_pr_review_negative_verdict_requires_findings() -> None: + with pytest.raises(ContentValidationError): + validate_content( + "pr_review", + { + "summary": "Looks broken but no detail given here.", + "verdict": "failed", + "findings": [], + }, + ) + + +def test_pr_review_approved_allows_empty_findings() -> None: + c = validate_content( + "pr_review", + {"summary": "All criteria met, nothing to change.", "verdict": "approved"}, + ) + assert isinstance(c, PrReviewContent) + + +def test_qa_verdict_must_be_pass_or_fail() -> None: + with pytest.raises(ContentValidationError) as exc: + validate_content( + "qa", + { + "summary": "Reviewed the acceptance criteria thoroughly.", + "ac_verdicts": [ + {"criterion": "c", "status": "verified", "how": "test"} + ], + "verdict": "approved", + }, + ) + assert exc.value.field == "verdict" + + +def test_qa_requires_ac_verdicts() -> None: + with pytest.raises(ContentValidationError) as exc: + validate_content( + "qa", + { + "summary": "Reviewed everything carefully here.", + "ac_verdicts": [], + "verdict": "passed", + }, + ) + assert exc.value.field == "ac_verdicts" + + +def test_task_description_requires_work() -> None: + with pytest.raises(ContentValidationError) as exc: + validate_content( + "task_description", + { + "objective": "Build the thing properly.", + "the_work": [], + "acceptance_criteria": ["x"], + }, + ) + assert exc.value.field == "the_work" + + +def test_work_unit_rejects_non_cell_team() -> None: + with pytest.raises(ContentValidationError): + validate_content( + "task_description", + { + "objective": "Build the thing properly.", + "the_work": [{"team": "board", "summary": "do it", "items": ["a"]}], + "acceptance_criteria": ["x"], + }, + ) + + +# --------------------------------------------------------------------------- # +# Graceful coercion (lone scalar -> one-element list) +# --------------------------------------------------------------------------- # + + +def test_findings_single_dict_coerced_to_list() -> None: + c = validate_content( + "pr_review", + { + "summary": "One finding passed as a bare dict.", + "verdict": "changes_requested", + "findings": { + "file": "a.py", + "severity": "nit", + "expected": "trailing newline", + "actual": "no newline", + }, + }, + ) + assert isinstance(c, PrReviewContent) + assert len(c.findings) == 1 + + +def test_where_to_look_single_string_coerced() -> None: + c = validate_content( + "resumption", + { + "done": "did the thing", + "next": "do next thing", + "where_to_look": "models.py", + }, + ) + assert isinstance(c, ResumptionNote) + assert c.where_to_look == ["models.py"] + + +def test_developer_and_doc_and_auditor_models() -> None: + assert isinstance( + validate_content("developer", {"summary": "Implemented the schema layer."}), + DeveloperNote, + ) + assert isinstance( + validate_content("doc", {"summary": "Documented the new endpoints."}), DocNote + ) + assert isinstance( + validate_content( + "auditor", {"summary": "No concerns with this change.", "severity": "info"} + ), + AuditorNote, + ) diff --git a/tests/unit/foundation/policy/content/test_content_renderers.py b/tests/unit/foundation/policy/content/test_content_renderers.py new file mode 100644 index 00000000..29798d98 --- /dev/null +++ b/tests/unit/foundation/policy/content/test_content_renderers.py @@ -0,0 +1,92 @@ +"""Markdown rendering tests for the structured content models. + +Assert structure (labeled sections + the content appears) rather than exact +bytes, so the renderers stay refactorable. +""" + +from __future__ import annotations + +from roboco.foundation.policy.content import ( + PrReviewContent, + QaNote, + ResumptionNote, + TaskDescription, +) + + +def test_pr_review_renders_sections_and_findings_table() -> None: + md = PrReviewContent( + summary="The guard is missing on the 422 path.", + findings=[ + { + "file": "roboco/services/git.py", + "line": 42, + "severity": "blocker", + "expected": "retry as COMMENT", + "actual": "raises", + } + ], + verdict="changes_requested", + ).render_markdown() + assert "## Summary" in md + assert "## Findings" in md + assert "| File | Line | Severity | Expected → Actual |" in md + assert "`roboco/services/git.py`" in md + assert "blocker" in md + assert "## Verdict" in md + assert "changes requested" in md + + +def test_qa_renders_checklist() -> None: + md = QaNote( + summary="Verified every acceptance criterion against the diff.", + ac_verdicts=[ + { + "criterion": "AC1 returns 400", + "status": "verified", + "how": "test passes", + }, + {"criterion": "AC2 logs", "status": "failed", "how": "no log emitted"}, + ], + verdict="failed", + ).render_markdown() + assert "## Acceptance Criteria" in md + assert "✅" in md and "❌" in md + assert "AC1 returns 400" in md + assert "## Verdict" in md + + +def test_resumption_renders_done_next_where() -> None: + md = ResumptionNote( + done="schema module landed", + next="wire the gateway chokepoint", + where_to_look=[ + "foundation/policy/content/models.py", + "services/content_notes.py", + ], + ).render_markdown() + assert "## Done" in md + assert "## Next" in md + assert "## Where to look" in md + assert "content_notes.py" in md + # No machine markers ever leak into the human resumption note. + assert "original_developer" not in md + assert "documenter:" not in md + + +def test_task_description_renders_all_sections() -> None: + md = TaskDescription( + objective="Add the structured content standard.", + what_this_builds=["a schema layer", "a gateway gate"], + the_work=[ + {"team": "backend", "summary": "models + verbs", "items": ["model", "verb"]} + ], + notes=["reuse the Team enum"], + acceptance_criteria=["reviewer notes are isolated"], + ).render_markdown() + assert "## Objective" in md + assert "## What This Builds" in md + assert "## The Work" in md + assert "**Backend**" in md + assert "## Notes" in md + assert "## Acceptance Criteria" in md