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.
Files changed (44) hide show
  1. reqdeck/__init__.py +3 -0
  2. reqdeck/__main__.py +7 -0
  3. reqdeck/cli.py +706 -0
  4. reqdeck/config.py +185 -0
  5. reqdeck/editor.py +51 -0
  6. reqdeck/init.py +317 -0
  7. reqdeck/markers.py +65 -0
  8. reqdeck/model.py +393 -0
  9. reqdeck/ops.py +124 -0
  10. reqdeck/questions.py +62 -0
  11. reqdeck/render.py +140 -0
  12. reqdeck/skills/reqdeck-cli/SKILL.md +166 -0
  13. reqdeck/skills/reqdeck-method/SKILL.md +166 -0
  14. reqdeck/skills/reqdeck-method/references/markers.md +92 -0
  15. reqdeck/skills/reqdeck-method/references/writing-requirements.md +125 -0
  16. reqdeck/store.py +186 -0
  17. reqdeck/templates/OPEN-QUESTIONS.md +23 -0
  18. reqdeck/themes/README.md +31 -0
  19. reqdeck/themes/__init__.py +73 -0
  20. reqdeck/themes/atom-one-dark.toml +27 -0
  21. reqdeck/themes/atom-one-light.toml +27 -0
  22. reqdeck/themes/catppuccin-frappe.toml +27 -0
  23. reqdeck/themes/catppuccin-latte.toml +27 -0
  24. reqdeck/themes/catppuccin-macchiato.toml +27 -0
  25. reqdeck/themes/catppuccin-mocha.toml +27 -0
  26. reqdeck/themes/dracula.toml +27 -0
  27. reqdeck/themes/flexoki.toml +27 -0
  28. reqdeck/themes/gruvbox.toml +27 -0
  29. reqdeck/themes/monokai.toml +27 -0
  30. reqdeck/themes/nord.toml +27 -0
  31. reqdeck/themes/rose-pine-dawn.toml +27 -0
  32. reqdeck/themes/rose-pine-moon.toml +27 -0
  33. reqdeck/themes/rose-pine.toml +27 -0
  34. reqdeck/themes/solarized-dark.toml +27 -0
  35. reqdeck/themes/solarized-light.toml +27 -0
  36. reqdeck/themes/textual-dark.toml +27 -0
  37. reqdeck/themes/textual-light.toml +27 -0
  38. reqdeck/themes/tokyo-night.toml +27 -0
  39. reqdeck/viewer.py +1452 -0
  40. reqdeck-0.1.0.dist-info/METADATA +305 -0
  41. reqdeck-0.1.0.dist-info/RECORD +44 -0
  42. reqdeck-0.1.0.dist-info/WHEEL +4 -0
  43. reqdeck-0.1.0.dist-info/entry_points.txt +2 -0
  44. 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