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/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