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
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
#!/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}"