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/model.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""The requirement model: everything loaded, linked and queryable.
|
|
2
|
+
|
|
3
|
+
from reqdeck.config import find_root, load
|
|
4
|
+
from reqdeck.model import Model
|
|
5
|
+
|
|
6
|
+
m = Model.load(load(find_root()))
|
|
7
|
+
m.reqs["TSR_009"].statement
|
|
8
|
+
m.up("ICD_BUS_014") # what it satisfies, transitively
|
|
9
|
+
m.down("TSR_009") # what satisfies it, transitively
|
|
10
|
+
m.covering_refs("TSR_009") # markers on it or on anything beneath it
|
|
11
|
+
|
|
12
|
+
Only `satisfies` is ever written, pointing one level up. The reverse direction
|
|
13
|
+
is derived here, so the two can never disagree.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import asdict, dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import markers, questions, store
|
|
23
|
+
from .config import STATUS, Config
|
|
24
|
+
from .markers import Ref
|
|
25
|
+
from .store import Unit
|
|
26
|
+
|
|
27
|
+
SPLIT = re.compile(r"[\s,;]+")
|
|
28
|
+
NONE = {"", "—", "-"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def split(cell: str) -> list[str]:
|
|
32
|
+
return [t for t in SPLIT.split(cell.strip()) if t and t not in NONE]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Req:
|
|
37
|
+
id: str
|
|
38
|
+
kind: str
|
|
39
|
+
unit: str
|
|
40
|
+
prefix: str
|
|
41
|
+
title: str = ""
|
|
42
|
+
statement: str = ""
|
|
43
|
+
rationale: str = ""
|
|
44
|
+
satisfies: list[str] = field(default_factory=list)
|
|
45
|
+
topics: list[str] = field(default_factory=list)
|
|
46
|
+
source: str = ""
|
|
47
|
+
verification: str = ""
|
|
48
|
+
status: str = ""
|
|
49
|
+
#: "Yes" where the requirement carries a security property. Absent means
|
|
50
|
+
#: not security relevant, so nothing is written on the majority.
|
|
51
|
+
security: str = ""
|
|
52
|
+
section: str = ""
|
|
53
|
+
#: Columns reqdeck does not interpret, kept as they are.
|
|
54
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
55
|
+
#: Multi-line markdown from the unit's details file.
|
|
56
|
+
detail: str = ""
|
|
57
|
+
file: str = ""
|
|
58
|
+
line: int = 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def label(self) -> str:
|
|
62
|
+
if self.title:
|
|
63
|
+
return self.title
|
|
64
|
+
s = self.statement
|
|
65
|
+
return s if len(s) <= 72 else s[:71] + "…"
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Model:
|
|
72
|
+
def __init__(self, cfg: Config):
|
|
73
|
+
self.cfg = cfg
|
|
74
|
+
self.units: list[Unit] = []
|
|
75
|
+
self.reqs: dict[str, Req] = {}
|
|
76
|
+
self.refs: list[Ref] = []
|
|
77
|
+
self.topics: dict[str, str] = {}
|
|
78
|
+
self.questions: list[questions.Question] = []
|
|
79
|
+
#: Problems found while loading, reported by `check`.
|
|
80
|
+
self.load_problems: list[str] = []
|
|
81
|
+
self.satisfied_by: dict[str, list[str]] = {}
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- loading
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def load(cls, cfg: Config, scan: bool = True) -> "Model":
|
|
87
|
+
m = cls(cfg)
|
|
88
|
+
m.topics, m.questions = questions.load(cfg.questions)
|
|
89
|
+
try:
|
|
90
|
+
m.units = store.units(cfg.requirements)
|
|
91
|
+
except store.StoreError as e:
|
|
92
|
+
m.load_problems.append(str(e))
|
|
93
|
+
for u in m.units:
|
|
94
|
+
m._load_unit(u)
|
|
95
|
+
if scan:
|
|
96
|
+
m.refs = markers.scan(cfg)
|
|
97
|
+
m.relink()
|
|
98
|
+
return m
|
|
99
|
+
|
|
100
|
+
def _load_unit(self, u: Unit) -> None:
|
|
101
|
+
try:
|
|
102
|
+
rows = store.read_rows(u)
|
|
103
|
+
except store.StoreError as e:
|
|
104
|
+
self.load_problems.append(str(e))
|
|
105
|
+
return
|
|
106
|
+
rel = self.cfg.rel(u.csv_path)
|
|
107
|
+
if "id" not in u.header:
|
|
108
|
+
self.load_problems.append(f"{rel}: header has no `id` column")
|
|
109
|
+
return
|
|
110
|
+
extras = [c for c in u.header if c not in store.STANDARD]
|
|
111
|
+
for row in rows:
|
|
112
|
+
v = row.values
|
|
113
|
+
rid = v["id"].strip()
|
|
114
|
+
if rid in self.reqs:
|
|
115
|
+
other = self.reqs[rid]
|
|
116
|
+
self.load_problems.append(f"duplicate id {rid} ({other.file}:{other.line}, {rel}:{row.line})")
|
|
117
|
+
continue
|
|
118
|
+
prefix = next((p for p in u.prefixes if rid.startswith(p)), "")
|
|
119
|
+
self.reqs[rid] = Req(
|
|
120
|
+
id=rid, kind=u.level, unit=u.stem, prefix=prefix,
|
|
121
|
+
title=v.get("title", "").strip(), statement=v.get("statement", "").strip(),
|
|
122
|
+
rationale=v.get("rationale", "").strip(),
|
|
123
|
+
satisfies=split(v.get("satisfies", "")), topics=split(v.get("topic", "")),
|
|
124
|
+
source=v.get("source", "").strip(), verification=v.get("verification", "").strip(),
|
|
125
|
+
status=v.get("status", "").strip(), security=v.get("security", "").strip(),
|
|
126
|
+
section=v.get("section", "").strip(),
|
|
127
|
+
extra={c: v[c] for c in extras if v.get(c, "").strip()},
|
|
128
|
+
file=rel, line=row.line,
|
|
129
|
+
)
|
|
130
|
+
for rid, text, line in store.read_details(u):
|
|
131
|
+
r = self.reqs.get(rid)
|
|
132
|
+
if r is None or r.unit != u.stem:
|
|
133
|
+
self.load_problems.append(
|
|
134
|
+
f"{self.cfg.rel(u.details_path)}:{line}: detail for {rid}, "
|
|
135
|
+
f"which is not a requirement in {u.csv_path.name}")
|
|
136
|
+
elif r.detail:
|
|
137
|
+
self.load_problems.append(f"{self.cfg.rel(u.details_path)}:{line}: second detail for {rid}")
|
|
138
|
+
else:
|
|
139
|
+
r.detail = text
|
|
140
|
+
|
|
141
|
+
def relink(self) -> None:
|
|
142
|
+
self.satisfied_by = {k: [] for k in self.reqs}
|
|
143
|
+
for r in self.reqs.values():
|
|
144
|
+
for target in r.satisfies:
|
|
145
|
+
self.satisfied_by.setdefault(target, []).append(r.id)
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- queries
|
|
148
|
+
|
|
149
|
+
def unit(self, name: str) -> Unit | None:
|
|
150
|
+
"""A unit by file stem or by prefix."""
|
|
151
|
+
name_u = name.upper().rstrip("_-")
|
|
152
|
+
return next((u for u in self.units
|
|
153
|
+
if u.stem == name or name_u in (p.upper() for p in u.prefixes)), None)
|
|
154
|
+
|
|
155
|
+
def level_order(self, kind: str) -> int:
|
|
156
|
+
kinds = [lv.kind for lv in self.cfg.levels]
|
|
157
|
+
return kinds.index(kind) if kind in kinds else len(kinds)
|
|
158
|
+
|
|
159
|
+
def sort_key(self, r: Req):
|
|
160
|
+
num = re.search(r"(\d+)$", r.id)
|
|
161
|
+
return (self.level_order(r.kind), r.prefix, int(num.group(1)) if num else 0, r.id)
|
|
162
|
+
|
|
163
|
+
def question(self, qid: str) -> questions.Question | None:
|
|
164
|
+
return next((q for q in self.questions if q.id == qid), None)
|
|
165
|
+
|
|
166
|
+
def knows(self, ident: str) -> bool:
|
|
167
|
+
"""A requirement or an open question by that ID exists."""
|
|
168
|
+
return ident in self.reqs or self.question(ident) is not None
|
|
169
|
+
|
|
170
|
+
def refs_for(self, rid: str) -> list[Ref]:
|
|
171
|
+
return [r for r in self.refs if r.id == rid]
|
|
172
|
+
|
|
173
|
+
def up(self, rid: str, _seen=None) -> list[str]:
|
|
174
|
+
"""Transitive closure of what `rid` satisfies, nearest first."""
|
|
175
|
+
_seen = _seen if _seen is not None else set()
|
|
176
|
+
out = []
|
|
177
|
+
r = self.reqs.get(rid)
|
|
178
|
+
for t in (r.satisfies if r else []):
|
|
179
|
+
if t in _seen:
|
|
180
|
+
continue
|
|
181
|
+
_seen.add(t)
|
|
182
|
+
out.append(t)
|
|
183
|
+
out += self.up(t, _seen)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
def down(self, rid: str, _seen=None) -> list[str]:
|
|
187
|
+
"""Transitive closure of what satisfies `rid`."""
|
|
188
|
+
_seen = _seen if _seen is not None else set()
|
|
189
|
+
out = []
|
|
190
|
+
for s in self.satisfied_by.get(rid, []):
|
|
191
|
+
if s in _seen:
|
|
192
|
+
continue
|
|
193
|
+
_seen.add(s)
|
|
194
|
+
out.append(s)
|
|
195
|
+
out += self.down(s, _seen)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
def covering_refs(self, rid: str) -> list[Ref]:
|
|
199
|
+
"""Refs on this requirement or on anything that satisfies it.
|
|
200
|
+
|
|
201
|
+
A test against a low-level requirement also verifies everything that
|
|
202
|
+
requirement satisfies, which is the point of the chain.
|
|
203
|
+
"""
|
|
204
|
+
ids = {rid} | set(self.down(rid))
|
|
205
|
+
return [r for r in self.refs if r.id in ids]
|
|
206
|
+
|
|
207
|
+
def code_bearing(self, rid: str) -> bool:
|
|
208
|
+
r = self.reqs.get(rid)
|
|
209
|
+
u = self.unit(r.unit) if r else None
|
|
210
|
+
return bool(u and u.code)
|
|
211
|
+
|
|
212
|
+
def automated(self, r: Req) -> bool:
|
|
213
|
+
v = self.cfg.verification.get(r.verification)
|
|
214
|
+
return bool(v and v.automated)
|
|
215
|
+
|
|
216
|
+
def reviewed(self, r: Req) -> bool:
|
|
217
|
+
lv = self.cfg.level(r.kind)
|
|
218
|
+
return lv.reviewed if lv else True
|
|
219
|
+
|
|
220
|
+
def traced(self, r: Req) -> bool:
|
|
221
|
+
lv = self.cfg.level(r.kind)
|
|
222
|
+
return lv.traced if lv else True
|
|
223
|
+
|
|
224
|
+
def next_id(self, prefix: str) -> str:
|
|
225
|
+
unit = self.unit(prefix)
|
|
226
|
+
lv = self.cfg.level(unit.level) if unit else None
|
|
227
|
+
sep, digits = (lv.separator, lv.digits) if lv else ("_", 3)
|
|
228
|
+
pat = re.compile(re.escape(prefix + sep) + r"(\d+)$")
|
|
229
|
+
nums = [int(mm.group(1)) for rid in self.reqs if (mm := pat.match(rid))]
|
|
230
|
+
return f"{prefix}{sep}{(max(nums) if nums else 0) + 1:0{digits}d}"
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------- reports
|
|
233
|
+
|
|
234
|
+
def gaps(self) -> dict:
|
|
235
|
+
g = {"dangling": [], "orphans": [], "unsatisfied": [], "untested": [],
|
|
236
|
+
"uncoded": [], "not_accepted": []}
|
|
237
|
+
parents = self.cfg.parent_kinds
|
|
238
|
+
for r in self.reqs.values():
|
|
239
|
+
lv = self.cfg.level(r.kind)
|
|
240
|
+
for t in r.satisfies:
|
|
241
|
+
if t not in self.reqs:
|
|
242
|
+
g["dangling"].append({"id": r.id, "missing": t})
|
|
243
|
+
if lv and lv.satisfies and not r.satisfies:
|
|
244
|
+
g["orphans"].append(r.id)
|
|
245
|
+
if r.kind in parents and not self.satisfied_by.get(r.id):
|
|
246
|
+
g["unsatisfied"].append(r.id)
|
|
247
|
+
if not self.reviewed(r):
|
|
248
|
+
continue
|
|
249
|
+
if self.automated(r) and not [x for x in self.covering_refs(r.id) if x.is_test]:
|
|
250
|
+
g["untested"].append(r.id)
|
|
251
|
+
if self.code_bearing(r.id) and not [x for x in self.refs_for(r.id) if not x.is_test]:
|
|
252
|
+
g["uncoded"].append(r.id)
|
|
253
|
+
if r.status != "Accepted":
|
|
254
|
+
g["not_accepted"].append(r.id)
|
|
255
|
+
for k in g:
|
|
256
|
+
if k != "dangling":
|
|
257
|
+
g[k].sort(key=lambda rid: self.sort_key(self.reqs[rid]))
|
|
258
|
+
return g
|
|
259
|
+
|
|
260
|
+
def problems(self) -> list[str]:
|
|
261
|
+
"""Everything `check` fails on."""
|
|
262
|
+
cfg = self.cfg
|
|
263
|
+
out = list(self.load_problems)
|
|
264
|
+
kinds = {lv.kind for lv in cfg.levels}
|
|
265
|
+
|
|
266
|
+
homes: dict[str, str] = {}
|
|
267
|
+
for u in self.units:
|
|
268
|
+
rel = cfg.rel(u.toml_path)
|
|
269
|
+
if u.level not in kinds:
|
|
270
|
+
out.append(f"{rel}: level {u.level!r} is not a level in {cfg.root.name}/.reqdeck.toml")
|
|
271
|
+
if not u.prefixes:
|
|
272
|
+
out.append(f"{rel}: no prefix")
|
|
273
|
+
for p in u.prefixes:
|
|
274
|
+
if p in homes:
|
|
275
|
+
out.append(f"prefix {p} is declared by both {homes[p]} and {rel} — "
|
|
276
|
+
f"one prefix belongs in one unit")
|
|
277
|
+
homes[p] = rel
|
|
278
|
+
keys = set()
|
|
279
|
+
for s in u.sections:
|
|
280
|
+
if not s.key or not s.title:
|
|
281
|
+
out.append(f"{rel}: a section needs both key and title")
|
|
282
|
+
if s.key in keys:
|
|
283
|
+
out.append(f"{rel}: section {s.key} declared twice")
|
|
284
|
+
if s.parent and s.parent not in keys:
|
|
285
|
+
out.append(f"{rel}: section {s.key} comes before its parent {s.parent}, "
|
|
286
|
+
f"or its parent is missing")
|
|
287
|
+
keys.add(s.key)
|
|
288
|
+
if u.header and u.csv_path.exists():
|
|
289
|
+
try:
|
|
290
|
+
rows = store.read_rows(u)
|
|
291
|
+
if not store.is_canonical(u, rows):
|
|
292
|
+
out.append(f"{cfg.rel(u.csv_path)}: not in canonical form — run `reqdeck fmt`")
|
|
293
|
+
except store.StoreError:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
for r in self.reqs.values():
|
|
297
|
+
u = self.unit(r.unit)
|
|
298
|
+
lv = cfg.level(r.kind)
|
|
299
|
+
where = f"{r.id}"
|
|
300
|
+
if u and not r.prefix:
|
|
301
|
+
out.append(f"{where}: does not start with a prefix of {u.csv_path.name} "
|
|
302
|
+
f"({', '.join(u.prefixes)})")
|
|
303
|
+
elif lv and u:
|
|
304
|
+
if not re.fullmatch(re.escape(r.prefix + lv.separator) + r"\d{%d}" % lv.digits, r.id):
|
|
305
|
+
out.append(f"{where}: expected {r.prefix}{lv.separator} and {lv.digits} digits")
|
|
306
|
+
if not r.statement:
|
|
307
|
+
out.append(f"{where}: missing statement")
|
|
308
|
+
if self.reviewed(r):
|
|
309
|
+
for fld in ("source", "verification", "status"):
|
|
310
|
+
if not getattr(r, fld):
|
|
311
|
+
out.append(f"{where}: missing {fld}")
|
|
312
|
+
if r.verification and r.verification not in cfg.verification:
|
|
313
|
+
out.append(f"{where}: bad verification {r.verification!r} — expected one of "
|
|
314
|
+
f"{', '.join(cfg.verification)}")
|
|
315
|
+
if r.status and r.status not in STATUS:
|
|
316
|
+
out.append(f"{where}: bad status {r.status!r} — expected one of {', '.join(STATUS)}")
|
|
317
|
+
if r.security and r.security != "Yes":
|
|
318
|
+
out.append(f"{where}: security is {r.security!r} — the only value is Yes; "
|
|
319
|
+
f"leave it empty where a requirement is not security relevant")
|
|
320
|
+
for t in r.satisfies:
|
|
321
|
+
tr = self.reqs.get(t)
|
|
322
|
+
if tr is None:
|
|
323
|
+
out.append(f"{where}: satisfies undefined {t}")
|
|
324
|
+
elif lv and tr.kind not in lv.satisfies:
|
|
325
|
+
allowed = ", ".join(lv.satisfies) or "nothing — it is a top level"
|
|
326
|
+
out.append(f"{where}: satisfies {t}, a {tr.kind}; a {r.kind} satisfies {allowed}")
|
|
327
|
+
if lv and lv.topic and not r.topics:
|
|
328
|
+
out.append(f"{where}: missing topic")
|
|
329
|
+
for t in r.topics:
|
|
330
|
+
if t not in self.topics:
|
|
331
|
+
out.append(f"{where}: topic {t} is not a topic in "
|
|
332
|
+
f"{cfg.rel(cfg.questions) if cfg.questions else 'OPEN-QUESTIONS'}")
|
|
333
|
+
if r.section and u and not u.section(r.section):
|
|
334
|
+
out.append(f"{where}: section {r.section!r} is not declared in {u.toml_path.name}")
|
|
335
|
+
|
|
336
|
+
mk = cfg.markers
|
|
337
|
+
for ref in self.refs:
|
|
338
|
+
at = f"{ref.file}:{ref.line}"
|
|
339
|
+
r = self.reqs.get(ref.id)
|
|
340
|
+
if r is None:
|
|
341
|
+
out.append(f"{at}: @REQ_REF to undefined {ref.id}")
|
|
342
|
+
elif not self.traced(r):
|
|
343
|
+
below = ", ".join(self.satisfied_by.get(ref.id, [])) or "nothing yet"
|
|
344
|
+
out.append(f"{at}: @REQ_REF({ref.id}) names a {r.kind}, which is traced through "
|
|
345
|
+
f"what satisfies it ({below}) — reference that instead")
|
|
346
|
+
# One ID per marker. A marker naming three requirements reads as
|
|
347
|
+
# one obligation and hides that the three may have diverged.
|
|
348
|
+
if ref.extra_ids:
|
|
349
|
+
out.append(f"{at}: @REQ_REF names {len(ref.extra_ids) + 1} requirements "
|
|
350
|
+
f"({ref.id}, {', '.join(ref.extra_ids)}) — one per marker, one line each")
|
|
351
|
+
if not ref.note:
|
|
352
|
+
out.append(f"{at}: @REQ_REF({ref.id}) has no description — "
|
|
353
|
+
f"add a few words on what the requirement asks for")
|
|
354
|
+
if not ref.is_test and mk.interface_suffixes and \
|
|
355
|
+
Path(ref.file).stem.endswith(tuple(mk.interface_suffixes)):
|
|
356
|
+
out.append(f"{at}: @REQ_REF({ref.id}) is on an interface — "
|
|
357
|
+
f"put it on the code that implements it")
|
|
358
|
+
if not ref.is_test and mk.declaration_extensions and \
|
|
359
|
+
Path(ref.file).suffix not in mk.declaration_extensions:
|
|
360
|
+
out.append(f"{at}: @REQ_REF({ref.id}) is in an implementation file — "
|
|
361
|
+
f"markers belong on the declaration, or in a test")
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
def status_tally(self) -> dict[str, int]:
|
|
365
|
+
counts = {k: 0 for k in STATUS}
|
|
366
|
+
counts["missing"] = 0
|
|
367
|
+
for r in self.reqs.values():
|
|
368
|
+
if not self.reviewed(r):
|
|
369
|
+
continue
|
|
370
|
+
counts[r.status if r.status in STATUS else "missing"] += 1
|
|
371
|
+
return counts
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- document
|
|
374
|
+
|
|
375
|
+
def document(self, unit: Unit) -> list[dict]:
|
|
376
|
+
"""A unit as a document, in reading order.
|
|
377
|
+
|
|
378
|
+
A flat list of entries, each either a section heading or a requirement,
|
|
379
|
+
with its depth. Requirements with no section come first, as the body
|
|
380
|
+
text before the first heading; within a section they follow ID order.
|
|
381
|
+
"""
|
|
382
|
+
mine = sorted((r for r in self.reqs.values() if r.unit == unit.stem), key=self.sort_key)
|
|
383
|
+
by_section: dict[str, list[Req]] = {}
|
|
384
|
+
for r in mine:
|
|
385
|
+
key = r.section if unit.section(r.section) else ""
|
|
386
|
+
by_section.setdefault(key, []).append(r)
|
|
387
|
+
numbers = unit.numbering()
|
|
388
|
+
out: list[dict] = [{"type": "req", "depth": 0, "req": r} for r in by_section.get("", [])]
|
|
389
|
+
for s in unit.sections:
|
|
390
|
+
depth = s.key.count(".") + 1
|
|
391
|
+
out.append({"type": "section", "depth": depth, "section": s, "number": numbers[s.key]})
|
|
392
|
+
out += [{"type": "req", "depth": depth, "req": r} for r in by_section.get(s.key, [])]
|
|
393
|
+
return out
|
reqdeck/ops.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Writing requirements. Shared by the CLI and the TUI, so both obey the same rules.
|
|
2
|
+
|
|
3
|
+
The status rule is enforced here rather than trusted to the caller: `add`
|
|
4
|
+
writes Proposed and nothing else, `edit` turns an Accepted requirement into
|
|
5
|
+
Reopened when it changes, and only `accept` — reached from the TUI, by a
|
|
6
|
+
person — ever writes Accepted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from . import store
|
|
12
|
+
from .model import Model, split
|
|
13
|
+
|
|
14
|
+
#: Fields `edit` may change. Status is not among them.
|
|
15
|
+
EDITABLE = ("title", "statement", "rationale", "satisfies", "topic", "source",
|
|
16
|
+
"verification", "security", "section")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalise(m: Model, rid: str, kind: str, unit, fields: dict[str, str]) -> None:
|
|
24
|
+
lv = m.cfg.level(kind)
|
|
25
|
+
if fields.get("verification"):
|
|
26
|
+
fields["verification"] = fields["verification"].upper()
|
|
27
|
+
if fields["verification"] not in m.cfg.verification:
|
|
28
|
+
raise OpError(f"Bad verification {fields['verification']!r} — one of "
|
|
29
|
+
f"{', '.join(m.cfg.verification)}")
|
|
30
|
+
if "topic" in fields:
|
|
31
|
+
topics = [t.zfill(2) for t in split(fields["topic"])]
|
|
32
|
+
for t in topics:
|
|
33
|
+
if t not in m.topics:
|
|
34
|
+
raise OpError(f"{rid}: topic {t} is not a topic in OPEN-QUESTIONS")
|
|
35
|
+
fields["topic"] = " ".join(topics)
|
|
36
|
+
if "satisfies" in fields:
|
|
37
|
+
sat = split(fields["satisfies"])
|
|
38
|
+
for t in sat:
|
|
39
|
+
tr = m.reqs.get(t)
|
|
40
|
+
if tr is None:
|
|
41
|
+
raise OpError(f"{rid}: satisfies undefined {t}")
|
|
42
|
+
if lv and tr.kind not in lv.satisfies:
|
|
43
|
+
raise OpError(f"{rid}: a {kind} cannot satisfy {t}, a {tr.kind}")
|
|
44
|
+
fields["satisfies"] = " ".join(sat)
|
|
45
|
+
if fields.get("section") and not unit.section(fields["section"]):
|
|
46
|
+
keys = ", ".join(s.key for s in unit.sections) or "none declared"
|
|
47
|
+
raise OpError(f"{rid}: section {fields['section']!r} is not in {unit.toml_path.name} ({keys})")
|
|
48
|
+
if "security" in fields:
|
|
49
|
+
fields["security"] = "Yes" if fields["security"].strip().lower() in ("yes", "y", "true", "1") else ""
|
|
50
|
+
missing = [c for c, v in fields.items() if v and c not in unit.header]
|
|
51
|
+
if missing:
|
|
52
|
+
raise OpError(f"{unit.csv_path.name} has no column for {', '.join(missing)}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add(m: Model, prefix: str, fields: dict[str, str]) -> tuple[str, str, int]:
|
|
56
|
+
"""Allocate the next ID under `prefix` and append it. Returns (id, file, line)."""
|
|
57
|
+
unit = m.unit(prefix)
|
|
58
|
+
if unit is None:
|
|
59
|
+
raise OpError(f"No unit holds {prefix}. A new prefix is a new unit of the design — "
|
|
60
|
+
f"create it deliberately with `reqdeck unit`, then add to it.")
|
|
61
|
+
want = prefix.upper().rstrip("_-")
|
|
62
|
+
exact = next((p for p in unit.prefixes if p.upper() == want), unit.prefixes[0])
|
|
63
|
+
lv = m.cfg.level(unit.level)
|
|
64
|
+
rid = m.next_id(exact)
|
|
65
|
+
store.read_rows(unit)
|
|
66
|
+
|
|
67
|
+
fields = {k: (v or "").strip() for k, v in fields.items() if k != "status"}
|
|
68
|
+
_normalise(m, rid, unit.level, unit, fields)
|
|
69
|
+
if not fields.get("statement"):
|
|
70
|
+
raise OpError(f"{rid}: a statement is required")
|
|
71
|
+
if lv and lv.topic and not fields.get("topic"):
|
|
72
|
+
raise OpError(f"{rid}: a topic is required on {lv.kind}")
|
|
73
|
+
if not lv or lv.reviewed:
|
|
74
|
+
if not fields.get("source"):
|
|
75
|
+
raise OpError(f"{rid}: a source is required — what decided it")
|
|
76
|
+
if not fields.get("verification"):
|
|
77
|
+
raise OpError(f"{rid}: a verification method is required")
|
|
78
|
+
fields["status"] = "Proposed"
|
|
79
|
+
line = store.append_row(unit, {"id": rid, **fields})
|
|
80
|
+
return rid, m.cfg.rel(unit.csv_path), line
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def edit(m: Model, rid: str, changes: dict[str, str]) -> tuple[list[str], bool]:
|
|
84
|
+
"""Change fields of an existing requirement. Returns (changed, reopened).
|
|
85
|
+
|
|
86
|
+
A change to an Accepted requirement makes it Reopened, since what the user
|
|
87
|
+
agreed to is no longer what is written."""
|
|
88
|
+
r = m.reqs.get(rid)
|
|
89
|
+
if r is None:
|
|
90
|
+
raise OpError(f"No such requirement: {rid}")
|
|
91
|
+
if "status" in changes or "id" in changes:
|
|
92
|
+
raise OpError("id and status are not editable")
|
|
93
|
+
unit = m.unit(r.unit)
|
|
94
|
+
rows = store.read_rows(unit)
|
|
95
|
+
changes = {k: v.strip() for k, v in changes.items()}
|
|
96
|
+
if not changes:
|
|
97
|
+
raise OpError("Nothing to change.")
|
|
98
|
+
_normalise(m, rid, r.kind, unit, changes)
|
|
99
|
+
row = next(x for x in rows if x.values["id"].strip() == rid)
|
|
100
|
+
before = dict(row.values)
|
|
101
|
+
row.values.update(changes)
|
|
102
|
+
if row.values == before:
|
|
103
|
+
raise OpError(f"{rid}: already says that.")
|
|
104
|
+
reopened = before.get("status") == "Accepted"
|
|
105
|
+
if reopened:
|
|
106
|
+
row.values["status"] = "Reopened"
|
|
107
|
+
store.write_rows(unit, [x.values for x in rows])
|
|
108
|
+
return sorted(k for k in changes if before.get(k, "") != row.values[k]), reopened
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def accept(m: Model, rid: str) -> None:
|
|
112
|
+
"""Mark a requirement Accepted.
|
|
113
|
+
|
|
114
|
+
Only ever called on a person's explicit action in the TUI. No CLI command
|
|
115
|
+
reaches it: an agent that accepts its own work erases the one record of
|
|
116
|
+
what a human has reviewed."""
|
|
117
|
+
r = m.reqs.get(rid)
|
|
118
|
+
if r is None or not m.reviewed(r):
|
|
119
|
+
raise OpError(f"{rid} carries no review status")
|
|
120
|
+
unit = m.unit(r.unit)
|
|
121
|
+
rows = store.read_rows(unit)
|
|
122
|
+
row = next(x for x in rows if x.values["id"].strip() == rid)
|
|
123
|
+
row.values["status"] = "Accepted"
|
|
124
|
+
store.write_rows(unit, [x.values for x in rows])
|
reqdeck/questions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""OPEN-QUESTIONS: the topics and the questions inside them.
|
|
2
|
+
|
|
3
|
+
Markdown rather than CSV, because a question is a discussion and its answer is
|
|
4
|
+
prose. The shape is fixed, since the method depends on it:
|
|
5
|
+
|
|
6
|
+
## Topic 03 — LED interface
|
|
7
|
+
|
|
8
|
+
### OQ-0301 — Which LEDs must work
|
|
9
|
+
**Question:** ...
|
|
10
|
+
**Answer:** <blank until decided>
|
|
11
|
+
**Blocks:** ...
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
TOPIC = re.compile(r"^## Topic (\d{2}) — (.+?)\s*$", re.M)
|
|
21
|
+
QUESTION = re.compile(r"^### (OQ-(\d{2})\d{2}) — (.+?)\s*$", re.M)
|
|
22
|
+
FIELD = re.compile(r"^\*\*([\w ]+?):\*\* ?(.*)$", re.M)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Question:
|
|
27
|
+
id: str
|
|
28
|
+
topic: str
|
|
29
|
+
title: str
|
|
30
|
+
question: str
|
|
31
|
+
answer: str
|
|
32
|
+
blocks: str
|
|
33
|
+
line: int
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def open(self) -> bool:
|
|
37
|
+
return not self.answer.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load(path: Path | None) -> tuple[dict[str, str], list[Question]]:
|
|
41
|
+
if path is None or not path.exists():
|
|
42
|
+
return {}, []
|
|
43
|
+
text = path.read_text()
|
|
44
|
+
topics = {m.group(1): m.group(2) for m in TOPIC.finditer(text)}
|
|
45
|
+
heads = list(QUESTION.finditer(text))
|
|
46
|
+
questions = []
|
|
47
|
+
for i, h in enumerate(heads):
|
|
48
|
+
end = heads[i + 1].start() if i + 1 < len(heads) else len(text)
|
|
49
|
+
nxt = TOPIC.search(text, h.end(), end)
|
|
50
|
+
body = text[h.end(): nxt.start() if nxt else end]
|
|
51
|
+
# A field runs until the next field, so a multi-paragraph answer is kept whole.
|
|
52
|
+
fields: dict[str, str] = {}
|
|
53
|
+
marks = list(FIELD.finditer(body))
|
|
54
|
+
for j, f in enumerate(marks):
|
|
55
|
+
stop = marks[j + 1].start() if j + 1 < len(marks) else len(body)
|
|
56
|
+
fields[f.group(1)] = (f.group(2) + body[f.end():stop]).strip()
|
|
57
|
+
questions.append(Question(
|
|
58
|
+
id=h.group(1), topic=h.group(2), title=h.group(3),
|
|
59
|
+
question=fields.get("Question", ""), answer=fields.get("Answer", ""),
|
|
60
|
+
blocks=fields.get("Blocks", ""), line=text[:h.start()].count("\n") + 1,
|
|
61
|
+
))
|
|
62
|
+
return topics, questions
|