#!/usr/bin/env python3 # Implements: REQ-1 """Validate that a PR has a spec file with required sections and acceptance criteria. Spec-driven development gate. Runs in CI before expensive jobs. Used by grm, infra, sso-bridge, and devx itself. Validates: 1. A spec file exists at ``docs/specs/.md`` (TASK-ID extracted from branch). 2. The spec contains required sections: Problem, Approach, Test Plan, Deploy Plan, Rollback Plan. 3. The spec contains REQ-ID lines (``REQ-N: ``). 4. The spec contains an Acceptance Criteria checklist with at least one item. 5. All acceptance criteria checkboxes are checked (``- [x]``). Usage: python -m devx.ci.validate_spec --branch OBL-INFRA-531-fix-foo In CI, also pass ``--github-output`` to set ``spec-valid`` and ``spec-path`` for downstream steps. """ from __future__ import annotations import re from pathlib import Path import click from dotenv import load_dotenv from devx.ci._shared import extract_task_id, write_github_output from devx.i18n import _ load_dotenv() REQUIRED_SECTIONS = [ "## Problem", "## Approach", "## Test Plan", "## Deploy Plan", "## Rollback Plan", "## Acceptance Criteria", ] REQ_ID_RE = re.compile(r"^REQ-\d+:\s+.+", re.MULTILINE) AC_CHECKED_RE = re.compile(r"^\s*- \[x\]\s+.+", re.MULTILINE) AC_UNCHECKED_RE = re.compile(r"^\s*- \[ \]\s+.+", re.MULTILINE) def find_spec_file(task_id: str, specs_dir: str = "docs/specs") -> Path | None: """Find the spec file for the given task ID. Looks for ``docs/specs/.md`` (case-insensitive filename). Returns the Path if found, None otherwise. """ base = Path(specs_dir) if not base.is_dir(): return None # Exact match (case-insensitive) for p in base.glob("*.md"): if p.stem.upper() == task_id.upper(): return p return None def validate_spec_content(content: str) -> list[str]: """Validate spec content and return a list of error messages. Returns an empty list if the spec is valid. """ errors: list[str] = [] # Check required sections for section in REQUIRED_SECTIONS: if section not in content: errors.append(_("Missing required section: {section}", section=section)) # Check for at least one REQ-ID req_ids = REQ_ID_RE.findall(content) if not req_ids: errors.append(_("No REQ-ID lines found. Each requirement must be labeled (e.g., 'REQ-1: ').")) # Check acceptance criteria has at least one item checked = AC_CHECKED_RE.findall(content) unchecked = AC_UNCHECKED_RE.findall(content) if not checked and not unchecked: errors.append(_("Acceptance Criteria section has no checklist items. Add at least one '- [ ] item'.")) elif unchecked: errors.append( _( "Acceptance Criteria has {count} unchecked item(s). All AC items must be checked (- [x]) before merge.", count=len(unchecked), ) ) return errors @click.command() @click.option("--branch", required=True, help=_("Branch name (e.g., OBL-INFRA-531-fix-foo)")) @click.option("--specs-dir", default="docs/specs", help=_("Directory containing spec files")) @click.option( "--github-output", "github_output", is_flag=True, default=False, help=_("Write results to $GITHUB_OUTPUT"), ) @click.option("--allow-missing", is_flag=True, default=False, help=_("Allow missing spec (warn only, don't fail)")) def cli(branch: str, specs_dir: str, github_output: bool, allow_missing: bool) -> None: """Validate that a spec file exists and has required content.""" task_id = extract_task_id(branch) if not task_id: msg = _("No task ID found in branch name '{branch}'. Expected format: -N-description.", branch=branch) if allow_missing: click.echo(f"WARNING: {msg}") if github_output: write_github_output("spec-valid", "false") write_github_output("spec-path", "") return raise click.ClickException(msg) spec_path = find_spec_file(task_id, specs_dir) if spec_path is None: msg = _( "No spec file found for task {task_id} in {dir}/. Expected: {dir}/{task_id}.md", task_id=task_id, dir=specs_dir, ) if allow_missing: click.echo(f"WARNING: {msg}") if github_output: write_github_output("spec-valid", "false") write_github_output("spec-path", "") return raise click.ClickException(msg) content = spec_path.read_text(encoding="utf-8") errors = validate_spec_content(content) if github_output: write_github_output("spec-valid", "true" if not errors else "false") write_github_output("spec-path", str(spec_path)) if errors: click.echo("", err=True) click.echo("=" * 60, err=True) click.echo(f"Spec validation FAILED for {spec_path}:", err=True) click.echo("=" * 60, err=True) for e in errors: click.echo(f" - {e}", err=True) raise click.ClickException(_("Spec validation failed.")) click.echo(_("[spec-check] Spec validated: {path}", path=spec_path)) if __name__ == "__main__": # pragma: no cover cli()