requirements-as-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rac/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ """Command-line interface for RAC.
2
+
3
+ Commands:
4
+ rac validate <file.md> [--json]
5
+ rac diff <old.md> <new.md> [--json]
6
+
7
+ Exit codes:
8
+ 0 success (validate: no errors; diff: always, when it runs)
9
+ 1 validation found errors
10
+ 2 usage / IO error (e.g. file not found)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+
18
+ from . import __version__
19
+ from . import outputs
20
+ from .diff import diff as diff_asts
21
+ from .parser import parse_file
22
+ from .validate import has_errors, validate
23
+
24
+ EXIT_OK = 0
25
+ EXIT_VALIDATION_FAILED = 1
26
+ EXIT_USAGE = 2
27
+
28
+
29
+ def _read(path: str):
30
+ """Parse a file, or print an error and exit with EXIT_USAGE."""
31
+ try:
32
+ return parse_file(path)
33
+ except FileNotFoundError:
34
+ print(f"rac: file not found: {path}", file=sys.stderr)
35
+ raise SystemExit(EXIT_USAGE)
36
+ except OSError as exc:
37
+ print(f"rac: cannot read {path}: {exc}", file=sys.stderr)
38
+ raise SystemExit(EXIT_USAGE)
39
+
40
+
41
+ def cmd_validate(args: argparse.Namespace) -> int:
42
+ product = _read(args.file)
43
+ issues = validate(product)
44
+ if args.json:
45
+ print(outputs.render_validation_json(product, issues))
46
+ else:
47
+ print(outputs.render_validation_human(product, issues))
48
+ return EXIT_VALIDATION_FAILED if has_errors(issues) else EXIT_OK
49
+
50
+
51
+ def cmd_diff(args: argparse.Namespace) -> int:
52
+ old = _read(args.old)
53
+ new = _read(args.new)
54
+ result = diff_asts(old, new)
55
+ if args.json:
56
+ print(outputs.render_diff_json(result, args.old, args.new))
57
+ else:
58
+ print(outputs.render_diff_human(result, args.old, args.new))
59
+ return EXIT_OK
60
+
61
+
62
+ def build_parser() -> argparse.ArgumentParser:
63
+ parser = argparse.ArgumentParser(
64
+ prog="rac",
65
+ description="Requirements As Code — lint and diff Markdown requirements.",
66
+ )
67
+ parser.add_argument("--version", action="version", version=f"rac {__version__}")
68
+ sub = parser.add_subparsers(dest="command", required=True)
69
+
70
+ p_validate = sub.add_parser(
71
+ "validate", help="Validate a single requirement file."
72
+ )
73
+ p_validate.add_argument("file", help="Path to the requirement Markdown file.")
74
+ p_validate.add_argument(
75
+ "--json", action="store_true", help="Emit JSON instead of human-readable text."
76
+ )
77
+ p_validate.set_defaults(func=cmd_validate)
78
+
79
+ p_diff = sub.add_parser(
80
+ "diff", help="Compare two versions of a requirement file."
81
+ )
82
+ p_diff.add_argument("old", help="Path to the old version.")
83
+ p_diff.add_argument("new", help="Path to the new version.")
84
+ p_diff.add_argument(
85
+ "--json", action="store_true", help="Emit JSON instead of human-readable text."
86
+ )
87
+ p_diff.set_defaults(func=cmd_diff)
88
+
89
+ # Future commands (rac stats <dir>, rac review <file>) will register here.
90
+ return parser
91
+
92
+
93
+ def main(argv: list[str] | None = None) -> int:
94
+ parser = build_parser()
95
+ args = parser.parse_args(argv)
96
+ return args.func(args)
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
rac/diff.py ADDED
@@ -0,0 +1,69 @@
1
+ """Compare two :class:`~rac.models.Product` ASTs and classify the changes.
2
+
3
+ Diffing operates purely on the AST, never on raw Markdown text.
4
+
5
+ - Requirements are matched by ID:
6
+ * same ID, same text -> unchanged (omitted)
7
+ * same ID, different -> modified
8
+ * ID only in the new -> added
9
+ * ID only in the old -> removed
10
+ - Metrics and risks are matched by exact string (set difference).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .models import Diff, Product, Requirement, RequirementChange
16
+
17
+
18
+ def _by_id(requirements: list[Requirement]) -> dict[str, Requirement]:
19
+ # On a duplicate ID (a validation error) the last occurrence wins; the diff
20
+ # is still well-defined.
21
+ return {r.id: r for r in requirements}
22
+
23
+
24
+ def _ordered_difference(a: list[str], b: list[str]) -> list[str]:
25
+ """Items in ``a`` not present in ``b``, preserving ``a``'s order, de-duped."""
26
+ b_set = set(b)
27
+ seen: set[str] = set()
28
+ out: list[str] = []
29
+ for item in a:
30
+ if item not in b_set and item not in seen:
31
+ seen.add(item)
32
+ out.append(item)
33
+ return out
34
+
35
+
36
+ def diff(old: Product, new: Product) -> Diff:
37
+ """Return the classified :class:`Diff` between ``old`` and ``new``."""
38
+ old_reqs = _by_id(old.requirements)
39
+ new_reqs = _by_id(new.requirements)
40
+
41
+ result = Diff()
42
+
43
+ # Added / modified: iterate new (preserves new-file order).
44
+ for req_id, new_req in new_reqs.items():
45
+ old_req = old_reqs.get(req_id)
46
+ if old_req is None:
47
+ result.added_requirements.append(new_req)
48
+ elif old_req.text != new_req.text:
49
+ result.modified_requirements.append(
50
+ RequirementChange(
51
+ id=req_id, old_text=old_req.text, new_text=new_req.text
52
+ )
53
+ )
54
+
55
+ # Removed: in old but not new (preserves old-file order).
56
+ for req_id, old_req in old_reqs.items():
57
+ if req_id not in new_reqs:
58
+ result.removed_requirements.append(old_req)
59
+
60
+ result.added_metrics = _ordered_difference(
61
+ new.success_metrics, old.success_metrics
62
+ )
63
+ result.removed_metrics = _ordered_difference(
64
+ old.success_metrics, new.success_metrics
65
+ )
66
+ result.added_risks = _ordered_difference(new.risks, old.risks)
67
+ result.removed_risks = _ordered_difference(old.risks, new.risks)
68
+
69
+ return result
rac/models.py ADDED
@@ -0,0 +1,107 @@
1
+ """The Product AST and result types.
2
+
3
+ These dataclasses are the *only* thing validation, diffing, and future analysis
4
+ should operate on. The parser (``rac.parser``) is responsible for turning a
5
+ Markdown file into a :class:`Product`; everything downstream reads the AST, never
6
+ the raw text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Literal
13
+
14
+ Severity = Literal["error", "warning"]
15
+
16
+
17
+ @dataclass
18
+ class Requirement:
19
+ """A single requirement line, e.g. ``[REQ-001] User can view data``."""
20
+
21
+ id: str # canonical ID, preserved exactly (e.g. "REQ-001")
22
+ text: str # the description following the ID
23
+ line: int # 1-based source line, for diagnostics
24
+
25
+
26
+ @dataclass
27
+ class MalformedRequirement:
28
+ """A non-empty line under ``## Requirements`` that is not a valid requirement.
29
+
30
+ Captured (rather than dropped) so validation can report *why* it is invalid
31
+ instead of silently ignoring it.
32
+ """
33
+
34
+ raw: str # the raw line text
35
+ line: int
36
+ # The parsed ID if one was found but is malformed (e.g. "REQ-1A"); None if
37
+ # the line had no recognizable ID prefix at all.
38
+ bad_id: str | None = None
39
+ # True when an ID was present and well-formed but the description was blank.
40
+ empty_text: bool = False
41
+
42
+
43
+ @dataclass
44
+ class Product:
45
+ """The structured representation of a single requirement file."""
46
+
47
+ title: str | None
48
+ # Source lines of any *additional* top-level # titles (a file must have
49
+ # exactly one). Empty in a well-formed file.
50
+ extra_title_lines: list[int] = field(default_factory=list)
51
+ problem: str | None = None # None = section absent; "" = present but empty
52
+ requirements: list[Requirement] = field(default_factory=list)
53
+ malformed_requirements: list[MalformedRequirement] = field(default_factory=list)
54
+ success_metrics: list[str] = field(default_factory=list)
55
+ risks: list[str] = field(default_factory=list)
56
+ # Distinguish "section absent" from "section present but empty".
57
+ has_problem_section: bool = False
58
+ has_requirements_section: bool = False
59
+ has_metrics_section: bool = False
60
+ has_risks_section: bool = False
61
+ source_path: str = ""
62
+
63
+
64
+ @dataclass
65
+ class Issue:
66
+ """A validation finding."""
67
+
68
+ severity: Severity
69
+ code: str # stable machine code, e.g. "missing-title", "ambiguous-verb"
70
+ message: str # human-readable explanation
71
+ line: int | None = None
72
+
73
+
74
+ @dataclass
75
+ class RequirementChange:
76
+ """A requirement whose text changed between two versions (same ID)."""
77
+
78
+ id: str
79
+ old_text: str
80
+ new_text: str
81
+
82
+
83
+ @dataclass
84
+ class Diff:
85
+ """The classified differences between two :class:`Product` ASTs."""
86
+
87
+ added_requirements: list[Requirement] = field(default_factory=list)
88
+ removed_requirements: list[Requirement] = field(default_factory=list)
89
+ modified_requirements: list[RequirementChange] = field(default_factory=list)
90
+ added_metrics: list[str] = field(default_factory=list)
91
+ removed_metrics: list[str] = field(default_factory=list)
92
+ added_risks: list[str] = field(default_factory=list)
93
+ removed_risks: list[str] = field(default_factory=list)
94
+
95
+ def is_empty(self) -> bool:
96
+ """True when nothing changed across any comparison unit."""
97
+ return not any(
98
+ (
99
+ self.added_requirements,
100
+ self.removed_requirements,
101
+ self.modified_requirements,
102
+ self.added_metrics,
103
+ self.removed_metrics,
104
+ self.added_risks,
105
+ self.removed_risks,
106
+ )
107
+ )
rac/output.py ADDED
@@ -0,0 +1,152 @@
1
+ """Rendering for RAC command results: human-readable text and JSON.
2
+
3
+ Keeping this separate from :mod:`rac.cli` lets the CLI stay thin and makes the
4
+ output formats easy to test directly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from dataclasses import asdict
12
+
13
+ from .models import Diff, Issue, Product
14
+
15
+ # --- Minimal color (auto-disabled when not writing to a TTY) ----------------
16
+
17
+ _USE_COLOR = sys.stdout.isatty()
18
+
19
+
20
+ def _c(text: str, code: str) -> str:
21
+ if not _USE_COLOR:
22
+ return text
23
+ return f"\033[{code}m{text}\033[0m"
24
+
25
+
26
+ def _green(t: str) -> str:
27
+ return _c(t, "32")
28
+
29
+
30
+ def _red(t: str) -> str:
31
+ return _c(t, "31")
32
+
33
+
34
+ def _yellow(t: str) -> str:
35
+ return _c(t, "33")
36
+
37
+
38
+ def _bold(t: str) -> str:
39
+ return _c(t, "1")
40
+
41
+
42
+ def _loc(file: str, line: int | None) -> str:
43
+ return f"{file}:{line}" if line is not None else file
44
+
45
+
46
+ # --- validate ---------------------------------------------------------------
47
+
48
+
49
+ def render_validation_human(product: Product, issues: list[Issue]) -> str:
50
+ errors = [i for i in issues if i.severity == "error"]
51
+ warnings = [i for i in issues if i.severity == "warning"]
52
+ file = product.source_path or "<input>"
53
+
54
+ lines: list[str] = []
55
+ if errors:
56
+ lines.append(_red(_bold(f"FAIL {file}")))
57
+ else:
58
+ lines.append(_green(_bold(f"PASS {file}")))
59
+
60
+ for issue in errors:
61
+ lines.append(f" {_red('error')} [{issue.code}] {_loc(file, issue.line)}")
62
+ lines.append(f" {issue.message}")
63
+ for issue in warnings:
64
+ lines.append(
65
+ f" {_yellow('warning')} [{issue.code}] {_loc(file, issue.line)}"
66
+ )
67
+ lines.append(f" {issue.message}")
68
+
69
+ lines.append("")
70
+ lines.append(
71
+ f"{len(errors)} error(s), {len(warnings)} warning(s)."
72
+ )
73
+ return "\n".join(lines)
74
+
75
+
76
+ def render_validation_json(product: Product, issues: list[Issue]) -> str:
77
+ errors = [asdict(i) for i in issues if i.severity == "error"]
78
+ warnings = [asdict(i) for i in issues if i.severity == "warning"]
79
+ payload = {
80
+ "file": product.source_path or None,
81
+ "valid": not errors,
82
+ "errors": errors,
83
+ "warnings": warnings,
84
+ }
85
+ return json.dumps(payload, indent=2)
86
+
87
+
88
+ # --- diff -------------------------------------------------------------------
89
+
90
+
91
+ def render_diff_human(d: Diff, old_path: str, new_path: str) -> str:
92
+ if d.is_empty():
93
+ return "No changes."
94
+
95
+ blocks: list[str] = []
96
+
97
+ def list_block(title: str, items: list[str], sign: str) -> None:
98
+ """A titled block of single-line +/- entries (added/removed)."""
99
+ if not items:
100
+ return
101
+ color = _green if sign == "+" else _red
102
+ lines = [_bold(title), ""]
103
+ lines.extend(color(f"{sign} {item}") for item in items)
104
+ blocks.append("\n".join(lines))
105
+
106
+ list_block(
107
+ "Added Requirements",
108
+ [f"{r.id} {r.text}" for r in d.added_requirements],
109
+ "+",
110
+ )
111
+ list_block(
112
+ "Removed Requirements",
113
+ [f"{r.id} {r.text}" for r in d.removed_requirements],
114
+ "-",
115
+ )
116
+
117
+ if d.modified_requirements:
118
+ lines = [_bold("Modified Requirements"), ""]
119
+ for i, c in enumerate(d.modified_requirements):
120
+ if i:
121
+ lines.append("")
122
+ lines.append(f"~ {c.id}")
123
+ lines.append("")
124
+ lines.append("Before:")
125
+ lines.append(_red(c.old_text))
126
+ lines.append("")
127
+ lines.append("After:")
128
+ lines.append(_green(c.new_text))
129
+ blocks.append("\n".join(lines))
130
+
131
+ list_block("Added Metrics", d.added_metrics, "+")
132
+ list_block("Removed Metrics", d.removed_metrics, "-")
133
+ list_block("Added Risks", d.added_risks, "+")
134
+ list_block("Removed Risks", d.removed_risks, "-")
135
+
136
+ # Blank line between blocks.
137
+ return "\n\n".join(blocks)
138
+
139
+
140
+ def render_diff_json(d: Diff, old_path: str, new_path: str) -> str:
141
+ payload = {
142
+ "old": old_path,
143
+ "new": new_path,
144
+ "added_requirements": [asdict(r) for r in d.added_requirements],
145
+ "removed_requirements": [asdict(r) for r in d.removed_requirements],
146
+ "modified_requirements": [asdict(c) for c in d.modified_requirements],
147
+ "added_metrics": d.added_metrics,
148
+ "removed_metrics": d.removed_metrics,
149
+ "added_risks": d.added_risks,
150
+ "removed_risks": d.removed_risks,
151
+ }
152
+ return json.dumps(payload, indent=2)
rac/parser.py ADDED
@@ -0,0 +1,159 @@
1
+ """Turn a Markdown requirement file into a :class:`~rac.models.Product` AST.
2
+
3
+ We tokenize with ``markdown-it-py`` and walk the (flat) token stream, tracking the
4
+ current ``##`` section. This module performs *structural extraction only* — it does
5
+ not enforce any rules. All rule-checking lives in :mod:`rac.validate`, so that
6
+ diffing and future analysis share a single source of truth.
7
+
8
+ Heading matching is case-insensitive and whitespace-trimmed, so ``## problem`` and
9
+ ``## Problem `` both work.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from markdown_it import MarkdownIt
17
+
18
+ from .models import MalformedRequirement, Product, Requirement
19
+
20
+ # A requirement line: a leading ``[...]`` ID token followed by description text.
21
+ # We capture anything inside the brackets so we can distinguish a *malformed* ID
22
+ # from a missing one, then validate the ID shape separately.
23
+ _BRACKET_RE = re.compile(r"^\[(?P<id>[^\]]*)\]\s*(?P<text>.*)$")
24
+ # Canonical requirement ID, e.g. REQ-001.
25
+ _CANONICAL_ID_RE = re.compile(r"^REQ-\d+$")
26
+
27
+ # Recognized section headings, normalized (stripped + casefolded).
28
+ _SECTIONS = {
29
+ "problem": "problem",
30
+ "requirements": "requirements",
31
+ "success metrics": "success_metrics",
32
+ "risks": "risks",
33
+ }
34
+
35
+
36
+ def _normalize_heading(text: str) -> str:
37
+ return text.strip().casefold()
38
+
39
+
40
+ def _content_lines(content: str, start_line: int) -> list[tuple[str, int]]:
41
+ """Split an inline token's content into ``(text, 1-based-line)`` pairs.
42
+
43
+ ``start_line`` is the 0-based line where the enclosing block begins (from the
44
+ token's ``.map``). Blank lines are dropped but still advance the line counter.
45
+ """
46
+ pairs: list[tuple[str, int]] = []
47
+ for offset, raw in enumerate(content.split("\n")):
48
+ stripped = raw.strip()
49
+ if stripped:
50
+ pairs.append((stripped, start_line + offset + 1))
51
+ return pairs
52
+
53
+
54
+ def _classify_requirement_line(text: str, line: int):
55
+ """Return either a :class:`Requirement` or :class:`MalformedRequirement`."""
56
+ m = _BRACKET_RE.match(text)
57
+ if not m:
58
+ # No recognizable ``[...]`` prefix at all.
59
+ return MalformedRequirement(raw=text, line=line, bad_id=None)
60
+ req_id = m.group("id").strip()
61
+ desc = m.group("text").strip()
62
+ if not _CANONICAL_ID_RE.match(req_id):
63
+ return MalformedRequirement(raw=text, line=line, bad_id=req_id)
64
+ if not desc:
65
+ return MalformedRequirement(
66
+ raw=text, line=line, bad_id=req_id, empty_text=True
67
+ )
68
+ return Requirement(id=req_id, text=desc, line=line)
69
+
70
+
71
+ def parse(text: str, source_path: str = "") -> Product:
72
+ """Parse Markdown ``text`` into a :class:`Product`."""
73
+ tokens = MarkdownIt("commonmark").parse(text)
74
+
75
+ title: str | None = None
76
+ extra_title_lines: list[int] = []
77
+ section: str | None = None # current tracked section key, or None/"other"
78
+
79
+ problem_lines: list[str] = []
80
+ requirement_lines: list[tuple[str, int]] = []
81
+ metric_lines: list[str] = []
82
+ risk_lines: list[str] = []
83
+
84
+ has = {
85
+ "problem": False,
86
+ "requirements": False,
87
+ "success_metrics": False,
88
+ "risks": False,
89
+ }
90
+
91
+ for i, tok in enumerate(tokens):
92
+ if tok.type == "heading_open":
93
+ heading_text = tokens[i + 1].content if i + 1 < len(tokens) else ""
94
+ if tok.tag == "h1":
95
+ if title is None:
96
+ title = heading_text.strip()
97
+ else:
98
+ extra_title_lines.append((tok.map[0] + 1) if tok.map else 0)
99
+ section = None # content directly under the title is ignored
100
+ elif tok.tag == "h2":
101
+ key = _SECTIONS.get(_normalize_heading(heading_text))
102
+ section = key
103
+ if key is not None:
104
+ has[key] = True
105
+ else:
106
+ section = "other"
107
+ continue
108
+
109
+ if tok.type != "inline" or section is None or section == "other":
110
+ continue
111
+
112
+ # Skip the inline that *is* a heading's text.
113
+ if i > 0 and tokens[i - 1].type == "heading_open":
114
+ continue
115
+
116
+ start_line = tok.map[0] if tok.map else 0
117
+ lines = _content_lines(tok.content, start_line)
118
+
119
+ if section == "problem":
120
+ problem_lines.extend(t for t, _ in lines)
121
+ elif section == "requirements":
122
+ requirement_lines.extend(lines)
123
+ elif section == "success_metrics":
124
+ metric_lines.extend(t for t, _ in lines)
125
+ elif section == "risks":
126
+ risk_lines.extend(t for t, _ in lines)
127
+
128
+ requirements: list[Requirement] = []
129
+ malformed: list[MalformedRequirement] = []
130
+ for line_text, line_no in requirement_lines:
131
+ result = _classify_requirement_line(line_text, line_no)
132
+ if isinstance(result, Requirement):
133
+ requirements.append(result)
134
+ else:
135
+ malformed.append(result)
136
+
137
+ # None = section absent; "" = present but empty; otherwise the joined text.
138
+ problem = "\n".join(problem_lines).strip() if has["problem"] else None
139
+
140
+ return Product(
141
+ title=title,
142
+ extra_title_lines=extra_title_lines,
143
+ problem=problem,
144
+ requirements=requirements,
145
+ malformed_requirements=malformed,
146
+ success_metrics=metric_lines,
147
+ risks=risk_lines,
148
+ has_problem_section=has["problem"],
149
+ has_requirements_section=has["requirements"],
150
+ has_metrics_section=has["success_metrics"],
151
+ has_risks_section=has["risks"],
152
+ source_path=source_path,
153
+ )
154
+
155
+
156
+ def parse_file(path: str) -> Product:
157
+ """Read ``path`` and parse it into a :class:`Product`."""
158
+ with open(path, encoding="utf-8") as fh:
159
+ return parse(fh.read(), source_path=path)
rac/validate.py ADDED
@@ -0,0 +1,174 @@
1
+ """Validate a :class:`~rac.models.Product` against RAC's format rules.
2
+
3
+ Returns a flat list of :class:`~rac.models.Issue` objects (errors and warnings);
4
+ it never stops at the first problem. Whether a run "fails" is the CLI's decision,
5
+ based on whether any ``error``-severity issues are present.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import Counter
12
+
13
+ from .models import Issue, Product
14
+
15
+ # A file with more requirements than this earns a (non-failing) warning.
16
+ MAX_REQUIREMENTS = 50
17
+
18
+ # Vague verbs that tend to hide unspecified behavior.
19
+ AMBIGUOUS_VERBS = ("support", "handle", "allow", "enable")
20
+ _AMBIGUOUS_RE = re.compile(
21
+ r"\b(" + "|".join(AMBIGUOUS_VERBS) + r")\b", re.IGNORECASE
22
+ )
23
+
24
+
25
+ def has_errors(issues: list[Issue]) -> bool:
26
+ """True if any issue is error-severity."""
27
+ return any(issue.severity == "error" for issue in issues)
28
+
29
+
30
+ def validate(product: Product) -> list[Issue]:
31
+ """Check ``product`` and return all structural and quality findings."""
32
+ issues: list[Issue] = []
33
+
34
+ # --- Hard failures: structure -------------------------------------------
35
+ if not product.title:
36
+ issues.append(Issue("error", "missing-title", "File has no top-level # title."))
37
+
38
+ if product.extra_title_lines:
39
+ # One error regardless of how many extra titles there are; point at the
40
+ # first offending title.
41
+ issues.append(
42
+ Issue(
43
+ "error",
44
+ "multiple-titles",
45
+ "File has more than one top-level # title; expected exactly one.",
46
+ product.extra_title_lines[0],
47
+ )
48
+ )
49
+
50
+ if not product.has_problem_section:
51
+ issues.append(
52
+ Issue("error", "missing-problem", "File is missing a ## Problem section.")
53
+ )
54
+
55
+ if not product.has_requirements_section:
56
+ issues.append(
57
+ Issue(
58
+ "error",
59
+ "missing-requirements",
60
+ "File is missing a ## Requirements section.",
61
+ )
62
+ )
63
+
64
+ # --- Hard failures: malformed requirement lines -------------------------
65
+ for m in product.malformed_requirements:
66
+ if m.bad_id is None:
67
+ issues.append(
68
+ Issue(
69
+ "error",
70
+ "req-missing-id",
71
+ f"Requirement line has no [REQ-NNN] ID: {m.raw!r}",
72
+ m.line,
73
+ )
74
+ )
75
+ elif m.empty_text:
76
+ issues.append(
77
+ Issue(
78
+ "error",
79
+ "empty-req-text",
80
+ f"Requirement [{m.bad_id}] has no description text.",
81
+ m.line,
82
+ )
83
+ )
84
+ else:
85
+ issues.append(
86
+ Issue(
87
+ "error",
88
+ "malformed-req-id",
89
+ f"Malformed requirement ID [{m.bad_id}]; expected form [REQ-NNN].",
90
+ m.line,
91
+ )
92
+ )
93
+
94
+ # --- Hard failures: duplicate IDs ---------------------------------------
95
+ id_counts = Counter(r.id for r in product.requirements)
96
+ seen: set[str] = set()
97
+ for r in product.requirements:
98
+ if id_counts[r.id] > 1 and r.id not in seen:
99
+ seen.add(r.id)
100
+ issues.append(
101
+ Issue(
102
+ "error",
103
+ "duplicate-req-id",
104
+ f"Duplicate requirement ID {r.id} (used {id_counts[r.id]} times).",
105
+ r.line,
106
+ )
107
+ )
108
+
109
+ # --- Warnings: missing optional sections --------------------------------
110
+ if not product.has_metrics_section:
111
+ issues.append(
112
+ Issue(
113
+ "warning",
114
+ "missing-success-metrics",
115
+ "No ## Success Metrics section (optional, but recommended).",
116
+ )
117
+ )
118
+ if not product.has_risks_section:
119
+ issues.append(
120
+ Issue(
121
+ "warning",
122
+ "missing-risks",
123
+ "No ## Risks section (optional, but recommended).",
124
+ )
125
+ )
126
+
127
+ # --- Warnings: empty problem --------------------------------------------
128
+ if product.has_problem_section and not (product.problem or "").strip():
129
+ issues.append(
130
+ Issue("warning", "empty-problem", "## Problem section is empty.")
131
+ )
132
+
133
+ # --- Warnings: too many requirements ------------------------------------
134
+ if len(product.requirements) > MAX_REQUIREMENTS:
135
+ issues.append(
136
+ Issue(
137
+ "warning",
138
+ "too-many-requirements",
139
+ f"{len(product.requirements)} requirements "
140
+ f"(more than {MAX_REQUIREMENTS}); consider splitting the feature.",
141
+ )
142
+ )
143
+
144
+ # --- Warnings: duplicate requirement text -------------------------------
145
+ text_counts = Counter(r.text.strip().casefold() for r in product.requirements)
146
+ seen_text: set[str] = set()
147
+ for r in product.requirements:
148
+ key = r.text.strip().casefold()
149
+ if text_counts[key] > 1 and key not in seen_text:
150
+ seen_text.add(key)
151
+ issues.append(
152
+ Issue(
153
+ "warning",
154
+ "duplicate-req-text",
155
+ f"Duplicate requirement text: {r.text!r}.",
156
+ r.line,
157
+ )
158
+ )
159
+
160
+ # --- Warnings: ambiguous verbs ------------------------------------------
161
+ for r in product.requirements:
162
+ found = _AMBIGUOUS_RE.findall(r.text)
163
+ if found:
164
+ verbs = ", ".join(sorted({v.lower() for v in found}))
165
+ issues.append(
166
+ Issue(
167
+ "warning",
168
+ "ambiguous-verb",
169
+ f"{r.id} uses ambiguous verb(s) ({verbs}); be more specific.",
170
+ r.line,
171
+ )
172
+ )
173
+
174
+ return issues
@@ -0,0 +1,440 @@
1
+ Metadata-Version: 2.4
2
+ Name: requirements-as-code
3
+ Version: 0.1.0
4
+ Summary: RAC — lint and diff product requirements written in Markdown.
5
+ Author: RAC
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: markdown-it-py>=3.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.0; extra == "dev"
12
+
13
+ # RAC (Requirements-as-Code)
14
+
15
+ > Lint, diff, and analyse product requirements from the command line
16
+
17
+ Product requirements are often trapped in documents, making them difficult to review, validate, and track over time.
18
+
19
+ RAC brings software engineering workflows to product requirements.
20
+
21
+ Write requirements in Markdown. Store them in Git. Validate them, compare versions, and analyse change over time.
22
+
23
+ ```text
24
+ Markdown
25
+
26
+ Product Model
27
+
28
+ Validation
29
+
30
+ Diffing
31
+
32
+ Portfolio Analysis
33
+
34
+ AI Review (future)
35
+ ```
36
+
37
+ ## Why RAC?
38
+
39
+ Engineers have mature tooling for code:
40
+
41
+ - Linters
42
+ - Code review
43
+ - Diffs
44
+ - Static analysis
45
+ - Version control
46
+
47
+ Product requirements typically have none of these.
48
+
49
+ RAC applies the same principles to product requirements.
50
+
51
+ ### Validate
52
+
53
+ ```bash
54
+ rac validate bond_dashboard.md
55
+ ```
56
+
57
+ ### Compare
58
+
59
+ ```bash
60
+ rac diff bond_dashboard_v1.md bond_dashboard_v2.md
61
+ ```
62
+
63
+ ### Analyse
64
+
65
+ ```bash
66
+ rac stats ./features
67
+ ```
68
+
69
+ *(planned for v0.2)*
70
+
71
+ ---
72
+
73
+ # Example
74
+
75
+ Create a requirement file:
76
+
77
+ ```markdown
78
+ # Bond Dashboard
79
+
80
+ ## Problem
81
+
82
+ Retail investors struggle to understand interest-rate exposure.
83
+
84
+ ## Requirements
85
+
86
+ - [REQ-001] User can view portfolio holdings
87
+ - [REQ-002] User can view portfolio duration
88
+ - [REQ-003] User can view portfolio yield
89
+
90
+ ## Success Metrics
91
+
92
+ - Monthly Active Users
93
+ - Dashboard Views
94
+
95
+ ## Risks
96
+
97
+ - Inaccurate market data
98
+ ```
99
+
100
+ Validate it:
101
+
102
+ ```bash
103
+ rac validate bond_dashboard.md
104
+ ```
105
+
106
+ Output:
107
+
108
+ ```text
109
+ PASS
110
+ ```
111
+
112
+ Now compare two versions:
113
+
114
+ ```bash
115
+ rac diff bond_dashboard_v1.md bond_dashboard_v2.md
116
+ ```
117
+
118
+ Output:
119
+
120
+ ```text
121
+ Added Requirements
122
+
123
+ + REQ-004 View projected yield forecast
124
+
125
+ Modified Requirements
126
+
127
+ ~ REQ-002
128
+
129
+ Before:
130
+ User can view portfolio duration
131
+
132
+ After:
133
+ User can view and compare portfolio duration
134
+ ```
135
+
136
+ RAC compares **product changes**, not just text changes.
137
+
138
+ ---
139
+
140
+ # Installation
141
+
142
+ ## Using pip
143
+
144
+ ```bash
145
+ pip install requirements-as-code
146
+ ```
147
+
148
+ ## Using uv
149
+
150
+ ```bash
151
+ uv tool install requirements-as-code
152
+ ```
153
+
154
+ Verify installation:
155
+
156
+ ```bash
157
+ rac --help
158
+ ```
159
+
160
+ ---
161
+
162
+ # Quick Start
163
+
164
+ Create a file:
165
+
166
+ ```bash
167
+ touch feature.md
168
+ ```
169
+
170
+ Add requirements:
171
+
172
+ ```markdown
173
+ # Trade Alerts
174
+
175
+ ## Problem
176
+
177
+ Investors miss important market movements.
178
+
179
+ ## Requirements
180
+
181
+ - [REQ-001] User can create a trade alert
182
+ - [REQ-002] User can edit a trade alert
183
+ - [REQ-003] User can delete a trade alert
184
+ ```
185
+
186
+ Validate:
187
+
188
+ ```bash
189
+ rac validate feature.md
190
+ ```
191
+
192
+ Compare versions:
193
+
194
+ ```bash
195
+ rac diff old.md new.md
196
+ ```
197
+
198
+ ---
199
+
200
+ # Philosophy
201
+
202
+ RAC follows a few simple principles.
203
+
204
+ ## Markdown First
205
+
206
+ Requirements should remain easy to write and review.
207
+
208
+ RAC uses Markdown as the source format.
209
+
210
+ No proprietary editors.
211
+
212
+ No custom file formats.
213
+
214
+ ## Git Native
215
+
216
+ Requirements should work naturally inside:
217
+
218
+ - GitHub
219
+ - GitLab
220
+ - VS Code
221
+ - Cursor
222
+ - Claude Code
223
+
224
+ ## AI Optional
225
+
226
+ RAC should be useful without AI.
227
+
228
+ The foundation is:
229
+
230
+ - structure
231
+ - validation
232
+ - diffing
233
+ - analysis
234
+
235
+ AI is an enhancement, not a dependency.
236
+
237
+ ## Product Model
238
+
239
+ Internally, RAC converts Markdown into a structured Product Model.
240
+
241
+ ```text
242
+ Markdown
243
+
244
+ Parser
245
+
246
+ Feature Model
247
+
248
+ Validation
249
+
250
+ Diffing
251
+
252
+ Stats
253
+ ```
254
+
255
+ This enables reliable analysis without relying on fragile text processing.
256
+
257
+ ---
258
+
259
+ # Markdown Specification
260
+
261
+ Every feature is represented by a single Markdown file.
262
+
263
+ Example:
264
+
265
+ ```markdown
266
+ # Feature Title
267
+
268
+ ## Problem
269
+
270
+ Problem statement.
271
+
272
+ ## Requirements
273
+
274
+ - [REQ-001] Requirement text
275
+ - [REQ-002] Requirement text
276
+
277
+ ## Success Metrics
278
+
279
+ - Metric 1
280
+
281
+ ## Risks
282
+
283
+ - Risk 1
284
+ ```
285
+
286
+ ## Required Sections
287
+
288
+ Required:
289
+
290
+ - `# Title`
291
+ - `## Problem`
292
+ - `## Requirements`
293
+
294
+ Optional (recommended):
295
+
296
+ - `## Success Metrics`
297
+ - `## Risks`
298
+
299
+ ---
300
+
301
+ # Commands
302
+
303
+ ## Validate
304
+
305
+ Validate a requirement file.
306
+
307
+ ```bash
308
+ rac validate feature.md
309
+ ```
310
+
311
+ Checks:
312
+
313
+ - Required sections exist
314
+ - Requirement IDs are valid
315
+ - Requirement IDs are unique
316
+ - Requirement text is not empty
317
+
318
+ Warnings:
319
+
320
+ - Missing risks
321
+ - Missing success metrics
322
+ - Duplicate requirement text
323
+ - Ambiguous wording
324
+
325
+ ---
326
+
327
+ ## Diff
328
+
329
+ Compare two versions of a feature.
330
+
331
+ ```bash
332
+ rac diff old.md new.md
333
+ ```
334
+
335
+ Detects:
336
+
337
+ - Added requirements
338
+ - Removed requirements
339
+ - Modified requirements
340
+ - Added metrics
341
+ - Removed metrics
342
+ - Added risks
343
+ - Removed risks
344
+
345
+ Requirements are matched by ID.
346
+
347
+ ---
348
+
349
+ ## Stats (Planned)
350
+
351
+ Portfolio-level analysis.
352
+
353
+ ```bash
354
+ rac stats ./features
355
+ ```
356
+
357
+ Example output:
358
+
359
+ ```text
360
+ Portfolio Overview
361
+ ==================
362
+
363
+ Features: 12
364
+
365
+ Requirements: 87
366
+
367
+ Success Metrics: 24
368
+
369
+ Risks: 18
370
+
371
+ Features Missing Risks: 3
372
+
373
+ Features Missing Metrics: 2
374
+ ```
375
+
376
+ ---
377
+
378
+ ## Review (Planned)
379
+
380
+ AI-assisted product review.
381
+
382
+ ```bash
383
+ rac review feature.md
384
+ ```
385
+
386
+ Potential checks:
387
+
388
+ - Missing requirements
389
+ - Missing risks
390
+ - Ambiguity
391
+ - Product concerns
392
+ - Engineering concerns
393
+
394
+ RAC will use the user's configured AI provider rather than requiring hosted infrastructure.
395
+
396
+ ---
397
+
398
+ # Roadmap
399
+
400
+ ## v0.1
401
+
402
+ - Markdown parser
403
+ - Product Model (AST)
404
+ - Validation
405
+ - Diffing
406
+ - CLI
407
+
408
+ ## v0.2
409
+
410
+ - Portfolio statistics
411
+ - Quality metrics
412
+ - Repository-wide analysis
413
+
414
+ ## v0.3
415
+
416
+ - AI review
417
+ - Provider abstraction
418
+ - Git-aware workflows
419
+
420
+ ## v1.0
421
+
422
+ - Product intelligence
423
+ - Daily product briefs
424
+ - VS Code integration
425
+
426
+ ---
427
+
428
+ # Contributing
429
+
430
+ Contributions, ideas, and feedback are welcome.
431
+
432
+ The project is intentionally focused on one goal:
433
+
434
+ > Treat product requirements like code.
435
+
436
+ ---
437
+
438
+ # License
439
+
440
+ MIT
@@ -0,0 +1,11 @@
1
+ rac/cli.py,sha256=OJtnI0aW_RDoiVhIg3x6uADSs_mjkiLE0lhq-1kjYt4,2996
2
+ rac/diff.py,sha256=bIgV5b7HSLqnkqVO_7XIwfbcKW1v0x5n4b6u-pOPU68,2354
3
+ rac/models.py,sha256=qQ1AASbUhgCpGHpsZtui_wXVt8ymZIWDPY8h6BmAjLA,3581
4
+ rac/output.py,sha256=3EIBxJHwGmKrOhX1V2RzwSUyLimsy55B4bYjGq01yWc,4446
5
+ rac/parser.py,sha256=PwW5ZgdaAA2RLD2qmDRSKtxfidRyqzA2i9EIvGzAgQ8,5729
6
+ rac/validate.py,sha256=Xm1sa_DLltLx1v2n-mUk-BnGz7BaIThMO9QASJaWAL8,5913
7
+ requirements_as_code-0.1.0.dist-info/METADATA,sha256=pt16b3A6zBvmA1kY87SeO-CjUP3bQyI9RkGm3mqm4ms,5603
8
+ requirements_as_code-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ requirements_as_code-0.1.0.dist-info/entry_points.txt,sha256=qcG8cyRxeHDQ0XisELlsOZaefADoDEvHMLRbWO4igr4,37
10
+ requirements_as_code-0.1.0.dist-info/top_level.txt,sha256=XEcpL2fCC4e7IAX56SQgC_MY8kOSXUDKLeDQdo5qwIE,4
11
+ requirements_as_code-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rac = rac.cli:main