#!/usr/bin/env python3 """Canonical schema, validator, and generator for Ralph `prd.json` backlogs. `prd.json` is the single authoritative source for a Ralph-managed backlog. This module defines the field shape every story must have, validates structural and cross-story invariants (unique IDs/titles, resolvable `dependsOn`, acyclic dependency graph, `blocks` derived correctly), and renders the same fields into the generated Markdown/Gitea-issue view used by `.scratch//issues/*.md`. Generation is one-directional: prd.json -> Markdown / Gitea payload. There is no Markdown -> prd.json parser, so a generated artifact can never feed `passes` (or any other field) back into the authoritative source. Every generated Markdown document must start with the authority disclaimer (`authority_disclaimer()`, sourced from prd.json's own `metadataSchema` when present) so a stray edit of the generated file cannot be mistaken for a source-of-truth change. The shared "quality gates" boilerplate is likewise sourced from prd.json's `qualityGates`, not duplicated as a Python literal. Example: python scripts/ralph_prd_schema.py validate .scratch/distributed-gguf-runtime/prd.json python scripts/ralph_prd_schema.py render .scratch/distributed-gguf-runtime/prd.json DGR-018 """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] AUTHORITY_DISCLAIMER = ( "" ) EXECUTION_MODES = {"AFK", "HITL"} EVIDENCE_CLASSES = {"model-free", "fixture", "real-model", "real-hardware", "release"} HARDWARE_FLAGS = {"none", "optional", "required"} UPSTREAM_FLAGS = {"yes", "no", "conditional"} TRIAGE_VALUES = {"ready-for-agent", "ready-for-human"} # Canonical field registry: name -> (required, expected python type(s)). # This is the single definition of "what a story is" referenced by both # validation and documentation; keep it in sync with any schema change. STORY_FIELDS: dict[str, tuple[bool, tuple[type, ...]]] = { "id": (True, (str,)), "title": (True, (str,)), "priority": (True, (int,)), "milestone": (True, (str,)), "executionMode": (True, (str,)), "labels": (True, (list,)), "evidenceClass": (True, (str,)), "evidencePath": (True, (str,)), "hardware": (True, (str,)), "model": (True, (str,)), "upstream": (True, (str,)), "dependsOn": (True, (list,)), "triage": (True, (str,)), "description": (True, (str,)), "acceptanceCriteria": (True, (list,)), "passes": (True, (bool,)), "notes": (True, (str,)), "blocks": (True, (list,)), "completionNotes": (False, (str,)), } _ID_RE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$") class PrdValidationError(ValueError): """Raised with all accumulated validation errors, one per line.""" def __init__(self, errors: list[str]): super().__init__("\n".join(errors)) self.errors = errors def load_prd(path: Path) -> dict[str, Any]: try: text = path.read_text(encoding="utf-8") except FileNotFoundError as exc: raise PrdValidationError([f"PRD not found: {path}"]) from exc try: data = json.loads(text) except json.JSONDecodeError as exc: raise PrdValidationError([f"Invalid JSON in {path}: {exc}"]) from exc if not isinstance(data, dict): raise PrdValidationError([f"{path}: top-level PRD document must be an object"]) return data def stories_of(data: dict[str, Any]) -> list[dict[str, Any]]: stories = data.get("userStories") if not isinstance(stories, list): return [] return [s for s in stories if isinstance(s, dict)] def derive_type(labels: list[str]) -> str | None: """Derive the story's `type` from its `type:` label, if present. Release-gate stories (labelled `gate:hitl`) intentionally carry no `type:` label; they derive the synthetic type `release-gate` instead. """ types = [label.split(":", 1)[1] for label in labels if label.startswith("type:")] if types: return types[0] if any(label.startswith("gate:") for label in labels): return "release-gate" return None def _label_prefix_counts(labels: list[str], prefix: str) -> int: return sum(1 for label in labels if label.startswith(prefix)) def validate_schema(data: dict[str, Any]) -> list[str]: """Structural validation: required top-level keys and per-story field shape.""" errors: list[str] = [] for key in ("name", "description", "branchName", "userStories"): if key not in data: errors.append(f"prd: missing top-level field '{key}'") stories = data.get("userStories") if not isinstance(stories, list): errors.append("prd: 'userStories' must be a list") return errors for index, story in enumerate(stories): label = f"userStories[{index}]" if not isinstance(story, dict): errors.append(f"{label}: story must be an object") continue sid = story.get("id", label) for field, (required, types) in STORY_FIELDS.items(): if field not in story: if required: errors.append(f"{sid}: missing required field '{field}'") continue value = story[field] if types == (bool,) and isinstance(value, bool): continue if not isinstance(value, types): errors.append( f"{sid}: field '{field}' must be {types}, got {type(value).__name__}" ) if isinstance(story.get("id"), str) and not _ID_RE.match(story["id"]): errors.append(f"{sid}: id '{story['id']}' does not match PREFIX-NUMBER convention") if isinstance(story.get("executionMode"), str) and story["executionMode"] not in EXECUTION_MODES: errors.append(f"{sid}: executionMode '{story['executionMode']}' not in {sorted(EXECUTION_MODES)}") if isinstance(story.get("evidenceClass"), str) and story["evidenceClass"] not in EVIDENCE_CLASSES: errors.append(f"{sid}: evidenceClass '{story['evidenceClass']}' not in {sorted(EVIDENCE_CLASSES)}") if isinstance(story.get("hardware"), str) and story["hardware"] not in HARDWARE_FLAGS: errors.append(f"{sid}: hardware '{story['hardware']}' not in {sorted(HARDWARE_FLAGS)}") if isinstance(story.get("upstream"), str) and story["upstream"] not in UPSTREAM_FLAGS: errors.append(f"{sid}: upstream '{story['upstream']}' not in {sorted(UPSTREAM_FLAGS)}") if isinstance(story.get("triage"), str) and story["triage"] not in TRIAGE_VALUES: errors.append(f"{sid}: triage '{story['triage']}' not in {sorted(TRIAGE_VALUES)}") labels = story.get("labels") if isinstance(labels, list): if not all(isinstance(label, str) for label in labels): errors.append(f"{sid}: labels must all be strings") elif _label_prefix_counts(labels, "type:") > 1: errors.append(f"{sid}: at most one 'type:' label allowed") elif _label_prefix_counts(labels, "priority:") > 1: errors.append(f"{sid}: at most one 'priority:' label allowed") depends_on = story.get("dependsOn") if isinstance(depends_on, list) and not all(isinstance(dep, str) for dep in depends_on): errors.append(f"{sid}: dependsOn entries must all be strings") acceptance = story.get("acceptanceCriteria") if isinstance(acceptance, list): if not acceptance: errors.append(f"{sid}: acceptanceCriteria must be non-empty") elif not all(isinstance(item, str) and item.strip() for item in acceptance): errors.append(f"{sid}: acceptanceCriteria entries must be non-empty strings") return errors def _find_cycle(story_ids: set[str], depends_on: dict[str, list[str]]) -> list[str] | None: WHITE, GRAY, BLACK = 0, 1, 2 color = {sid: WHITE for sid in story_ids} path: list[str] = [] def dfs(node: str) -> list[str] | None: color[node] = GRAY path.append(node) for dep in depends_on.get(node, []): if dep not in color: continue if color[dep] == GRAY: cycle_start = path.index(dep) return path[cycle_start:] + [dep] if color[dep] == WHITE: found = dfs(dep) if found: return found path.pop() color[node] = BLACK return None for sid in sorted(story_ids): if color[sid] == WHITE: cycle = dfs(sid) if cycle: return cycle return None def validate_semantics(data: dict[str, Any]) -> list[str]: """Cross-story validation: uniqueness, dependency graph, derived `blocks`.""" errors: list[str] = [] stories = stories_of(data) ids = [s.get("id") for s in stories if isinstance(s.get("id"), str)] seen_ids: set[str] = set() for sid in ids: if sid in seen_ids: errors.append(f"duplicate story id: '{sid}'") seen_ids.add(sid) titles = [s.get("title") for s in stories if isinstance(s.get("title"), str)] seen_titles: set[str] = set() for title in titles: if title in seen_titles: errors.append(f"duplicate story title: '{title}'") seen_titles.add(title) by_id = {s["id"]: s for s in stories if isinstance(s.get("id"), str)} depends_on: dict[str, list[str]] = {} for story in stories: sid = story.get("id") if not isinstance(sid, str): continue deps = story.get("dependsOn") if isinstance(story.get("dependsOn"), list) else [] deps = [d for d in deps if isinstance(d, str)] depends_on[sid] = deps if sid in deps: errors.append(f"{sid}: cannot depend on itself") for dep in deps: if dep not in by_id: errors.append(f"{sid}: depends on unknown story '{dep}'") cycle = _find_cycle(set(by_id), depends_on) if cycle: errors.append("dependency cycle detected: " + " -> ".join(cycle)) derived_blocks: dict[str, list[str]] = {sid: [] for sid in by_id} for sid, deps in depends_on.items(): for dep in deps: if dep in derived_blocks: derived_blocks[dep].append(sid) for sid in derived_blocks: derived_blocks[sid] = sorted(derived_blocks[sid]) for story in stories: sid = story.get("id") if not isinstance(sid, str): continue given = story.get("blocks") if isinstance(story.get("blocks"), list) else [] given_sorted = sorted(d for d in given if isinstance(d, str)) expected = derived_blocks.get(sid, []) if given_sorted != expected: errors.append( f"{sid}: 'blocks' does not match derived dependents " f"(got {given_sorted}, expected {expected})" ) evidence_path = story.get("evidencePath") if isinstance(evidence_path, str): expected_path = f".scratch/distributed-gguf-runtime/evidence/{sid}/README.md" if evidence_path != expected_path: errors.append( f"{sid}: evidencePath '{evidence_path}' does not match convention " f"'{expected_path}'" ) return errors def validate_fresh_backlog(data: dict[str, Any]) -> list[str]: """Extra invariant for a freshly generated (not-yet-started) backlog. A fresh backlog must not claim any completion credit: every story starts `passes: false`. This is distinct from validating an in-flight backlog (use validate_schema + validate_semantics for that), because a real backlog legitimately accumulates `passes: true` stories over time. """ errors = validate_backlog(data) for story in stories_of(data): if story.get("passes") is not False: errors.append( f"{story.get('id')}: fresh backlog requires passes=false, " f"got {story.get('passes')!r}" ) return errors def validate_backlog(data: dict[str, Any]) -> list[str]: """Full validation for an in-progress backlog: structure, semantics, and (when present) prd.json's self-documented metadataSchema/qualityGates.""" return ( validate_schema(data) + validate_semantics(data) + validate_metadata_schema_consistency(data) ) def validate_metadata_schema_consistency(data: dict[str, Any]) -> list[str]: """If prd.json documents its own `metadataSchema`/`qualityGates`, verify that self-description hasn't drifted from what this validator actually enforces. Absent entirely, this is a no-op (a minimal fixture PRD is not required to carry self-documentation to be structurally valid). """ errors: list[str] = [] ms = data.get("metadataSchema") if ms is None: return errors enum_checks = { "triageValues": TRIAGE_VALUES, "executionModeValues": EXECUTION_MODES, "evidenceClassValues": EVIDENCE_CLASSES, "hardwareValues": HARDWARE_FLAGS, "upstreamValues": UPSTREAM_FLAGS, } for key, expected in enum_checks.items(): declared = ms.get(key) if declared is None: errors.append(f"metadataSchema: missing '{key}'") continue if set(declared) != expected: errors.append( f"metadataSchema.{key} {sorted(declared)} does not match " f"enforced values {sorted(expected)}" ) required_in_code = {field for field, (required, _) in STORY_FIELDS.items() if required} declared_required = set(ms.get("requiredStoryFields", [])) if declared_required != required_in_code: errors.append( f"metadataSchema.requiredStoryFields {sorted(declared_required)} does not " f"match code-enforced required fields {sorted(required_in_code)}" ) optional_in_code = {field for field, (required, _) in STORY_FIELDS.items() if not required} declared_optional = set(ms.get("optionalStoryFields", [])) if declared_optional != optional_in_code: errors.append( f"metadataSchema.optionalStoryFields {sorted(declared_optional)} does not " f"match code-enforced optional fields {sorted(optional_in_code)}" ) if not ms.get("generatedArtifactDisclaimer"): errors.append("metadataSchema: missing 'generatedArtifactDisclaimer'") if "qualityGates" not in data: errors.append("prd: metadataSchema is documented but top-level 'qualityGates' is missing") elif not quality_gate_bullets(data): errors.append("qualityGates: no bullets declared under universal/native/realModelHardware/scope") return errors def quality_gate_bullets(data: dict[str, Any]) -> list[str]: """Flatten prd.json's `qualityGates` into the bullet order used by the generated "Shared quality gates" Markdown section.""" gates = data.get("qualityGates", {}) bullets: list[str] = [] for key in ("universal", "native", "realModelHardware", "scope"): bullets.extend(gates.get(key, [])) return bullets def authority_disclaimer(data: dict[str, Any]) -> str: """The exact disclaimer generated Markdown/Gitea bodies must start with. Sourced from prd.json's `metadataSchema.generatedArtifactDisclaimer` when present, falling back to the module default for minimal fixtures. """ return data.get("metadataSchema", {}).get("generatedArtifactDisclaimer", AUTHORITY_DISCLAIMER) def check_generated_markdown_authority(text: str, disclaimer: str = AUTHORITY_DISCLAIMER) -> list[str]: """Reject generated Markdown that fails to defer authority to prd.json.""" errors: list[str] = [] first_line = text.splitlines()[0] if text else "" if first_line != disclaimer: errors.append( "generated Markdown must start with the authority disclaimer: " f"expected {disclaimer!r}, got {first_line!r}" ) lowered = text.lower() for claim in ("this file is authoritative", "this document is authoritative", "markdown is authoritative"): if claim in lowered: errors.append(f"generated Markdown claims authority over prd.json: found '{claim}'") return errors def slugify(title: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") return re.sub(r"-{2,}", "-", slug) def issue_number(story_id: str) -> str: match = re.search(r"(\d+)$", story_id) if not match: raise ValueError(f"story id '{story_id}' has no trailing number") return match.group(1) def issue_filename(story: dict[str, Any]) -> str: return f"{issue_number(story['id'])}-{slugify(story['title'])}.md" # Fallback bullets used only when a minimal fixture PRD has no qualityGates # block of its own; the real backlog always sources these from prd.json via # quality_gate_bullets() so this list is a default, not a duplicate of truth. _DEFAULT_QUALITY_GATE_BULLETS = [ "Targeted deterministic tests pass; Python changes also pass `python -m compileall packages tests`.", "`git diff --check` passes.", "Default tests are model-download-free, API-credit-free, and GPU-free.", "Evidence README records exact changed files, commands/results, limitations, and dependency handoff; no fabricated evidence or inherited completion credit.", ] def render_issue_markdown(story: dict[str, Any], data: dict[str, Any] | None = None) -> str: """Render the canonical generated Markdown view of a story. `data` supplies the shared quality-gates bullets and authority disclaimer from prd.json's own `qualityGates`/`metadataSchema`; omit it only for fixtures that don't model those fields. This is byte-for-byte identical to the convention already used by `.scratch/distributed-gguf-runtime/ issues/*.md`; see the round-trip tests in tests/test_ralph_prd_schema.py. """ data = data or {} sid = story["id"] passes = story["passes"] deps = story.get("dependsOn", []) blocks = sorted(story.get("blocks", [])) deps_text = ", ".join(f"`{d}`" for d in deps) if deps else "None" blocks_text = ", ".join(f"`{b}`" for b in blocks) if blocks else "None" labels_text = ", ".join(f"`{label}`" for label in story.get("labels", [])) if passes: status_line = "completed; `passes: true`" else: status_line = f"specification only; `{story['triage']}`; `passes: false`" checkbox = "x" if passes else " " acceptance_lines = "\n".join( f"- [{checkbox}] {criterion}" for criterion in story["acceptanceCriteria"] ) if passes: evidence_handoff = ( f"Verified evidence: `{story['evidencePath']}`. Legacy evidence remains " "provenance only and grants no implementation completion credit." ) else: evidence_handoff = ( f"Write and verify `{story['evidencePath']}`. Until every criterion and " f"applicable gate has real evidence, this story remains `passes: false`. " "Legacy evidence is provenance only, not completion credit." ) description = story["description"] gate_bullets = quality_gate_bullets(data) if "qualityGates" in data else _DEFAULT_QUALITY_GATE_BULLETS gates_section = "## Shared quality gates\n\n" + "\n".join(f"- {bullet}" for bullet in gate_bullets) lines = [ authority_disclaimer(data), f"# {sid}: {story['title']}", "", f"- **Status / triage:** {status_line}", f"- **Execution mode:** `{story['executionMode']}`", f"- **Milestone:** `{story['milestone']}`", f"- **Dependencies:** {deps_text}", f"- **Blocks (derived):** {blocks_text}", f"- **Labels:** {labels_text}", f"- **Evidence class:** `{story['evidenceClass']}`", f"- **Hardware:** `{story['hardware']}`", f"- **Model:** `{story['model']}`", f"- **Upstream:** `{story['upstream']}`", "", "## Objective / description", "", description, "", "## Acceptance criteria", "", acceptance_lines, "", gates_section, "", "## Evidence handoff", "", evidence_handoff, "", ] return "\n".join(lines[:-1]) + "\n" def to_gitea_issue_payload(story: dict[str, Any], data: dict[str, Any] | None = None) -> dict[str, Any]: """Structured payload matching the Gitea create-issue API shape. `body` is exactly `render_issue_markdown`'s output, so the payload carries no information that isn't losslessly derivable from prd.json. """ return { "title": f"{story['id']}: {story['title']}", "body": render_issue_markdown(story, data), "labels": list(story.get("labels", [])), "milestone": story["milestone"], } def _cmd_validate(args: argparse.Namespace) -> int: data = load_prd(args.prd) errors = validate_fresh_backlog(data) if args.fresh else validate_backlog(data) if errors: for error in errors: print(f"ERROR: {error}", file=sys.stderr) print(f"{len(errors)} validation error(s).", file=sys.stderr) return 1 print(f"OK: {len(stories_of(data))} stories validated.") return 0 def _cmd_render(args: argparse.Namespace) -> int: data = load_prd(args.prd) by_id = {s["id"]: s for s in stories_of(data)} story = by_id.get(args.story_id) if story is None: print(f"Unknown story id: {args.story_id}", file=sys.stderr) return 1 print(render_issue_markdown(story, data), end="") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) validate = subparsers.add_parser("validate", help="Validate a prd.json against the canonical schema") validate.add_argument("prd", type=Path) validate.add_argument("--fresh", action="store_true", help="Also require passes=false on every story") validate.set_defaults(func=_cmd_validate) render = subparsers.add_parser("render", help="Render the generated Markdown view of one story") render.add_argument("prd", type=Path) render.add_argument("story_id") render.set_defaults(func=_cmd_render) return parser def main(argv: list[str]) -> int: parser = build_parser() args = parser.parse_args(argv[1:]) return args.func(args) if __name__ == "__main__": raise SystemExit(main(sys.argv))