reqdeck 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.
- reqdeck/__init__.py +3 -0
- reqdeck/__main__.py +7 -0
- reqdeck/cli.py +706 -0
- reqdeck/config.py +185 -0
- reqdeck/editor.py +51 -0
- reqdeck/init.py +317 -0
- reqdeck/markers.py +65 -0
- reqdeck/model.py +393 -0
- reqdeck/ops.py +124 -0
- reqdeck/questions.py +62 -0
- reqdeck/render.py +140 -0
- reqdeck/skills/reqdeck-cli/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/SKILL.md +166 -0
- reqdeck/skills/reqdeck-method/references/markers.md +92 -0
- reqdeck/skills/reqdeck-method/references/writing-requirements.md +125 -0
- reqdeck/store.py +186 -0
- reqdeck/templates/OPEN-QUESTIONS.md +23 -0
- reqdeck/themes/README.md +31 -0
- reqdeck/themes/__init__.py +73 -0
- reqdeck/themes/atom-one-dark.toml +27 -0
- reqdeck/themes/atom-one-light.toml +27 -0
- reqdeck/themes/catppuccin-frappe.toml +27 -0
- reqdeck/themes/catppuccin-latte.toml +27 -0
- reqdeck/themes/catppuccin-macchiato.toml +27 -0
- reqdeck/themes/catppuccin-mocha.toml +27 -0
- reqdeck/themes/dracula.toml +27 -0
- reqdeck/themes/flexoki.toml +27 -0
- reqdeck/themes/gruvbox.toml +27 -0
- reqdeck/themes/monokai.toml +27 -0
- reqdeck/themes/nord.toml +27 -0
- reqdeck/themes/rose-pine-dawn.toml +27 -0
- reqdeck/themes/rose-pine-moon.toml +27 -0
- reqdeck/themes/rose-pine.toml +27 -0
- reqdeck/themes/solarized-dark.toml +27 -0
- reqdeck/themes/solarized-light.toml +27 -0
- reqdeck/themes/textual-dark.toml +27 -0
- reqdeck/themes/textual-light.toml +27 -0
- reqdeck/themes/tokyo-night.toml +27 -0
- reqdeck/viewer.py +1452 -0
- reqdeck-0.1.0.dist-info/METADATA +305 -0
- reqdeck-0.1.0.dist-info/RECORD +44 -0
- reqdeck-0.1.0.dist-info/WHEEL +4 -0
- reqdeck-0.1.0.dist-info/entry_points.txt +2 -0
- reqdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
reqdeck/config.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Project configuration: `.reqdeck.toml` in the root of the project using reqdeck.
|
|
2
|
+
|
|
3
|
+
Everything project-specific lives here — where the files are, which levels of
|
|
4
|
+
requirement exist, which verification methods, and how `@REQ_REF` markers are
|
|
5
|
+
found. The rest of the tool reads only this object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CONFIG_NAME = ".reqdeck.toml"
|
|
16
|
+
|
|
17
|
+
#: Review status. Fixed rather than configurable: it is the one signal that a
|
|
18
|
+
#: human has read a requirement, and only the user ever sets Accepted.
|
|
19
|
+
STATUS = {
|
|
20
|
+
"Proposed": "written, not yet reviewed",
|
|
21
|
+
"Accepted": "reviewed and agreed by the user",
|
|
22
|
+
"Reopened": "was accepted, then changed; needs review again",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
DEFAULT_VERIFICATION = {
|
|
26
|
+
"A": ("Analysis", "Calculation, modelling or simulation shows the requirement is met.", False),
|
|
27
|
+
"I": ("Inspection", "Examination of the design, drawings or source, without operating it.", False),
|
|
28
|
+
"D": ("Demonstration", "Operate the system and observe the expected behaviour.", False),
|
|
29
|
+
"T": ("Test", "An automated test shows the requirement is met.", True),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
DEFAULT_EXTENSIONS = [".c", ".h", ".cpp", ".hpp", ".cc", ".py", ".swift", ".rs", ".go",
|
|
33
|
+
".js", ".ts", ".kt", ".java", ".m", ".mm", ".s", ".S"]
|
|
34
|
+
DEFAULT_SKIP = ["docs", "node_modules", ".build", "build", "dist", "__pycache__",
|
|
35
|
+
".venv", "venv", "DerivedData", "Pods"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Level:
|
|
44
|
+
kind: str
|
|
45
|
+
title: str = ""
|
|
46
|
+
#: Kinds this level may name in `satisfies`. Empty means a top level.
|
|
47
|
+
satisfies: list[str] = field(default_factory=list)
|
|
48
|
+
#: Every requirement of this level names the OPEN-QUESTIONS topic behind it.
|
|
49
|
+
topic: bool = False
|
|
50
|
+
#: Carries source, verification and status. False for user stories.
|
|
51
|
+
reviewed: bool = True
|
|
52
|
+
#: Code and tests may reference it directly. False for a level that is only
|
|
53
|
+
#: ever traced through what satisfies it.
|
|
54
|
+
traced: bool = True
|
|
55
|
+
separator: str = "_"
|
|
56
|
+
digits: int = 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Verification:
|
|
61
|
+
code: str
|
|
62
|
+
name: str
|
|
63
|
+
definition: str
|
|
64
|
+
#: Discharged by an automated test, so untraced until a test names it.
|
|
65
|
+
automated: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Markers:
|
|
70
|
+
extensions: list[str] = field(default_factory=lambda: list(DEFAULT_EXTENSIONS))
|
|
71
|
+
skip: list[str] = field(default_factory=lambda: list(DEFAULT_SKIP))
|
|
72
|
+
#: A path containing any of these, case-insensitively, is a test.
|
|
73
|
+
test_paths: list[str] = field(default_factory=lambda: ["test", "spec"])
|
|
74
|
+
#: Outside tests, markers may only sit in files with these extensions.
|
|
75
|
+
#: Empty allows any source file.
|
|
76
|
+
declaration_extensions: list[str] = field(default_factory=list)
|
|
77
|
+
#: A file whose stem ends in one of these declares an interface, which
|
|
78
|
+
#: never carries a marker.
|
|
79
|
+
interface_suffixes: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Editor:
|
|
84
|
+
#: Command opening a file at a line, `{file}` and `{line}` substituted:
|
|
85
|
+
#: "nvim +{line} {file}", "code --goto {file}:{line}". Empty means the
|
|
86
|
+
#: user's $VISUAL or $EDITOR, through a preset for the common ones.
|
|
87
|
+
command: str = ""
|
|
88
|
+
#: The TUI hands the terminal over until the editor exits. Unset means
|
|
89
|
+
#: yes for a terminal editor and no for a windowed one.
|
|
90
|
+
suspend: bool | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Config:
|
|
95
|
+
root: Path
|
|
96
|
+
requirements: Path
|
|
97
|
+
questions: Path | None
|
|
98
|
+
levels: list[Level]
|
|
99
|
+
verification: dict[str, Verification]
|
|
100
|
+
markers: Markers
|
|
101
|
+
editor: Editor = field(default_factory=Editor)
|
|
102
|
+
|
|
103
|
+
def level(self, kind: str) -> Level | None:
|
|
104
|
+
return next((lv for lv in self.levels if lv.kind == kind), None)
|
|
105
|
+
|
|
106
|
+
def rel(self, p: Path) -> str:
|
|
107
|
+
return str(p.relative_to(self.root))
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def parent_kinds(self) -> set[str]:
|
|
111
|
+
"""Kinds something else exists to satisfy — so expected to be satisfied."""
|
|
112
|
+
return {k for lv in self.levels for k in lv.satisfies}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def find_root(start: Path | None = None) -> Path | None:
|
|
116
|
+
"""The nearest directory holding `.reqdeck.toml`: `REQDECK_ROOT` if set,
|
|
117
|
+
otherwise searched upward from the current directory."""
|
|
118
|
+
env = os.environ.get("REQDECK_ROOT")
|
|
119
|
+
if env:
|
|
120
|
+
return Path(env).expanduser().resolve()
|
|
121
|
+
base = (start or Path.cwd()).resolve()
|
|
122
|
+
for d in (base, *base.parents):
|
|
123
|
+
if (d / CONFIG_NAME).is_file():
|
|
124
|
+
return d
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def load(root: Path) -> Config:
|
|
129
|
+
path = root / CONFIG_NAME
|
|
130
|
+
try:
|
|
131
|
+
data = tomllib.loads(path.read_text())
|
|
132
|
+
except tomllib.TOMLDecodeError as e:
|
|
133
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
134
|
+
|
|
135
|
+
if "requirements" not in data:
|
|
136
|
+
raise ConfigError(f"{path}: `requirements` (the folder holding the CSV files) is required")
|
|
137
|
+
|
|
138
|
+
levels = []
|
|
139
|
+
for raw in data.get("levels", []):
|
|
140
|
+
if "kind" not in raw:
|
|
141
|
+
raise ConfigError(f"{path}: every [[levels]] entry needs a kind")
|
|
142
|
+
unknown = set(raw) - set(Level.__dataclass_fields__)
|
|
143
|
+
if unknown:
|
|
144
|
+
raise ConfigError(f"{path}: level {raw['kind']}: unknown key(s) {', '.join(sorted(unknown))}")
|
|
145
|
+
levels.append(Level(**raw))
|
|
146
|
+
if not levels:
|
|
147
|
+
raise ConfigError(f"{path}: at least one [[levels]] entry is required")
|
|
148
|
+
kinds = {lv.kind for lv in levels}
|
|
149
|
+
for lv in levels:
|
|
150
|
+
for k in lv.satisfies:
|
|
151
|
+
if k not in kinds:
|
|
152
|
+
raise ConfigError(f"{path}: level {lv.kind} satisfies unknown level {k}")
|
|
153
|
+
|
|
154
|
+
verification = {}
|
|
155
|
+
for code, (name, definition, automated) in DEFAULT_VERIFICATION.items():
|
|
156
|
+
verification[code] = Verification(code, name, definition, automated)
|
|
157
|
+
if "verification" in data:
|
|
158
|
+
verification = {
|
|
159
|
+
code: Verification(code, v.get("name", code), v.get("definition", ""),
|
|
160
|
+
bool(v.get("automated", False)))
|
|
161
|
+
for code, v in data["verification"].items()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
m = data.get("markers", {})
|
|
165
|
+
unknown = set(m) - set(Markers.__dataclass_fields__)
|
|
166
|
+
if unknown:
|
|
167
|
+
raise ConfigError(f"{path}: [markers]: unknown key(s) {', '.join(sorted(unknown))}")
|
|
168
|
+
markers = Markers(**m)
|
|
169
|
+
|
|
170
|
+
e = data.get("editor", {})
|
|
171
|
+
unknown = set(e) - set(Editor.__dataclass_fields__)
|
|
172
|
+
if unknown:
|
|
173
|
+
raise ConfigError(f"{path}: [editor]: unknown key(s) {', '.join(sorted(unknown))}")
|
|
174
|
+
editor = Editor(**e)
|
|
175
|
+
|
|
176
|
+
q = data.get("questions")
|
|
177
|
+
return Config(
|
|
178
|
+
root=root,
|
|
179
|
+
requirements=root / data["requirements"],
|
|
180
|
+
questions=root / q if q else None,
|
|
181
|
+
levels=levels,
|
|
182
|
+
verification=verification,
|
|
183
|
+
markers=markers,
|
|
184
|
+
editor=editor,
|
|
185
|
+
)
|
reqdeck/editor.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Opening a file at a line in the user's editor, as lazygit does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import Editor
|
|
10
|
+
|
|
11
|
+
#: Editor executable to (command template, runs in the terminal).
|
|
12
|
+
PRESETS = {
|
|
13
|
+
"nvim": ("nvim +{line} {file}", True),
|
|
14
|
+
"vim": ("vim +{line} {file}", True),
|
|
15
|
+
"vi": ("vi +{line} {file}", True),
|
|
16
|
+
"nano": ("nano +{line} {file}", True),
|
|
17
|
+
"micro": ("micro {file}:{line}", True),
|
|
18
|
+
"hx": ("hx {file}:{line}", True),
|
|
19
|
+
"helix": ("helix {file}:{line}", True),
|
|
20
|
+
"kak": ("kak +{line} {file}", True),
|
|
21
|
+
"emacs": ("emacs -nw +{line} {file}", True),
|
|
22
|
+
"code": ("code --goto {file}:{line}", False),
|
|
23
|
+
"cursor": ("cursor --goto {file}:{line}", False),
|
|
24
|
+
"codium": ("codium --goto {file}:{line}", False),
|
|
25
|
+
"zed": ("zed {file}:{line}", False),
|
|
26
|
+
"subl": ("subl {file}:{line}", False),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve(editor: Editor) -> tuple[str, bool]:
|
|
31
|
+
"""The command template and whether it runs in the terminal."""
|
|
32
|
+
template = editor.command.strip()
|
|
33
|
+
if not template:
|
|
34
|
+
env = (os.environ.get("VISUAL") or os.environ.get("EDITOR") or "vi").strip()
|
|
35
|
+
exe = Path(shlex.split(env)[0]).name
|
|
36
|
+
template, terminal = PRESETS.get(exe, (f"{env} {{file}}", True))
|
|
37
|
+
if exe in PRESETS and env != exe:
|
|
38
|
+
# Keep the user's own path or flags, with the preset's arguments.
|
|
39
|
+
template = env + template[len(exe):]
|
|
40
|
+
else:
|
|
41
|
+
exe = Path(shlex.split(template)[0]).name
|
|
42
|
+
terminal = PRESETS.get(exe, ("", True))[1]
|
|
43
|
+
if editor.suspend is not None:
|
|
44
|
+
terminal = editor.suspend
|
|
45
|
+
return template, terminal
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def command(editor: Editor, file: str, line: int) -> tuple[list[str], bool]:
|
|
49
|
+
"""argv opening `file` at `line`, and whether the terminal must be handed over."""
|
|
50
|
+
template, terminal = resolve(editor)
|
|
51
|
+
return [part.format(file=file, line=line) for part in shlex.split(template)], terminal
|
reqdeck/init.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""`reqdeck init`: set a project up, asking as it goes.
|
|
2
|
+
|
|
3
|
+
Asks everything first, checks that none of the files it would create exist,
|
|
4
|
+
shows a summary, and writes only once that is confirmed. A failure while
|
|
5
|
+
writing removes what this run created, so a project is either set up or
|
|
6
|
+
untouched. An existing OPEN-QUESTIONS.md and existing skill folders are kept.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import store
|
|
18
|
+
from .config import CONFIG_NAME
|
|
19
|
+
|
|
20
|
+
PACKAGE = Path(__file__).resolve().parent
|
|
21
|
+
PREFIX_RE = re.compile(r"[A-Z][A-Z0-9]*")
|
|
22
|
+
PREFIX_HINT = "capitals and digits, starting with a letter"
|
|
23
|
+
PATH_RE = re.compile(r"[^/\s][^\s]*")
|
|
24
|
+
STEM_RE = re.compile(r"[a-z0-9][a-z0-9-]*")
|
|
25
|
+
SKILL_DIRS = (".claude/skills", ".agents/skills")
|
|
26
|
+
|
|
27
|
+
SHAPES = """\
|
|
28
|
+
Requirements form levels, each satisfying the one above it, rooted in user
|
|
29
|
+
stories. Pick the shape that fits — it can be edited in .reqdeck.toml later.
|
|
30
|
+
|
|
31
|
+
1) Stories → System → Sub-system + Interface
|
|
32
|
+
A product of several parts that talk to each other: firmware, hardware,
|
|
33
|
+
an app. Interface requirements describe behaviour at a boundary.
|
|
34
|
+
2) Stories → System → Sub-system
|
|
35
|
+
Several parts, no boundary worth specifying on its own.
|
|
36
|
+
3) Stories → Requirements
|
|
37
|
+
One app or one component.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Abort(Exception):
|
|
42
|
+
"""Stop before anything is written."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def skills_source() -> Path:
|
|
46
|
+
"""The skills shipped with reqdeck: inside the package when installed, at
|
|
47
|
+
the repository root in a checkout."""
|
|
48
|
+
for p in (PACKAGE / "skills", PACKAGE.parent / "skills"):
|
|
49
|
+
if p.is_dir():
|
|
50
|
+
return p
|
|
51
|
+
raise FileNotFoundError("reqdeck's skills are missing from this installation")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ask(prompt: str, default: str = "", pattern: re.Pattern | None = None, hint: str = "") -> str:
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
answer = input(f"{prompt}{f' [{default}]' if default else ''}: ").strip()
|
|
58
|
+
except EOFError:
|
|
59
|
+
raise Abort("input ended before setup was complete — nothing written") from None
|
|
60
|
+
answer = answer or default
|
|
61
|
+
if pattern is None or pattern.fullmatch(answer):
|
|
62
|
+
return answer
|
|
63
|
+
print(f" {hint or 'not a valid answer'}", file=sys.stderr)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _yes(prompt: str, default: bool) -> bool:
|
|
67
|
+
try:
|
|
68
|
+
answer = input(f"{prompt} [{'Y/n' if default else 'y/N'}]: ").strip()
|
|
69
|
+
except EOFError:
|
|
70
|
+
raise Abort("input ended before setup was complete — nothing written") from None
|
|
71
|
+
return answer.lower().startswith("y") if answer else default
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class UnitPlan:
|
|
76
|
+
stem: str
|
|
77
|
+
level: str
|
|
78
|
+
prefixes: list[str]
|
|
79
|
+
title: str
|
|
80
|
+
code: bool
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ask_prefix(prompt: str, default: str, taken: list[str], what: str) -> str:
|
|
84
|
+
while True:
|
|
85
|
+
p = _ask(prompt, default, PREFIX_RE, PREFIX_HINT)
|
|
86
|
+
if p not in taken:
|
|
87
|
+
return p
|
|
88
|
+
print(f" must differ from {what}", file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ask_units(level: str, what: str, units: list[UnitPlan]) -> None:
|
|
92
|
+
print(f"\n{what} units — one per part. Blank code to finish.")
|
|
93
|
+
while True:
|
|
94
|
+
code = _ask(" Unit code (e.g. FW, APP, BUS)", "", re.compile(r"([A-Za-z][A-Za-z0-9]*)?"),
|
|
95
|
+
"letters and digits, starting with a letter").upper()
|
|
96
|
+
if not code:
|
|
97
|
+
return
|
|
98
|
+
prefix = f"{level}_{code}"
|
|
99
|
+
if any(prefix in u.prefixes for u in units):
|
|
100
|
+
print(f" {prefix} is already a unit", file=sys.stderr)
|
|
101
|
+
continue
|
|
102
|
+
while True:
|
|
103
|
+
stem = _ask(" File name", code.lower(), STEM_RE, "lower case letters, digits and hyphens")
|
|
104
|
+
if all(stem != u.stem for u in units):
|
|
105
|
+
break
|
|
106
|
+
print(f" {stem} is already taken", file=sys.stderr)
|
|
107
|
+
title = _ask(" Title", code)
|
|
108
|
+
units.append(UnitPlan(stem, level, [prefix], title, _yes(" Implemented in code?", True)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _config(shape: str, req_dir: str, questions: str, sys_: str, sub: str, icd: str,
|
|
112
|
+
split_tests: bool, headers: bool) -> str:
|
|
113
|
+
out = ["# reqdeck configuration — https://github.com/danielriege/reqdeck", "",
|
|
114
|
+
f'requirements = "{req_dir}"', f'questions = "{questions}"', "",
|
|
115
|
+
"# Most abstract first. `satisfies` names the levels a requirement may point at.", "",
|
|
116
|
+
'[[levels]]\nkind = "STORY"\ntitle = "User story"\nreviewed = false\ntraced = false\n'
|
|
117
|
+
'separator = "-"\ndigits = 2\n']
|
|
118
|
+
if shape == "3":
|
|
119
|
+
out.append(f'[[levels]]\nkind = "{sys_}"\ntitle = "Requirement"\nsatisfies = ["STORY"]\ntopic = true\n')
|
|
120
|
+
else:
|
|
121
|
+
out.append(f'[[levels]]\nkind = "{sys_}"\ntitle = "System requirement"\nsatisfies = ["STORY"]\n'
|
|
122
|
+
f'topic = true\ntraced = false\n')
|
|
123
|
+
if shape == "1":
|
|
124
|
+
out.append(f'[[levels]]\nkind = "{icd}"\ntitle = "Interface requirement"\nsatisfies = ["{sys_}"]\n')
|
|
125
|
+
out.append(f'[[levels]]\nkind = "{sub}"\ntitle = "Sub-system requirement"\n'
|
|
126
|
+
f'satisfies = ["{sys_}", "{icd}"]\ntopic = true\n')
|
|
127
|
+
else:
|
|
128
|
+
out.append(f'[[levels]]\nkind = "{sub}"\ntitle = "Sub-system requirement"\n'
|
|
129
|
+
f'satisfies = ["{sys_}"]\ntopic = true\n')
|
|
130
|
+
methods = [("A", "Analysis", "Calculation, modelling or simulation shows the requirement is met.", False),
|
|
131
|
+
("I", "Inspection", "Examination of the design, drawings or source, without operating it.", False),
|
|
132
|
+
("D", "Demonstration", "Operate the system and observe the expected behaviour.", False)]
|
|
133
|
+
if split_tests:
|
|
134
|
+
methods += [("U", "Unit test", "Automated, on a development machine, with the hardware mocked.", True),
|
|
135
|
+
("S", "Integration test", "Automated, on the real target.", True),
|
|
136
|
+
("M", "Manual test", "Measured or run by a person, with instruments where needed.", False)]
|
|
137
|
+
else:
|
|
138
|
+
methods += [("T", "Test", "An automated test shows the requirement is met.", True)]
|
|
139
|
+
for code, name, definition, automated in methods:
|
|
140
|
+
out.append(f'[verification.{code}]\nname = "{name}"\ndefinition = "{definition}"'
|
|
141
|
+
+ ("\nautomated = true" if automated else "") + "\n")
|
|
142
|
+
out.append("[markers]")
|
|
143
|
+
if headers:
|
|
144
|
+
out += ['declaration_extensions = [".h", ".hpp", ".swift", ".py"]', 'interface_suffixes = ["_interface"]']
|
|
145
|
+
else:
|
|
146
|
+
out += ["# declaration_extensions = [] # restrict non-test markers to these file types",
|
|
147
|
+
"# interface_suffixes = [] # file stems that declare an interface, never marked"]
|
|
148
|
+
out += ["", "# Where the TUI opens a code or test reference. Unset: $VISUAL or $EDITOR.",
|
|
149
|
+
"# [editor]", '# command = "nvim +{line} {file}" # or "code --goto {file}:{line}"', ""]
|
|
150
|
+
return "\n".join(out)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def copy_skills(root: Path, dirs: list[str], created: list[Path] | None = None,
|
|
154
|
+
update: bool = False) -> tuple[list[str], list[str]]:
|
|
155
|
+
"""Copy reqdeck's skills into `dirs` of the project. Returns (written, kept).
|
|
156
|
+
|
|
157
|
+
A folder that exists is kept unless `update` and it is reqdeck's own — its
|
|
158
|
+
SKILL.md names the same skill — so a folder of the user's is never replaced."""
|
|
159
|
+
written, kept = [], []
|
|
160
|
+
for d in dirs:
|
|
161
|
+
for skill in sorted(p for p in skills_source().iterdir() if p.is_dir()):
|
|
162
|
+
target = root / d / skill.name
|
|
163
|
+
rel = f"{d}/{skill.name}"
|
|
164
|
+
if target.exists() or target.is_symlink():
|
|
165
|
+
ours = (target / "SKILL.md").is_file() and \
|
|
166
|
+
f"name: {skill.name}" in (target / "SKILL.md").read_text()
|
|
167
|
+
if not (update and ours and not target.is_symlink()):
|
|
168
|
+
kept.append(rel)
|
|
169
|
+
continue
|
|
170
|
+
shutil.rmtree(target)
|
|
171
|
+
else:
|
|
172
|
+
_mkdirs(target.parent, created)
|
|
173
|
+
if created is not None:
|
|
174
|
+
created.append(target)
|
|
175
|
+
shutil.copytree(skill, target)
|
|
176
|
+
written.append(rel)
|
|
177
|
+
return written, kept
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _mkdirs(d: Path, created: list[Path] | None) -> None:
|
|
181
|
+
missing = []
|
|
182
|
+
while not d.exists():
|
|
183
|
+
missing.append(d)
|
|
184
|
+
d = d.parent
|
|
185
|
+
for m in reversed(missing):
|
|
186
|
+
m.mkdir()
|
|
187
|
+
if created is not None:
|
|
188
|
+
created.append(m)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _rollback(created: list[Path]) -> None:
|
|
192
|
+
print("init: setup failed — removing what this run created", file=sys.stderr)
|
|
193
|
+
for p in reversed(created):
|
|
194
|
+
if p.is_symlink() or p.is_file():
|
|
195
|
+
p.unlink()
|
|
196
|
+
elif p.is_dir():
|
|
197
|
+
shutil.rmtree(p, ignore_errors=True)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run(root: Path) -> int:
|
|
201
|
+
root = root.resolve()
|
|
202
|
+
if (root / CONFIG_NAME).exists():
|
|
203
|
+
print(f"init: {CONFIG_NAME} already exists — this project is set up. Add a unit with\n"
|
|
204
|
+
f" reqdeck unit <file> --level <LEVEL> --prefix <PREFIX> --title <title>", file=sys.stderr)
|
|
205
|
+
return 1
|
|
206
|
+
try:
|
|
207
|
+
plan = _questions(root)
|
|
208
|
+
except Abort as e:
|
|
209
|
+
print(f"init: {e}", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
created: list[Path] = []
|
|
212
|
+
try:
|
|
213
|
+
_write(root, plan, created)
|
|
214
|
+
except BaseException:
|
|
215
|
+
_rollback(created)
|
|
216
|
+
raise
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _questions(root: Path) -> dict:
|
|
221
|
+
print(f"reqdeck setup in {root}\n\n{SHAPES}")
|
|
222
|
+
shape = _ask("Shape", "1", re.compile(r"[123]"), "pick 1, 2 or 3")
|
|
223
|
+
req_dir = _ask("Folder for the requirement files", "docs/requirements", PATH_RE, "a relative path without spaces")
|
|
224
|
+
questions = _ask("Open questions file", "docs/OPEN-QUESTIONS.md", PATH_RE, "a relative path without spaces")
|
|
225
|
+
while True:
|
|
226
|
+
letters = _ask("Persona letters for user stories, space-separated (A-01, B-01, …)", "A",
|
|
227
|
+
re.compile(r"[A-Z]( [A-Z])*"), "single capital letters separated by spaces, e.g. A B C").split()
|
|
228
|
+
if len(set(letters)) == len(letters):
|
|
229
|
+
break
|
|
230
|
+
print(" each letter once", file=sys.stderr)
|
|
231
|
+
|
|
232
|
+
sys_, sub, icd = "TSR", "TSSR", "ICD"
|
|
233
|
+
if shape == "3":
|
|
234
|
+
sys_ = _ask_prefix("Requirement prefix", "REQ", letters, "the persona letters")
|
|
235
|
+
else:
|
|
236
|
+
sys_ = _ask_prefix("System level prefix", "TSR", letters, "the persona letters")
|
|
237
|
+
sub = _ask_prefix("Sub-system level prefix", "TSSR", [sys_, *letters], f"{sys_} and the persona letters")
|
|
238
|
+
if shape == "1":
|
|
239
|
+
icd = _ask_prefix("Interface level prefix", "ICD", [sys_, sub, *letters],
|
|
240
|
+
f"{sys_}, {sub} and the persona letters")
|
|
241
|
+
print()
|
|
242
|
+
split_tests = _yes("Split testing into unit (U), integration (S) and manual (M) instead of one T?", False)
|
|
243
|
+
headers = _yes("C or C++ with headers — keep markers on headers, never on .c/.cpp?", False)
|
|
244
|
+
|
|
245
|
+
units = [UnitPlan("stories", "STORY", letters, "User stories", False)]
|
|
246
|
+
if shape == "3":
|
|
247
|
+
units.append(UnitPlan("requirements", sys_, [sys_], "Requirements",
|
|
248
|
+
_yes("Are the requirements implemented in code (so code must reference them)?", True)))
|
|
249
|
+
else:
|
|
250
|
+
units.append(UnitPlan("system", sys_, [sys_], "System requirements", False))
|
|
251
|
+
_ask_units(sub, "Sub-system", units)
|
|
252
|
+
if shape == "1":
|
|
253
|
+
_ask_units(icd, "Interface", units)
|
|
254
|
+
skill_dirs = [d for d in SKILL_DIRS if _yes(f"Copy the reqdeck skills into {d}?", True)]
|
|
255
|
+
|
|
256
|
+
conflicts = [f"{req_dir}/{u.stem}.{ext}" for u in units for ext in ("csv", "toml", "details.md")
|
|
257
|
+
if (root / req_dir / f"{u.stem}.{ext}").exists()]
|
|
258
|
+
if conflicts:
|
|
259
|
+
print("\n".join(f" {c}" for c in conflicts), file=sys.stderr)
|
|
260
|
+
raise Abort("these files already exist — move them away or pick other names; nothing written")
|
|
261
|
+
keep_questions = (root / questions).exists()
|
|
262
|
+
if keep_questions and not re.search(r"^## Topic \d{2} — ", (root / questions).read_text(), re.M):
|
|
263
|
+
print(f"warning: {questions} exists but has no\n '## Topic NN — Title' heading, so reqdeck will "
|
|
264
|
+
f"find no topics in it. It is kept as it is.", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
print("\nAbout to create:\n .reqdeck.toml")
|
|
267
|
+
for u in units:
|
|
268
|
+
print(f" {req_dir + '/' + u.stem + '.csv':<36} {' '.join(u.prefixes):<12} {u.title}"
|
|
269
|
+
f"{' (code)' if u.code else ''}")
|
|
270
|
+
if not keep_questions:
|
|
271
|
+
print(f" {questions}")
|
|
272
|
+
for d in skill_dirs:
|
|
273
|
+
print(f" {d}/ with the reqdeck skills")
|
|
274
|
+
print()
|
|
275
|
+
if not _yes("Create these?", True):
|
|
276
|
+
raise Abort("nothing written")
|
|
277
|
+
return dict(shape=shape, req_dir=req_dir, questions=questions, sys_=sys_, sub=sub, icd=icd,
|
|
278
|
+
split_tests=split_tests, headers=headers, units=units, skill_dirs=skill_dirs,
|
|
279
|
+
keep_questions=keep_questions)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _write(root: Path, plan: dict, created: list[Path]) -> None:
|
|
283
|
+
from . import config
|
|
284
|
+
from .model import Model
|
|
285
|
+
|
|
286
|
+
cfg_path = root / CONFIG_NAME
|
|
287
|
+
created.append(cfg_path)
|
|
288
|
+
cfg_path.write_text(_config(plan["shape"], plan["req_dir"], plan["questions"], plan["sys_"],
|
|
289
|
+
plan["sub"], plan["icd"], plan["split_tests"], plan["headers"]))
|
|
290
|
+
req_dir = root / plan["req_dir"]
|
|
291
|
+
_mkdirs(req_dir, created)
|
|
292
|
+
for u in plan["units"]:
|
|
293
|
+
created += [req_dir / f"{u.stem}.csv", req_dir / f"{u.stem}.toml"]
|
|
294
|
+
level = next(lv for lv in config.load(root).levels if lv.kind == u.level)
|
|
295
|
+
columns = list(store.STORY_COLUMNS) if not level.reviewed else \
|
|
296
|
+
[c for c in store.STANDARD if level.topic or c != "topic"]
|
|
297
|
+
store.create_unit(req_dir, u.stem, u.level, u.prefixes, u.title, u.code, columns)
|
|
298
|
+
if not plan["keep_questions"]:
|
|
299
|
+
q = root / plan["questions"]
|
|
300
|
+
_mkdirs(q.parent, created)
|
|
301
|
+
created.append(q)
|
|
302
|
+
shutil.copyfile(PACKAGE / "templates" / "OPEN-QUESTIONS.md", q)
|
|
303
|
+
_, kept = copy_skills(root, plan["skill_dirs"], created)
|
|
304
|
+
|
|
305
|
+
problems = Model.load(config.load(root)).problems()
|
|
306
|
+
if problems:
|
|
307
|
+
raise RuntimeError("the new project does not pass check:\n" + "\n".join(problems))
|
|
308
|
+
|
|
309
|
+
for k in kept:
|
|
310
|
+
print(f"kept the existing {k} — it is not reqdeck's copy")
|
|
311
|
+
print(f"""Done. Next:
|
|
312
|
+
reqdeck check validate
|
|
313
|
+
reqdeck levels the levels and units just created
|
|
314
|
+
reqdeck tui browse
|
|
315
|
+
|
|
316
|
+
Then write the product brief and start the first topics in {plan['questions']} —
|
|
317
|
+
the reqdeck-method skill describes how.""")
|
reqdeck/markers.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`@REQ_REF` markers in source and tests.
|
|
2
|
+
|
|
3
|
+
@REQ_REF(TSSR_FWN_023) no code above the abstraction may name a vendor register
|
|
4
|
+
|
|
5
|
+
One ID per marker, then a few words on what that requirement asks for.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
REQ_REF = re.compile(r"@REQ_REF\(\s*([^)]*?)\s*\)[ \t]*(.*)")
|
|
18
|
+
SPLIT = re.compile(r"[\s,;]+")
|
|
19
|
+
#: What an ID looks like: a prefix, a separator, a number. Anything else in the
|
|
20
|
+
#: parentheses, such as `@REQ_REF(<ID>)` in a comment explaining markers, is
|
|
21
|
+
#: not a marker.
|
|
22
|
+
LOOKS_LIKE_ID = re.compile(r"[A-Za-z][A-Za-z0-9_]*[_-]\d+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Ref:
|
|
27
|
+
id: str
|
|
28
|
+
file: str
|
|
29
|
+
line: int
|
|
30
|
+
is_test: bool
|
|
31
|
+
note: str = ""
|
|
32
|
+
#: Only ever populated by a malformed marker naming several IDs.
|
|
33
|
+
extra_ids: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def scan(cfg: Config) -> list[Ref]:
|
|
37
|
+
mk = cfg.markers
|
|
38
|
+
skip = set(mk.skip)
|
|
39
|
+
exts = set(mk.extensions)
|
|
40
|
+
req_dir = cfg.requirements.resolve()
|
|
41
|
+
found: list[Ref] = []
|
|
42
|
+
for dirpath, dirnames, filenames in os.walk(cfg.root):
|
|
43
|
+
here = Path(dirpath)
|
|
44
|
+
dirnames[:] = sorted(d for d in dirnames
|
|
45
|
+
if d not in skip and not d.startswith(".")
|
|
46
|
+
and (here / d).resolve() != req_dir)
|
|
47
|
+
for name in sorted(filenames):
|
|
48
|
+
if Path(name).suffix not in exts:
|
|
49
|
+
continue
|
|
50
|
+
p = here / name
|
|
51
|
+
rel = str(p.relative_to(cfg.root))
|
|
52
|
+
low = rel.lower()
|
|
53
|
+
is_test = any(t.lower() in low for t in mk.test_paths)
|
|
54
|
+
try:
|
|
55
|
+
text = p.read_text(errors="replace")
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
for m in REQ_REF.finditer(text):
|
|
59
|
+
ids = [t for t in SPLIT.split(m.group(1)) if t]
|
|
60
|
+
if not ids or not LOOKS_LIKE_ID.fullmatch(ids[0]):
|
|
61
|
+
continue
|
|
62
|
+
line = text[: m.start()].count("\n") + 1
|
|
63
|
+
note = m.group(2).strip().rstrip("*/").strip()
|
|
64
|
+
found.append(Ref(ids[0], rel, line, is_test, note, tuple(ids[1:])))
|
|
65
|
+
return found
|