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/cli.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""The `reqdeck` command line. Logic lives in the model; this file only asks and prints.
|
|
2
|
+
|
|
3
|
+
Every command takes --json for machine-readable output.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__, config, ops, render, store
|
|
17
|
+
from .config import STATUS
|
|
18
|
+
from .markers import Ref
|
|
19
|
+
from .model import Model, Req, split
|
|
20
|
+
|
|
21
|
+
BOLD, DIM, RED, RESET = "\033[1m", "\033[2m", "\033[31m", "\033[0m"
|
|
22
|
+
|
|
23
|
+
EDITABLE = ops.EDITABLE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _c(s: str, code: str) -> str:
|
|
27
|
+
return s if not sys.stdout.isatty() else f"{code}{s}{RESET}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _err(msg: str) -> int:
|
|
31
|
+
print(msg, file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _label(m: Model, rid: str) -> str:
|
|
36
|
+
r = m.reqs.get(rid)
|
|
37
|
+
return f"{rid} — {r.label}" if r else f"{rid} {_c('(undefined)', DIM)}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _print_branch(m: Model, rid: str, direction: str, prefix: str = "", seen=None):
|
|
41
|
+
seen = seen if seen is not None else set()
|
|
42
|
+
nxt = m.reqs[rid].satisfies if direction == "up" else m.satisfied_by.get(rid, [])
|
|
43
|
+
nxt = [n for n in nxt if n not in seen] if direction == "up" else nxt
|
|
44
|
+
for i, child in enumerate(nxt):
|
|
45
|
+
last = i == len(nxt) - 1
|
|
46
|
+
print(f"{prefix}{'└─ ' if last else '├─ '}{_label(m, child)}")
|
|
47
|
+
if child in m.reqs and child not in seen:
|
|
48
|
+
seen.add(child)
|
|
49
|
+
_print_branch(m, child, direction, prefix + (" " if last else "│ "), seen)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find(m: Model, rid: str) -> Req | None:
|
|
53
|
+
r = m.reqs.get(rid) or m.reqs.get(rid.upper())
|
|
54
|
+
if r is None:
|
|
55
|
+
print(f"No such requirement: {rid}", file=sys.stderr)
|
|
56
|
+
near = sorted(k for k in m.reqs if rid.upper() in k.upper())
|
|
57
|
+
if near:
|
|
58
|
+
print("Did you mean: " + ", ".join(near[:8]), file=sys.stderr)
|
|
59
|
+
return r
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------- queries
|
|
63
|
+
|
|
64
|
+
def cmd_get(m: Model, args) -> int:
|
|
65
|
+
r = _find(m, args.id)
|
|
66
|
+
if r is None:
|
|
67
|
+
return 1
|
|
68
|
+
rid = r.id
|
|
69
|
+
refs = m.refs_for(rid)
|
|
70
|
+
if args.json:
|
|
71
|
+
print(json.dumps({
|
|
72
|
+
**r.to_dict(),
|
|
73
|
+
"topic_titles": {t: m.topics.get(t, "") for t in r.topics},
|
|
74
|
+
"satisfies_transitive": m.up(rid),
|
|
75
|
+
"satisfied_by": m.satisfied_by.get(rid, []),
|
|
76
|
+
"satisfied_by_transitive": m.down(rid),
|
|
77
|
+
"code_refs": [asdict(x) for x in refs if not x.is_test],
|
|
78
|
+
"test_refs": [asdict(x) for x in refs if x.is_test],
|
|
79
|
+
"covering_test_refs": [asdict(x) for x in m.covering_refs(rid) if x.is_test],
|
|
80
|
+
}, indent=2))
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
print(_c(f"{r.id} — {r.label}", BOLD))
|
|
84
|
+
print(f"{_c(r.file + ':' + str(r.line), DIM)}\n")
|
|
85
|
+
if r.statement:
|
|
86
|
+
print(f" {r.statement}")
|
|
87
|
+
if r.detail:
|
|
88
|
+
print("\n" + "\n".join(" " + ln for ln in r.detail.splitlines()))
|
|
89
|
+
if r.rationale:
|
|
90
|
+
print(f"\n {_c('Rationale:', DIM)} {r.rationale}")
|
|
91
|
+
if r.source:
|
|
92
|
+
print(f" {_c('Source:', DIM)} {r.source}")
|
|
93
|
+
if r.topics:
|
|
94
|
+
print(f" {_c('Topic:', DIM)} " + ", ".join(f"{t} {m.topics.get(t, '?')}" for t in r.topics))
|
|
95
|
+
if r.verification:
|
|
96
|
+
v = m.cfg.verification.get(r.verification)
|
|
97
|
+
print(f" {_c('Verification:', DIM)} {r.verification} — {v.name if v else '?'}")
|
|
98
|
+
if r.status:
|
|
99
|
+
print(f" {_c('Status:', DIM)} {r.status}")
|
|
100
|
+
if r.security:
|
|
101
|
+
print(f" {_c('Security relevant:', DIM)} {r.security}")
|
|
102
|
+
for k, v in r.extra.items():
|
|
103
|
+
print(f" {_c(k.capitalize() + ':', DIM)} {v}")
|
|
104
|
+
|
|
105
|
+
print(f"\n{_c('Satisfies (up)', BOLD)}")
|
|
106
|
+
if r.satisfies:
|
|
107
|
+
_print_branch(m, rid, "up")
|
|
108
|
+
else:
|
|
109
|
+
lv = m.cfg.level(r.kind)
|
|
110
|
+
print(f" {_c('nothing — orphan' if lv and lv.satisfies else 'top level', DIM)}")
|
|
111
|
+
print(f"\n{_c('Satisfied by (down)', BOLD)}")
|
|
112
|
+
if m.satisfied_by.get(rid):
|
|
113
|
+
_print_branch(m, rid, "down")
|
|
114
|
+
else:
|
|
115
|
+
print(f" {_c('nothing yet', DIM)}")
|
|
116
|
+
|
|
117
|
+
def _ref_line(x: Ref) -> str:
|
|
118
|
+
return f" {x.file}:{x.line}" + (f" {_c(x.note, DIM)}" if x.note else "")
|
|
119
|
+
|
|
120
|
+
code = [x for x in refs if not x.is_test]
|
|
121
|
+
test = [x for x in refs if x.is_test]
|
|
122
|
+
cover = [x for x in m.covering_refs(rid) if x.is_test and x.id != rid]
|
|
123
|
+
print(f"\n{_c('Code', BOLD)}")
|
|
124
|
+
print("\n".join(_ref_line(x) for x in code) or f" {_c('none', DIM)}")
|
|
125
|
+
print(f"\n{_c('Tests', BOLD)}")
|
|
126
|
+
print("\n".join(_ref_line(x) for x in test) or f" {_c('none', DIM)}")
|
|
127
|
+
if cover:
|
|
128
|
+
print(f" {_c('via children:', DIM)}")
|
|
129
|
+
for x in cover:
|
|
130
|
+
print(f" {x.file}:{x.line} {_c('(' + x.id + ')', DIM)}")
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _print_reqs(m: Model, rs: list[Req], long: bool, noun=("requirement", "requirements")) -> None:
|
|
135
|
+
for r in rs:
|
|
136
|
+
v = f"[{r.verification}]" if r.verification else "[ ]"
|
|
137
|
+
t = f"{'/'.join(r.topics):<5}" if r.topics else " - "
|
|
138
|
+
st = r.status[0] if r.status else "-"
|
|
139
|
+
print(f"{v} {t} {st} {_c(r.id, BOLD):<28} {r.label}")
|
|
140
|
+
if long and r.statement and r.statement != r.label:
|
|
141
|
+
print(f" {_c(r.statement, DIM)}\n")
|
|
142
|
+
sys.stdout.flush()
|
|
143
|
+
print(f"{len(rs)} {noun[0] if len(rs) == 1 else noun[1]}", file=sys.stderr)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_search(m: Model, args) -> int:
|
|
147
|
+
pat = re.compile(args.query, re.I)
|
|
148
|
+
hits = [r for r in m.reqs.values()
|
|
149
|
+
if any(pat.search(s) for s in (r.id, r.title, r.statement, r.rationale, r.detail))]
|
|
150
|
+
hits.sort(key=m.sort_key)
|
|
151
|
+
if args.json:
|
|
152
|
+
print(json.dumps([r.to_dict() for r in hits], indent=2))
|
|
153
|
+
return 0
|
|
154
|
+
_print_reqs(m, hits, args.long, ("match", "matches"))
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_list(m: Model, args) -> int:
|
|
159
|
+
rs = list(m.reqs.values())
|
|
160
|
+
if args.kind:
|
|
161
|
+
rs = [r for r in rs if r.kind.upper() == args.kind.upper()]
|
|
162
|
+
if args.unit:
|
|
163
|
+
u = m.unit(args.unit)
|
|
164
|
+
if u is None:
|
|
165
|
+
# A bare unit code, as in TSSR_FWN's FWN.
|
|
166
|
+
rs = [r for r in rs if r.prefix.upper().endswith("_" + args.unit.upper())]
|
|
167
|
+
else:
|
|
168
|
+
rs = [r for r in rs if r.unit == u.stem]
|
|
169
|
+
if args.verification:
|
|
170
|
+
rs = [r for r in rs if r.verification == args.verification.upper()]
|
|
171
|
+
if args.status:
|
|
172
|
+
rs = [r for r in rs if r.status == args.status.capitalize()]
|
|
173
|
+
if args.topic:
|
|
174
|
+
want = args.topic.zfill(2)
|
|
175
|
+
rs = [r for r in rs if want in r.topics]
|
|
176
|
+
if args.section:
|
|
177
|
+
rs = [r for r in rs if r.section == args.section or r.section.startswith(args.section + ".")]
|
|
178
|
+
if args.security:
|
|
179
|
+
rs = [r for r in rs if r.security == "Yes"]
|
|
180
|
+
rs.sort(key=m.sort_key)
|
|
181
|
+
if args.json:
|
|
182
|
+
print(json.dumps([r.to_dict() for r in rs], indent=2))
|
|
183
|
+
return 0
|
|
184
|
+
_print_reqs(m, rs, args.long)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_tree(m: Model, args) -> int:
|
|
189
|
+
r = _find(m, args.id)
|
|
190
|
+
if r is None:
|
|
191
|
+
return 1
|
|
192
|
+
if args.json:
|
|
193
|
+
print(json.dumps({"id": r.id, "up": m.up(r.id), "down": m.down(r.id)}, indent=2))
|
|
194
|
+
return 0
|
|
195
|
+
if not args.down:
|
|
196
|
+
print(_c("Satisfies (up)", BOLD))
|
|
197
|
+
print(f" {_label(m, r.id)}")
|
|
198
|
+
_print_branch(m, r.id, "up", " ")
|
|
199
|
+
if not args.up:
|
|
200
|
+
print(_c("\nSatisfied by (down)", BOLD))
|
|
201
|
+
print(f" {_label(m, r.id)}")
|
|
202
|
+
_print_branch(m, r.id, "down", " ")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
GAP_LABELS = [
|
|
207
|
+
("dangling", "Satisfies a requirement that does not exist", True),
|
|
208
|
+
("orphans", "No satisfies link — traces to nothing", False),
|
|
209
|
+
("unsatisfied", "Nothing satisfies it yet", False),
|
|
210
|
+
("untested", "Verification is automated, but no test references it or its children", False),
|
|
211
|
+
("uncoded", "Implemented in software, but no code reference", False),
|
|
212
|
+
("not_accepted", "Not yet accepted — awaiting review by the user", False),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_coverage(m: Model, args) -> int:
|
|
217
|
+
g = m.gaps()
|
|
218
|
+
if args.json:
|
|
219
|
+
print(json.dumps(g, indent=2))
|
|
220
|
+
return 0
|
|
221
|
+
counts = ", ".join(f"{sum(1 for r in m.reqs.values() if r.kind == lv.kind)} {lv.kind}"
|
|
222
|
+
for lv in m.cfg.levels)
|
|
223
|
+
print(_c(f"{len(m.reqs)} requirements ({counts})", BOLD))
|
|
224
|
+
print(f"{len(m.refs)} @REQ_REF marker(s) in source\n")
|
|
225
|
+
clean = True
|
|
226
|
+
for key, label, hard in GAP_LABELS:
|
|
227
|
+
items = g[key]
|
|
228
|
+
if not items:
|
|
229
|
+
continue
|
|
230
|
+
clean = False
|
|
231
|
+
print(f"{'!!' if hard else ' '} {_c(label, BOLD)} ({len(items)})")
|
|
232
|
+
for it in items:
|
|
233
|
+
if isinstance(it, dict):
|
|
234
|
+
print(f" {it['id']} -> {it['missing']}")
|
|
235
|
+
else:
|
|
236
|
+
print(f" {it:<28} {m.reqs[it].label}")
|
|
237
|
+
print()
|
|
238
|
+
if clean:
|
|
239
|
+
print("No gaps.")
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_trace(m: Model, args) -> int:
|
|
244
|
+
"""Is each named requirement actually referenced from source and test?
|
|
245
|
+
|
|
246
|
+
The per-requirement form of `coverage`, for gating one change rather than
|
|
247
|
+
surveying the whole tree — a change claiming to implement something should
|
|
248
|
+
be the thing that makes it traced.
|
|
249
|
+
"""
|
|
250
|
+
problems: list[str] = []
|
|
251
|
+
results: list[dict] = []
|
|
252
|
+
for rid in args.ids:
|
|
253
|
+
r = m.reqs.get(rid)
|
|
254
|
+
if r is None:
|
|
255
|
+
problems.append(f"{rid}: no such requirement")
|
|
256
|
+
continue
|
|
257
|
+
if not m.traced(r):
|
|
258
|
+
below = ", ".join(m.satisfied_by.get(rid, [])) or "nothing yet"
|
|
259
|
+
problems.append(f"{rid}: is a {r.kind}, which no code references directly — "
|
|
260
|
+
f"name what satisfies it instead ({below})")
|
|
261
|
+
continue
|
|
262
|
+
code = [x for x in m.refs_for(rid) if not x.is_test]
|
|
263
|
+
test = [x for x in m.covering_refs(rid) if x.is_test]
|
|
264
|
+
needs_code = m.code_bearing(rid)
|
|
265
|
+
needs_test = m.automated(r)
|
|
266
|
+
if needs_code and not code:
|
|
267
|
+
problems.append(f"{rid}: no code reference — put @REQ_REF({rid}) on what implements it")
|
|
268
|
+
if needs_test and not test:
|
|
269
|
+
problems.append(f"{rid}: verification is {r.verification}, so a test must name it — "
|
|
270
|
+
f"add @REQ_REF({rid}) to the test function")
|
|
271
|
+
results.append({"id": rid, "verification": r.verification, "status": r.status,
|
|
272
|
+
"code_refs": [asdict(x) for x in code],
|
|
273
|
+
"test_refs": [asdict(x) for x in test],
|
|
274
|
+
"needs_code": needs_code, "needs_test": needs_test})
|
|
275
|
+
if args.json:
|
|
276
|
+
print(json.dumps({"ok": not problems, "problems": problems, "requirements": results}, indent=2))
|
|
277
|
+
return 1 if problems else 0
|
|
278
|
+
for res in results:
|
|
279
|
+
marks = [f"{len(res['code_refs'])} code" if res["needs_code"] else "code n/a",
|
|
280
|
+
f"{len(res['test_refs'])} test" if res["needs_test"]
|
|
281
|
+
else f"test n/a ({res['verification']})"]
|
|
282
|
+
print(f" {res['id']:<16} {', '.join(marks)}")
|
|
283
|
+
for prob in problems:
|
|
284
|
+
print(_c(f" {prob}", RED))
|
|
285
|
+
if problems:
|
|
286
|
+
print(f"\n{len(problems)} untraced")
|
|
287
|
+
return 1
|
|
288
|
+
print(f"\n{len(results)} traced")
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_check(m: Model, args) -> int:
|
|
293
|
+
problems = m.problems()
|
|
294
|
+
if args.json:
|
|
295
|
+
print(json.dumps({"ok": not problems, "problems": problems,
|
|
296
|
+
"status": m.status_tally()}, indent=2))
|
|
297
|
+
return 1 if problems else 0
|
|
298
|
+
for p in problems:
|
|
299
|
+
print(p)
|
|
300
|
+
print(f"\n{len(problems)} problem(s)" if problems else "OK")
|
|
301
|
+
_print_maturity(m)
|
|
302
|
+
return 1 if problems else 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _print_maturity(m: Model) -> None:
|
|
306
|
+
"""Status tally. Informational — never fails the check.
|
|
307
|
+
|
|
308
|
+
Everything starts Proposed, so failing on it would make check useless as a
|
|
309
|
+
gate. Full maturity is every requirement Accepted, linked, and referenced
|
|
310
|
+
from code and tests; this is the acceptance third of that."""
|
|
311
|
+
counts = m.status_tally()
|
|
312
|
+
total = sum(counts.values())
|
|
313
|
+
if not total:
|
|
314
|
+
return
|
|
315
|
+
bits = [f"{counts[k]} {k.lower()}" for k in ("Accepted", "Proposed", "Reopened") if counts[k]]
|
|
316
|
+
if counts["missing"]:
|
|
317
|
+
bits.append(f"{counts['missing']} without a status")
|
|
318
|
+
print(f"Status: {', '.join(bits)} of {total} — {100 * counts['Accepted'] // total}% accepted")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_topics(m: Model, args) -> int:
|
|
322
|
+
reqs = {t: [] for t in m.topics}
|
|
323
|
+
for r in m.reqs.values():
|
|
324
|
+
for t in r.topics:
|
|
325
|
+
reqs.setdefault(t, []).append(r.id)
|
|
326
|
+
qs: dict[str, list] = {t: [] for t in m.topics}
|
|
327
|
+
for q in m.questions:
|
|
328
|
+
qs.setdefault(q.topic, []).append(q)
|
|
329
|
+
if args.json:
|
|
330
|
+
print(json.dumps({t: {"title": m.topics.get(t, ""),
|
|
331
|
+
"open": [q.id for q in qs.get(t, []) if q.open],
|
|
332
|
+
"answered": [q.id for q in qs.get(t, []) if not q.open],
|
|
333
|
+
"requirements": sorted(reqs.get(t, []))}
|
|
334
|
+
for t in sorted(set(reqs) | set(qs))}, indent=2))
|
|
335
|
+
return 0
|
|
336
|
+
print(_c(f" {'':2} {'':<32} {'open':>5} {'done':>5} {'reqs':>5}", DIM))
|
|
337
|
+
for t, title in sorted(m.topics.items()):
|
|
338
|
+
n_open = sum(1 for q in qs.get(t, []) if q.open)
|
|
339
|
+
n_done = len(qs.get(t, [])) - n_open
|
|
340
|
+
n = len(reqs.get(t, []))
|
|
341
|
+
print(f" {_c(t, BOLD)} {title:<32} {n_open:>5} {n_done:>5} {n:>5}")
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_questions(m: Model, args) -> int:
|
|
346
|
+
qs = m.questions
|
|
347
|
+
if args.topic:
|
|
348
|
+
qs = [q for q in qs if q.topic == args.topic.zfill(2)]
|
|
349
|
+
if args.open:
|
|
350
|
+
qs = [q for q in qs if q.open]
|
|
351
|
+
if args.answered:
|
|
352
|
+
qs = [q for q in qs if not q.open]
|
|
353
|
+
if args.json:
|
|
354
|
+
print(json.dumps([{**asdict(q), "open": q.open} for q in qs], indent=2))
|
|
355
|
+
return 0
|
|
356
|
+
for q in qs:
|
|
357
|
+
mark = _c("open", RED) if q.open else _c("done", DIM)
|
|
358
|
+
print(f"{mark} {_c(q.id, BOLD)} {q.title}")
|
|
359
|
+
if args.long and q.question:
|
|
360
|
+
print(f" {_c(q.question, DIM)}\n")
|
|
361
|
+
sys.stdout.flush()
|
|
362
|
+
print(f"{len(qs)} question" + ("" if len(qs) == 1 else "s"), file=sys.stderr)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_verification(m: Model, args) -> int:
|
|
367
|
+
if args.json:
|
|
368
|
+
print(json.dumps({k: {"name": v.name, "definition": v.definition, "automated": v.automated}
|
|
369
|
+
for k, v in m.cfg.verification.items()}, indent=2))
|
|
370
|
+
return 0
|
|
371
|
+
for k, v in m.cfg.verification.items():
|
|
372
|
+
auto = _c(" (automated)", DIM) if v.automated else ""
|
|
373
|
+
print(f"{_c(k, BOLD)} — {_c(v.name, BOLD)}{auto}\n {v.definition}\n")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_levels(m: Model, args) -> int:
|
|
378
|
+
if args.json:
|
|
379
|
+
print(json.dumps([{**asdict(lv), "units": [u.stem for u in m.units if u.level == lv.kind]}
|
|
380
|
+
for lv in m.cfg.levels], indent=2))
|
|
381
|
+
return 0
|
|
382
|
+
for lv in m.cfg.levels:
|
|
383
|
+
up = ", ".join(lv.satisfies) or "top level"
|
|
384
|
+
print(f"{_c(lv.kind, BOLD)} — {lv.title} {_c('satisfies ' + up, DIM)}")
|
|
385
|
+
for u in (u for u in m.units if u.level == lv.kind):
|
|
386
|
+
n = sum(1 for r in m.reqs.values() if r.unit == u.stem)
|
|
387
|
+
print(f" {', '.join(u.prefixes):<14} {u.title:<36} {n:>4} {_c(m.cfg.rel(u.csv_path), DIM)}")
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------- documents
|
|
392
|
+
|
|
393
|
+
def cmd_doc(m: Model, args) -> int:
|
|
394
|
+
unit = m.unit(args.unit)
|
|
395
|
+
if unit is None:
|
|
396
|
+
return _err(f"No unit {args.unit!r}. `reqdeck levels` lists them.")
|
|
397
|
+
if args.json:
|
|
398
|
+
entries = []
|
|
399
|
+
for e in m.document(unit):
|
|
400
|
+
if e["type"] == "section":
|
|
401
|
+
s = e["section"]
|
|
402
|
+
entries.append({"type": "section", "depth": e["depth"], "number": e["number"],
|
|
403
|
+
"key": s.key, "title": s.title, "description": s.description})
|
|
404
|
+
else:
|
|
405
|
+
entries.append({"type": "req", "depth": e["depth"], **e["req"].to_dict()})
|
|
406
|
+
print(json.dumps({"unit": unit.stem, "title": unit.title, "entries": entries}, indent=2))
|
|
407
|
+
return 0
|
|
408
|
+
if args.markdown:
|
|
409
|
+
sys.stdout.write(render.document(m, unit))
|
|
410
|
+
return 0
|
|
411
|
+
print(_c(unit.title, BOLD))
|
|
412
|
+
if unit.description:
|
|
413
|
+
print(_c(unit.description, DIM))
|
|
414
|
+
for e in m.document(unit):
|
|
415
|
+
if e["type"] == "section":
|
|
416
|
+
ind = " " * (e["depth"] - 1)
|
|
417
|
+
print(f"\n{ind}{_c(e['number'] + ' ' + e['section'].title, BOLD)}")
|
|
418
|
+
else:
|
|
419
|
+
r = e["req"]
|
|
420
|
+
ind = " " * e["depth"]
|
|
421
|
+
print(f"{ind}{_c(r.id, BOLD)} {r.label}")
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# --------------------------------------------------------------------- writing
|
|
426
|
+
|
|
427
|
+
def cmd_add(m: Model, args) -> int:
|
|
428
|
+
fields = {"title": args.title, "statement": args.statement, "rationale": args.rationale,
|
|
429
|
+
"satisfies": args.satisfies, "topic": args.topic, "source": args.source,
|
|
430
|
+
"verification": args.verification or "", "security": "yes" if args.security else "",
|
|
431
|
+
"section": args.section}
|
|
432
|
+
for k, v in args.field or []:
|
|
433
|
+
fields[k] = v
|
|
434
|
+
try:
|
|
435
|
+
rid, rel, line = ops.add(m, args.prefix, fields)
|
|
436
|
+
except ops.OpError as e:
|
|
437
|
+
return _err(str(e))
|
|
438
|
+
if args.json:
|
|
439
|
+
print(json.dumps({"id": rid, "file": rel, "line": line}, indent=2))
|
|
440
|
+
else:
|
|
441
|
+
print(f"{_c(rid, BOLD)} added to {rel}:{line}")
|
|
442
|
+
lv = m.cfg.level(m.unit(args.prefix).level)
|
|
443
|
+
if not lv or lv.reviewed:
|
|
444
|
+
print(_c("Status is Proposed. Only the user marks a requirement Accepted.", DIM))
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_edit(m: Model, args) -> int:
|
|
449
|
+
r = _find(m, args.id)
|
|
450
|
+
if r is None:
|
|
451
|
+
return 1
|
|
452
|
+
changes = {k: getattr(args, k) for k in EDITABLE if getattr(args, k) is not None}
|
|
453
|
+
for k, v in args.field or []:
|
|
454
|
+
changes[k] = v
|
|
455
|
+
try:
|
|
456
|
+
changed, reopened = ops.edit(m, r.id, changes)
|
|
457
|
+
except ops.OpError as e:
|
|
458
|
+
return _err(str(e))
|
|
459
|
+
if args.json:
|
|
460
|
+
print(json.dumps({"id": r.id, "changed": changed, "reopened": reopened}, indent=2))
|
|
461
|
+
else:
|
|
462
|
+
print(f"{_c(r.id, BOLD)} updated: {', '.join(changed)}")
|
|
463
|
+
if reopened:
|
|
464
|
+
print(_c("It was Accepted, so it is now Reopened and needs the user's review again. "
|
|
465
|
+
"Say what changed in source.", DIM))
|
|
466
|
+
return 0
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def cmd_fmt(m: Model, args) -> int:
|
|
470
|
+
changed = []
|
|
471
|
+
for u in m.units:
|
|
472
|
+
try:
|
|
473
|
+
rows = store.read_rows(u)
|
|
474
|
+
except store.StoreError as e:
|
|
475
|
+
return _err(str(e))
|
|
476
|
+
if not store.is_canonical(u, rows):
|
|
477
|
+
store.write_rows(u, [r.values for r in rows])
|
|
478
|
+
changed.append(m.cfg.rel(u.csv_path))
|
|
479
|
+
if args.json:
|
|
480
|
+
print(json.dumps({"rewritten": changed}, indent=2))
|
|
481
|
+
else:
|
|
482
|
+
print("\n".join(f"rewrote {c}" for c in changed) or "Already canonical.")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_unit(m: Model, args) -> int:
|
|
487
|
+
lv = m.cfg.level(args.level)
|
|
488
|
+
if lv is None:
|
|
489
|
+
return _err(f"No level {args.level!r} in .reqdeck.toml — one of "
|
|
490
|
+
f"{', '.join(x.kind for x in m.cfg.levels)}")
|
|
491
|
+
prefixes = split(args.prefix)
|
|
492
|
+
for p in prefixes:
|
|
493
|
+
if m.unit(p):
|
|
494
|
+
return _err(f"{p} is already a unit.")
|
|
495
|
+
if args.columns:
|
|
496
|
+
columns = ["id"] + [c for c in split(args.columns) if c != "id"]
|
|
497
|
+
elif not lv.reviewed:
|
|
498
|
+
columns = list(store.STORY_COLUMNS)
|
|
499
|
+
else:
|
|
500
|
+
columns = [c for c in store.STANDARD if lv.topic or c != "topic"]
|
|
501
|
+
try:
|
|
502
|
+
u = store.create_unit(m.cfg.requirements, args.file, lv.kind, prefixes,
|
|
503
|
+
args.title, args.code, columns)
|
|
504
|
+
except store.StoreError as e:
|
|
505
|
+
return _err(str(e))
|
|
506
|
+
if args.json:
|
|
507
|
+
print(json.dumps({"unit": u.stem, "csv": m.cfg.rel(u.csv_path),
|
|
508
|
+
"toml": m.cfg.rel(u.toml_path)}, indent=2))
|
|
509
|
+
else:
|
|
510
|
+
print(f"Created {m.cfg.rel(u.csv_path)} and {m.cfg.rel(u.toml_path)}")
|
|
511
|
+
return 0
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ------------------------------------------------------------------------ links
|
|
515
|
+
|
|
516
|
+
def _repo_slug(root: Path) -> str:
|
|
517
|
+
try:
|
|
518
|
+
url = subprocess.run(["git", "-C", str(root), "remote", "get-url", "origin"],
|
|
519
|
+
capture_output=True, text=True, timeout=5).stdout.strip()
|
|
520
|
+
except (OSError, subprocess.SubprocessError):
|
|
521
|
+
return ""
|
|
522
|
+
mm = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", url)
|
|
523
|
+
return mm.group(1) if mm else ""
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def cmd_url(m: Model, args) -> int:
|
|
527
|
+
"""A link to where a requirement is written, for pasting into an issue.
|
|
528
|
+
|
|
529
|
+
Rows are only ever appended, so a row's line number is as stable as its ID
|
|
530
|
+
and the link can name it directly."""
|
|
531
|
+
repo = _repo_slug(m.cfg.root)
|
|
532
|
+
out = []
|
|
533
|
+
for rid in args.ids:
|
|
534
|
+
r = m.reqs.get(rid)
|
|
535
|
+
if r is None:
|
|
536
|
+
return _err(f"no such requirement: {rid}")
|
|
537
|
+
url = f"https://github.com/{repo}/blob/{args.ref}/{r.file}?plain=1#L{r.line}" if repo else ""
|
|
538
|
+
out.append({"id": rid, "title": r.title, "url": url, "markdown": f"[{rid}]({url})"})
|
|
539
|
+
if args.json:
|
|
540
|
+
print(json.dumps(out, indent=2))
|
|
541
|
+
return 0
|
|
542
|
+
for o in out:
|
|
543
|
+
print(o["markdown"] if args.markdown else o["url"])
|
|
544
|
+
return 0
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ------------------------------------------------------------------------- main
|
|
548
|
+
|
|
549
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
550
|
+
p = argparse.ArgumentParser(prog="reqdeck", description="Requirements, open questions and traceability.")
|
|
551
|
+
p.add_argument("--version", action="version", version=f"reqdeck {__version__}")
|
|
552
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
553
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
554
|
+
|
|
555
|
+
sub.add_parser("init", help="set this directory up as a reqdeck project, asking as it goes")
|
|
556
|
+
sub.add_parser("tui", help="browse the requirements and open questions")
|
|
557
|
+
sk = sub.add_parser("skills", help="copy the agent skills into .claude/skills and .agents/skills")
|
|
558
|
+
sk.add_argument("--update", action="store_true",
|
|
559
|
+
help="replace reqdeck's own copies with this version's; never a folder of yours")
|
|
560
|
+
|
|
561
|
+
g = sub.add_parser("get", help="one requirement, with its chain up and down")
|
|
562
|
+
g.add_argument("id")
|
|
563
|
+
g.set_defaults(fn=cmd_get)
|
|
564
|
+
|
|
565
|
+
s = sub.add_parser("search", help="regex over id, title, statement, rationale, detail")
|
|
566
|
+
s.add_argument("query")
|
|
567
|
+
s.add_argument("-l", "--long", action="store_true", help="include statements")
|
|
568
|
+
s.set_defaults(fn=cmd_search)
|
|
569
|
+
|
|
570
|
+
ls = sub.add_parser("list", help="all requirements, filterable")
|
|
571
|
+
ls.add_argument("--kind", help="a level from .reqdeck.toml, e.g. TSR")
|
|
572
|
+
ls.add_argument("--unit", help="file stem or prefix, e.g. firmware-node or TSSR_FWN")
|
|
573
|
+
ls.add_argument("--topic", help="OPEN-QUESTIONS topic number, e.g. 06")
|
|
574
|
+
ls.add_argument("--section", help="section key; includes its subsections")
|
|
575
|
+
ls.add_argument("--verification")
|
|
576
|
+
ls.add_argument("--status", choices=list(STATUS) + [k.lower() for k in STATUS],
|
|
577
|
+
help="review status; P/A/R shown in the third column")
|
|
578
|
+
ls.add_argument("--security", action="store_true", help="only security-relevant requirements")
|
|
579
|
+
ls.add_argument("-l", "--long", action="store_true", help="include statements")
|
|
580
|
+
ls.set_defaults(fn=cmd_list)
|
|
581
|
+
|
|
582
|
+
t = sub.add_parser("tree", help="trace chain in one or both directions")
|
|
583
|
+
t.add_argument("id")
|
|
584
|
+
t.add_argument("--up", action="store_true", help="only what it satisfies")
|
|
585
|
+
t.add_argument("--down", action="store_true", help="only what satisfies it")
|
|
586
|
+
t.set_defaults(fn=cmd_tree)
|
|
587
|
+
|
|
588
|
+
sub.add_parser("coverage", help="where traceability is missing").set_defaults(fn=cmd_coverage)
|
|
589
|
+
|
|
590
|
+
tr = sub.add_parser("trace", help="are these requirements referenced from source and test")
|
|
591
|
+
tr.add_argument("ids", nargs="+", metavar="ID")
|
|
592
|
+
tr.set_defaults(fn=cmd_trace)
|
|
593
|
+
|
|
594
|
+
sub.add_parser("check", help="validate everything; exit 1 on any problem").set_defaults(fn=cmd_check)
|
|
595
|
+
|
|
596
|
+
sub.add_parser("topics", help="topics with open and answered questions and requirement counts") \
|
|
597
|
+
.set_defaults(fn=cmd_topics)
|
|
598
|
+
|
|
599
|
+
q = sub.add_parser("questions", help="open questions, filterable")
|
|
600
|
+
q.add_argument("--topic")
|
|
601
|
+
q.add_argument("--open", action="store_true", help="only unanswered")
|
|
602
|
+
q.add_argument("--answered", action="store_true", help="only answered")
|
|
603
|
+
q.add_argument("-l", "--long", action="store_true", help="include the question text")
|
|
604
|
+
q.set_defaults(fn=cmd_questions)
|
|
605
|
+
|
|
606
|
+
sub.add_parser("verification", help="the verification methods").set_defaults(fn=cmd_verification)
|
|
607
|
+
sub.add_parser("levels", help="levels and the units in each").set_defaults(fn=cmd_levels)
|
|
608
|
+
|
|
609
|
+
d = sub.add_parser("doc", help="one unit as a document, with its sections")
|
|
610
|
+
d.add_argument("unit", help="file stem or prefix")
|
|
611
|
+
d.add_argument("--markdown", action="store_true", help="render as markdown")
|
|
612
|
+
d.set_defaults(fn=cmd_doc)
|
|
613
|
+
|
|
614
|
+
a = sub.add_parser("add", help="allocate the next ID and append a new requirement")
|
|
615
|
+
a.add_argument("prefix", help="e.g. TSR, TSSR_HWB, ICD_BUS")
|
|
616
|
+
a.add_argument("--title", default="")
|
|
617
|
+
a.add_argument("--statement", required=True)
|
|
618
|
+
a.add_argument("--source", default="", help="what decided it — an OQ, an ADR, a date")
|
|
619
|
+
a.add_argument("--verification")
|
|
620
|
+
a.add_argument("--satisfies", default="", help="IDs, comma or space separated")
|
|
621
|
+
a.add_argument("--topic", default="", help="OPEN-QUESTIONS topic number")
|
|
622
|
+
a.add_argument("--rationale", default="")
|
|
623
|
+
a.add_argument("--section", default="", help="section key from the unit's .toml")
|
|
624
|
+
a.add_argument("--security", action="store_true", help="carries a security property")
|
|
625
|
+
a.add_argument("--field", nargs=2, action="append", metavar=("COLUMN", "VALUE"),
|
|
626
|
+
help="a column of this unit that reqdeck does not interpret")
|
|
627
|
+
a.set_defaults(fn=cmd_add)
|
|
628
|
+
|
|
629
|
+
e = sub.add_parser("edit", help="change fields of an existing requirement")
|
|
630
|
+
e.add_argument("id")
|
|
631
|
+
for f in EDITABLE:
|
|
632
|
+
if f != "security":
|
|
633
|
+
e.add_argument(f"--{f}")
|
|
634
|
+
e.add_argument("--security", help="yes, or empty to clear")
|
|
635
|
+
e.add_argument("--field", nargs=2, action="append", metavar=("COLUMN", "VALUE"))
|
|
636
|
+
e.set_defaults(fn=cmd_edit)
|
|
637
|
+
|
|
638
|
+
sub.add_parser("fmt", help="rewrite the CSV files in canonical form").set_defaults(fn=cmd_fmt)
|
|
639
|
+
|
|
640
|
+
un = sub.add_parser("unit", help="create a new unit: a CSV and its .toml")
|
|
641
|
+
un.add_argument("file", help="file stem, e.g. firmware-node")
|
|
642
|
+
un.add_argument("--level", required=True)
|
|
643
|
+
un.add_argument("--prefix", required=True,
|
|
644
|
+
help="e.g. TSSR_FWN; several, space-separated, for one file of stories")
|
|
645
|
+
un.add_argument("--title", required=True)
|
|
646
|
+
un.add_argument("--code", action="store_true", help="implemented in software, so code must reference it")
|
|
647
|
+
un.add_argument("--columns", help="override the column list")
|
|
648
|
+
un.set_defaults(fn=cmd_unit)
|
|
649
|
+
|
|
650
|
+
u = sub.add_parser("url", help="link to where a requirement is written, for an issue")
|
|
651
|
+
u.add_argument("ids", nargs="+", metavar="ID")
|
|
652
|
+
u.add_argument("--ref", default="main", help="branch or tag to link at (default main)")
|
|
653
|
+
u.add_argument("--markdown", action="store_true", help="as [ID](url)")
|
|
654
|
+
u.set_defaults(fn=cmd_url)
|
|
655
|
+
return p
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def load_model(scan: bool = True) -> Model:
|
|
659
|
+
root = config.find_root()
|
|
660
|
+
if root is None:
|
|
661
|
+
raise config.ConfigError(f"No {config.CONFIG_NAME} found here or above. "
|
|
662
|
+
f"Run `reqdeck init` in the project root, or set REQDECK_ROOT.")
|
|
663
|
+
return Model.load(config.load(root), scan=scan)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def cmd_skills(args) -> int:
|
|
667
|
+
from .init import SKILL_DIRS, copy_skills
|
|
668
|
+
root = config.find_root()
|
|
669
|
+
if root is None:
|
|
670
|
+
return _err(f"No {config.CONFIG_NAME} found here or above — run `reqdeck init` first.")
|
|
671
|
+
written, kept = copy_skills(root, list(SKILL_DIRS), update=args.update)
|
|
672
|
+
for w in written:
|
|
673
|
+
print(f"wrote {w}")
|
|
674
|
+
for k in kept:
|
|
675
|
+
print(f"kept {k}" + ("" if args.update else " — --update replaces reqdeck's own copies"))
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def main(argv=None) -> int:
|
|
680
|
+
args = build_parser().parse_args(argv)
|
|
681
|
+
if args.cmd == "init":
|
|
682
|
+
from .init import run
|
|
683
|
+
return run(Path.cwd())
|
|
684
|
+
if args.cmd == "skills":
|
|
685
|
+
return cmd_skills(args)
|
|
686
|
+
if args.cmd == "tui":
|
|
687
|
+
try:
|
|
688
|
+
from .viewer import main as tui
|
|
689
|
+
except ModuleNotFoundError as e:
|
|
690
|
+
if e.name in ("textual", "rich"):
|
|
691
|
+
return _err("The TUI needs textual: pip install textual")
|
|
692
|
+
raise
|
|
693
|
+
try:
|
|
694
|
+
return tui()
|
|
695
|
+
except config.ConfigError as e:
|
|
696
|
+
return _err(str(e))
|
|
697
|
+
try:
|
|
698
|
+
m = load_model(scan=args.cmd not in ("add", "edit", "fmt", "unit", "url", "levels",
|
|
699
|
+
"verification", "questions", "doc"))
|
|
700
|
+
except config.ConfigError as e:
|
|
701
|
+
print(e, file=sys.stderr)
|
|
702
|
+
return 2
|
|
703
|
+
try:
|
|
704
|
+
return args.fn(m, args)
|
|
705
|
+
except store.StoreError as e:
|
|
706
|
+
return _err(str(e))
|