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 +100 -0
- rac/diff.py +69 -0
- rac/models.py +107 -0
- rac/output.py +152 -0
- rac/parser.py +159 -0
- rac/validate.py +174 -0
- requirements_as_code-0.1.0.dist-info/METADATA +440 -0
- requirements_as_code-0.1.0.dist-info/RECORD +11 -0
- requirements_as_code-0.1.0.dist-info/WHEEL +5 -0
- requirements_as_code-0.1.0.dist-info/entry_points.txt +2 -0
- requirements_as_code-0.1.0.dist-info/top_level.txt +1 -0
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 @@
|
|
|
1
|
+
rac
|