orchestrata_ultimate.openspec.requirements_parser

Reference › orchestrata_ultimate › openspec › requirements_parser

requirements_parser reads a spec's checklists/requirements.md and extracts every requirement line of the form checkbox, bold REQ-NNN-XXX id, colon and description. It is separate from the OpenSpec markdown parser: it serves the project's own spec format. parse_requirements returns the requirements as spec_requirement/3 Prolog facts, suitable for asserting into pyswip or writing to a .pl file, and run is the command-line entry point behind scripts.parse_spec_requirements. The id pattern accepts story-scoped and requirement-scoped tails as well as plain three-digit ones. find_story_headings returns the story numbers declared by a spec's own ### US-NNN headings. next_requirement_id emits the next canonical requirement id for a spec and story, accepting story forms such as US-004, US4, 4 and 004 (normalised by _normalize_story). The code comments explain why the story-heading helper lives in this installed package and is imported by scripts.check_req_format and not the reverse: scripts/ is not part of the distribution, so the opposite direction broke imports. verification.spec_completeness also imports it; tests are in tests/openspec/test_requirements_parser.py.

At a glance

Source src/orchestrata_ultimate/openspec/requirements_parser.py
Lines of code 219
Classes 0
Functions 5
Imports (internal) none
Imported by orchestrata_ultimate.verification.spec_completeness, scripts.check_req_format, scripts.parse_spec_requirements
Tests that import it tests/openspec/test_requirements_parser.py

Class diagram

No classes; see Functions.

Dependency diagram

Dependency diagram orchestrata_ultimate.openspec.requirements_parser

API

Functions

Function Signature Summary
find_story_headings find_story_headings(spec_md: Path) -> set[int] Story numbers declared by a spec's own ### US-NNN headings.
parse_requirements parse_requirements(spec_id: str, checklist_path: str) -> list[str] Parse requirements checklist into Prolog facts.
run run(spec_id: str, checklist_path: str) -> None CLI entry point for parse_spec_requirements.
_normalize_story _normalize_story(story: str | int) -> int Accept any of "US-004", "US4", "4", "004" or the int 4 and return the bare int -- the same normalization scripts/check_req_format.py's story-existence check applies, so a…
next_requirement_id next_requirement_id(spec_id: str, story: str | int, checklist_path: str, spec_md_path: str) -> str Emit the next correct canonical requirement id for spec_id's story -- the automatic seam that makes hand-typing (and hand-mistyping) a requirement id unnecessary.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/openspec/requirements_parser.py

#!/usr/bin/env python3
"""Parse spec requirement checklists into Prolog facts.

Reads a spec's ``checklists/requirements.md`` and extracts every
``REQ-NNN-XXX`` line.  Outputs ``spec_requirement/3`` Prolog facts
suitable for assertion into pyswip or writing to a ``.pl`` file.
"""

from __future__ import annotations

import os
import re
from pathlib import Path

# Story headings declared by a spec's own ``### US-NNN`` lines.
#
# This lives HERE, in the installed package, and ``scripts/check_req_format.py``
# imports it from here — never the reverse. It was briefly the other way round
# (``from scripts.check_req_format import find_story_headings``) and that is a
# layering inversion: ``scripts/`` is not part of the distribution, so the
# import only resolved when the repo root happened to be on ``sys.path``.
# Running any script directly puts ``scripts/`` on sys.path[0] instead, and the
# whole package then failed to import with ``No module named 'scripts'`` —
# which is how a requirement-id gate took out an unrelated scanner's entire
# test module.
_STORY_HEADING = re.compile(r"(?m)^#{1,6}\s*US-(\d{1,4})\b")


def find_story_headings(spec_md: Path) -> set[int]:
    """Story numbers declared by a spec's own ``### US-NNN`` headings.

    An empty result means either the file is missing/unreadable or the spec
    is genuinely FR-only (no US headings) -- callers must treat empty as
    "cannot determine", never as a violation.
    """
    try:
        text = spec_md.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return set()
    return {int(m.group(1)) for m in _STORY_HEADING.finditer(text)}


# Matches: - [ ] **REQ-NNN-XXX**: description text
# The id tail is NOT always plain \d{3}: specs legitimately use story/FR-scoped
# tails (REQ-009-US-001-001, REQ-050-FR8-001, REQ-000-US-001-001). Anything after
# the spec number that is alphanumeric-with-dashes is a valid requirement id.
_REQ_RE = re.compile(
    r"^-\s*\[[ x]\]\s*\*\*(?P<req_id>REQ-\d{3}-[A-Za-z0-9][A-Za-z0-9-]*)\*\*:\s*(?P<desc>.+)$"
)

# Accepts any of the forms a human or a caller might pass for a story:
# "US-004", "US4", "4", "004" -- normalized to the bare int. Mirrors the
# equivalence scripts/check_req_format.py's story-existence check enforces
# (US4 and US-004 name the same story).
_STORY_REF_RE = re.compile(r"^(?:US-?)?(?P<idx>\d{1,4})$", re.IGNORECASE)

# The story-index/seq tail of an ALREADY-PARSED requirement id, once the
# fixed "REQ-<spec>-" head is stripped -- either the unpadded/unhyphenated
# legacy form (US4-011) or the padded/hyphenated canonical form
# (US-004-011). Used only to read the existing max sequence out of parsed
# checklist facts for one story, regardless of which form they were written in.
_STORY_TAIL_RE = re.compile(r"^US-?0*(?P<idx>\d{1,4})-(?P<seq>\d{3})$")

__all__ = ["parse_requirements", "run", "next_requirement_id"]


