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