158 lines
5.2 KiB
Python
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()
|