def parse_requirements(
    spec_id: str,
    checklist_path: str,
) -> list[str]:
    """Parse requirements checklist into Prolog facts.

    Reads REQ-NNN-XXX lines from the checklist and generates facts
    of the form::

        spec_requirement('014', 'REQ-014-001', 'Client subscribes ...').

    Args:
        spec_id: Spec number (e.g., ``"014"``).
        checklist_path: Path to ``requirements.md``.

    Returns:
        List of Prolog fact strings (without trailing period).

    Raises:
        FileNotFoundError: When *checklist_path* does not exist.
    """
    if not os.path.isfile(checklist_path):
        raise FileNotFoundError(f"Checklist not found: {checklist_path}")

    facts: list[str] = []

    with open(checklist_path, encoding="utf-8") as fh:
        for line in fh:
            line = line.rstrip("\n")
            req_match = _REQ_RE.match(line.strip())
            if req_match:
                req_id = req_match.group("req_id")
                desc = req_match.group("desc").strip()
                # Escape backslashes FIRST, then single quotes, for Prolog
                # atoms -- SWI's default character_escapes flag means an
                # unescaped literal backslash in a quoted atom (e.g. spec
                # prose describing a regex like `\.claude` or the Prolog
                # `\+` operator) is parsed as an (invalid) escape sequence
                # rather than a literal character. Escaping backslashes
                # before quotes avoids double-processing the quote escape.
                safe_desc = desc.replace("\\", "\\\\").replace("'", "\\'")
                fact = (
                    f"spec_requirement('{spec_id}', " f"'{req_id}', " f"'{safe_desc}')"
                )
                facts.append(fact)

    return facts


def run(spec_id: str, checklist_path: str) -> None:
    """CLI entry point for parse_spec_requirements.

    Parses the checklist and prints one Prolog fact per line.
    """
    for fact in parse_requirements(spec_id, checklist_path):
        print(f"{fact}.")


def _normalize_story(story: str | int) -> int:
    """Accept any of ``"US-004"``, ``"US4"``, ``"4"``, ``"004"`` or the int
    ``4`` and return the bare int -- the same normalization
    ``scripts/check_req_format.py``'s story-existence check applies, so a
    caller of either tool gets identical answers for the same story.

    Raises:
        ValueError: *story* cannot be parsed as a story reference.
    """
    if isinstance(story, int):
        return story
    m = _STORY_REF_RE.match(story.strip())
    if not m:
        raise ValueError(f"cannot parse story reference: {story!r}")
    return int(m.group("idx"))


def next_requirement_id(
    spec_id: str,
    story: str | int,
    checklist_path: str,
    spec_md_path: str,
) -> str:
    """Emit the next correct canonical requirement id for ``spec_id``'s
    ``story`` -- the automatic seam that makes hand-typing (and
    hand-mistyping) a requirement id unnecessary.

    Two real artifacts drive this, never a guess:

    1. ``spec_md_path`` (the spec's own ``### US-NNN`` headings) proves the
       story actually EXISTS -- the exact rule
       ``scripts/check_req_format.py``'s story-existence check enforces, so
       a generated id can never itself become an existence violation.
    2. ``checklist_path`` (read via :func:`parse_requirements`, the existing
       checklist parser -- extended, not duplicated) supplies the highest
       sequence number already claimed for that spec+story, in EITHER
       accepted form (``US4-011`` or ``US-004-011``), so the new id can
       never collide with one already on record.

    The id returned is always in the canonical padded/hyphenated form
    (``REQ-<spec_id>-US-<idx>-<next_seq>``) -- legacy ``USn`` ids already in
    the checklist are read (their sequence still counts toward "next"), but
    this function never WRITES that form; only US-NNN goes out.

    Args:
        spec_id: Spec number, e.g. ``"003"``.
        story: Story reference in any accepted form -- ``"US-004"``,
            ``"US4"``, ``"4"``, ``"004"``, or the int ``4``.
        checklist_path: Path to the spec's ``checklists/requirements.md``.
        spec_md_path: Path to the spec's ``spec.md`` (source of the real
            ``### US-NNN`` headings).

    Returns:
        The next canonical requirement id, e.g. ``"REQ-003-US-004-013"``.

    Raises:
        FileNotFoundError: *checklist_path* does not exist.
        ValueError: *story* cannot be parsed, OR *spec_md_path* has zero
            ``### US-NNN`` headings at all (cannot verify -- refuses to mint
            an id for an unverifiable story), OR *story* names an index with
            no matching heading in *spec_md_path* (message names the
            attempted story, the spec file, and the stories that DO exist
            there -- mirrors the check_req_format.py existence-gate message).
    """
    idx = _normalize_story(story)

    headings = find_story_headings(Path(spec_md_path))
    if not headings:
        raise ValueError(
            f"{spec_md_path} (spec {spec_id}) has no '### US-NNN' headings "
            f"at all -- cannot verify story US-{idx:03d} exists; refusing "
            "to mint an id for an unverifiable story."
        )
    if idx not in headings:
        existing = ", ".join(f"US-{n:03d}" for n in sorted(headings))
        raise ValueError(
            f"story US-{idx:03d} does not exist in {spec_md_path} "
            f"(spec {spec_id}); stories that DO exist there: {existing}."
        )

    max_seq = 0
    for fact in parse_requirements(spec_id, checklist_path):
        # fact looks like: spec_requirement('003', 'REQ-003-US-004-011', '...')
        parts = fact.split("', '")
        if len(parts) < 3:
            continue
        req_id = parts[1]
        prefix = f"REQ-{spec_id}-"
        if not req_id.startswith(prefix):
            continue
        m = _STORY_TAIL_RE.match(req_id[len(prefix) :])
        if m and int(m.group("idx")) == idx:
            max_seq = max(max_seq, int(m.group("seq")))

    return f"REQ-{spec_id}-US-{idx:03d}-{max_seq + 1:03d}"