Files
devx/src/devx/ci/validate_spec.py
T

158 lines
5.2 KiB
Python

#!/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/<TASK-ID>.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: <description>``).
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/<TASK-ID>.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: <description>')."))
# 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: <PREFIX>-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()