reqdeck 0.1.0__tar.gz → 0.2.0__tar.gz

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 (66) hide show
  1. reqdeck-0.2.0/CHANGELOG.md +54 -0
  2. {reqdeck-0.1.0 → reqdeck-0.2.0}/PKG-INFO +68 -3
  3. {reqdeck-0.1.0 → reqdeck-0.2.0}/README.md +66 -1
  4. {reqdeck-0.1.0 → reqdeck-0.2.0}/pyproject.toml +2 -2
  5. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/__init__.py +1 -1
  6. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/cli.py +20 -11
  7. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/config.py +23 -0
  8. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/init.py +6 -1
  9. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/model.py +118 -7
  10. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/README.md +2 -2
  11. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/viewer.py +123 -9
  12. {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-cli/SKILL.md +23 -2
  13. {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_reqdeck.py +88 -0
  14. {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_tui.py +39 -0
  15. {reqdeck-0.1.0 → reqdeck-0.2.0}/.gitignore +0 -0
  16. {reqdeck-0.1.0 → reqdeck-0.2.0}/LICENSE +0 -0
  17. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/.reqdeck.toml +0 -0
  18. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/README.md +0 -0
  19. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Sources/ReminderScheduler.swift +0 -0
  20. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Sources/TaskItem.swift +0 -0
  21. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/Tests/ReminderSchedulerTests.swift +0 -0
  22. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/OPEN-QUESTIONS.md +0 -0
  23. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/product-brief.md +0 -0
  24. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/app.csv +0 -0
  25. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/app.toml +0 -0
  26. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.csv +0 -0
  27. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.details.md +0 -0
  28. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/if-sync.toml +0 -0
  29. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/stories.csv +0 -0
  30. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/stories.toml +0 -0
  31. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/system.csv +0 -0
  32. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/system.toml +0 -0
  33. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/widget.csv +0 -0
  34. {reqdeck-0.1.0 → reqdeck-0.2.0}/examples/tick/docs/requirements/widget.toml +0 -0
  35. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/__main__.py +0 -0
  36. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/editor.py +0 -0
  37. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/markers.py +0 -0
  38. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/ops.py +0 -0
  39. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/questions.py +0 -0
  40. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/render.py +0 -0
  41. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/store.py +0 -0
  42. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/templates/OPEN-QUESTIONS.md +0 -0
  43. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/__init__.py +0 -0
  44. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/atom-one-dark.toml +0 -0
  45. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/atom-one-light.toml +0 -0
  46. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-frappe.toml +0 -0
  47. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-latte.toml +0 -0
  48. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-macchiato.toml +0 -0
  49. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/catppuccin-mocha.toml +0 -0
  50. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/dracula.toml +0 -0
  51. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/flexoki.toml +0 -0
  52. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/gruvbox.toml +0 -0
  53. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/monokai.toml +0 -0
  54. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/nord.toml +0 -0
  55. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine-dawn.toml +0 -0
  56. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine-moon.toml +0 -0
  57. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/rose-pine.toml +0 -0
  58. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/solarized-dark.toml +0 -0
  59. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/solarized-light.toml +0 -0
  60. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/textual-dark.toml +0 -0
  61. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/textual-light.toml +0 -0
  62. {reqdeck-0.1.0 → reqdeck-0.2.0}/reqdeck/themes/tokyo-night.toml +0 -0
  63. {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/SKILL.md +0 -0
  64. {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/references/markers.md +0 -0
  65. {reqdeck-0.1.0 → reqdeck-0.2.0}/skills/reqdeck-method/references/writing-requirements.md +0 -0
  66. {reqdeck-0.1.0 → reqdeck-0.2.0}/tests/test_init.py +0 -0
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ What changed in each release of reqdeck. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ Every change a user would notice gets a line under **Unreleased**, in the commit that makes
8
+ it. To release: rename **Unreleased** to the version and date, open a new empty
9
+ **Unreleased** above it, bump the version in `reqdeck/__init__.py` and
10
+ `.claude-plugin/plugin.json`, re-record the README's media with `scripts/record-demo.sh`,
11
+ then tag `vX.Y.Z` — and paste the section into the GitHub release.
12
+
13
+ ## [Unreleased]
14
+
15
+ ## [0.2.0] - 2026-10-17
16
+
17
+ ### Added
18
+
19
+ - The TUI reloads from disk every 30 seconds, so what an agent or an editor writes shows up
20
+ on its own. `Reloading` spins at the left of the hint line while it runs; a reload that
21
+ finds nothing changed redraws nothing, and one that does keeps the reading pane where it
22
+ was scrolled. `"reload"` in `~/.config/reqdeck/tui.json` sets the seconds, `0` turns it off.
23
+ - Every requirement shows how far it is built, beside its review status: Done, Untested,
24
+ Partial, Not implemented or Unsatisfied, judged over it and everything that satisfies it, so
25
+ a user story is Done only once its whole chain is. The tree marks it `✓` `◑` `◔` `○` `✗`, the
26
+ card's title carries it, the level view counts the Done per unit, and `reqdeck get` prints
27
+ it with what keeps it from Done — `progress` in `--json`.
28
+ - `satisfied`, `code` and `test` on a `[[levels]]` entry set what that level needs before it is
29
+ done. Unset, they follow from `satisfies`, the unit's `code` and the verification method, as
30
+ before. `code = true` or `test = true` on a `traced = false` level is now allowed, met by
31
+ markers on every requirement beneath it.
32
+
33
+ ### Changed
34
+
35
+ - A requirement that must both be satisfied from below and named by code is done either way:
36
+ broken down, with code naming every requirement beneath it, or implemented as it stands,
37
+ with code naming it. Before, it needed its own code marker and something satisfying it — an
38
+ interface requirement with code but nothing satisfying it was a gap, and so was one whose
39
+ code sat on the software requirements beneath it.
40
+ - A test beneath a requirement counts for it only where its level must be satisfied, and then
41
+ every requirement beneath it needs one — as for code. Before, one test anywhere beneath was
42
+ enough, so a system requirement with one tested and one untested software requirement
43
+ beneath it counted as tested.
44
+ - `r` reloads in the background with the same spinner, instead of freezing the TUI while it
45
+ reads. A file that fails to load is reported once, not on every reload.
46
+
47
+ ## [0.1.0] - 2026-10-10
48
+
49
+ First release: CSV requirements and open questions in your repository, `@REQ_REF` markers
50
+ in code and tests, the `reqdeck` CLI and TUI, and the skills for Claude Code.
51
+
52
+ [Unreleased]: https://github.com/danielriege/reqdeck/compare/v0.2.0...HEAD
53
+ [0.2.0]: https://github.com/danielriege/reqdeck/compare/v0.1.0...v0.2.0
54
+ [0.1.0]: https://github.com/danielriege/reqdeck/releases/tag/v0.1.0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: reqdeck
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Requirements, open questions and traceability in your git repo — a CLI, a TUI, and skills for your AI agent
5
5
  Project-URL: Homepage, https://github.com/danielriege/reqdeck
6
6
  Project-URL: Source, https://github.com/danielriege/reqdeck
7
7
  Project-URL: Issues, https://github.com/danielriege/reqdeck/issues
8
- Project-URL: Changelog, https://github.com/danielriege/reqdeck/releases
8
+ Project-URL: Changelog, https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md
9
9
  Author: Daniel Riege
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
@@ -32,7 +32,7 @@ Description-Content-Type: text/markdown
32
32
  Think of <a href="https://www.ibm.com/products/requirements-management">IBM DOORS</a>, but it lives in your git repo, fits in your terminal, and your AI agent can read it.
33
33
  <br><br>
34
34
 
35
- <a href="https://github.com/danielriege/reqdeck/actions/workflows/ci.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
35
+ <a href="https://github.com/danielriege/reqdeck/actions/workflows/release.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/release.yml/badge.svg" alt="Release"></a>
36
36
  <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+">
37
37
  <a href="https://pypi.org/project/reqdeck/"><img src="https://img.shields.io/pypi/v/reqdeck" alt="PyPI"></a>
38
38
  <a href="https://github.com/danielriege/reqdeck/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
@@ -158,6 +158,11 @@ verification method fits, where a marker goes.
158
158
  status, statement, detail tables and fields — beside the open questions by topic. The tree
159
159
  always has the cursor; the right pane shows what it highlights.
160
160
 
161
+ Beside each requirement in the tree are two marks. The letter is its review status: `A`ccepted,
162
+ `P`roposed, `R`eopened. The symbol is how far it is built, judged over it and everything that
163
+ satisfies it: `✓` done, `◑` untested, `◔` partial, `○` not implemented, `✗` unsatisfied. The
164
+ card says which requirements beneath it keep it from done, and `reqdeck get` prints the same.
165
+
161
166
  | | |
162
167
  |---|---|
163
168
  | `j` `k` `h` `l` · `gg` `G` | Move through the tree, as in vim |
@@ -183,6 +188,11 @@ Flexoki), each given a full colour suite by a file in [reqdeck/themes/](https://
183
188
  where you can add your own. Your theme and sidebar width are remembered in
184
189
  `~/.config/reqdeck/tui.json`.
185
190
 
191
+ It reloads from disk every 30 seconds, so what an agent or your editor writes shows up on its
192
+ own — `Reloading` spins at the left of the hint line while it does, and only a change redraws
193
+ anything. `r` reloads at once. Set `"reload"` in `tui.json` to the seconds between reloads, or
194
+ `0` to turn it off.
195
+
186
196
  ## The CLI
187
197
 
188
198
  ```bash
@@ -264,6 +274,7 @@ traced = false
264
274
  kind = "SW"
265
275
  satisfies = ["SYS"]
266
276
  topic = true
277
+ code = true # what it needs before it is done — see below
267
278
 
268
279
  [verification.T]
269
280
  name = "Test"
@@ -282,6 +293,51 @@ command = "nvim +{line} {file}" # or "code --goto {file}:{line}"
282
293
  Without `[verification]` the methods are A, I, D and T. Without `[markers]` every common
283
294
  source file is scanned and a marker may sit anywhere.
284
295
 
296
+ ### What a level needs
297
+
298
+ Three keys on a `[[levels]]` entry say what each of its requirements needs before it is done.
299
+ Each is `true` or `false`; left out, it takes the default in the last column, which is how
300
+ reqdeck behaved before the keys existed.
301
+
302
+ | Key | Met when | Left out |
303
+ |---|---|---|
304
+ | `satisfied` | a requirement of a lower level names it in `satisfies` — **or**, where the level also has `code = true`, a code marker names it itself | `true` if another level lists this one in its `satisfies` |
305
+ | `code` | a code `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by code, or is satisfied by requirements that all are | the unit's `code = true` |
306
+ | `test` | a test `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by a test, or is satisfied by requirements that all are | the verification method's `automated = true` |
307
+
308
+ `code` and `test` follow one rule: **a marker on the requirement itself, or a marker on every
309
+ branch beneath it.** "Beneath" is everything that satisfies it, all the way down, and *every
310
+ branch* means each requirement satisfying it is named by a marker, or is in turn satisfied by
311
+ requirements that all are. One branch without is enough to leave it short. With three
312
+ software requirements satisfying a system requirement and code on two, the system
313
+ requirement is not coded and shows as partial; code on the third makes it coded. Tests are
314
+ counted the same way, separately: code on all three and a test on two leaves it untested.
315
+
316
+ So a level with `satisfied = true` and `code = true` is done in either of two ways:
317
+
318
+ - **Broken down.** Lower requirements satisfy it, and code names every one of them (or every
319
+ one of theirs, where they are broken down further).
320
+ - **Implemented as it stands.** Code names it directly, and nothing needs to satisfy it.
321
+
322
+ `test = true` adds the same again for tests, on top of either way.
323
+
324
+ With `satisfied = false`, only a marker on the requirement itself counts, for `code` and for
325
+ `test`. A level with `traced = false` may never be named by a marker directly, so `code = true`
326
+ or `test = true` there needs `satisfied = true` — the markers can only be beneath it — and
327
+ `check` refuses the config otherwise.
328
+
329
+ `coverage`, `trace`, `get` and the progress marks in the TUI all judge by these same rules.
330
+
331
+ ```toml
332
+ [[levels]]
333
+ kind = "TSR" # a technical system requirement
334
+ satisfies = ["STORY"]
335
+ traced = false # never named by code itself...
336
+ satisfied = true # ...so a TSSR or an ICD must satisfy it,
337
+ code = true # and code must name every one of those,
338
+ test = true # and a test must name every one of those too
339
+ ```
340
+
285
341
  ## Limitations
286
342
 
287
343
  - **Small teams.** It is built for one person and their agents, or a handful of people. Two
@@ -300,6 +356,15 @@ source file is scanned and a marker may sit anywhere.
300
356
  python3 -m unittest discover tests # the TUI tests skip without textual
301
357
  ```
302
358
 
359
+ The demo GIF and the screenshot above are recorded, not drawn: `scripts/record-demo.sh`
360
+ installs this checkout into a throwaway virtualenv and plays `docs/demo.tape` and
361
+ `docs/document.tape` through [vhs](https://github.com/charmbracelet/vhs). It needs vhs and nvim.
362
+
363
+ Every change a user would notice gets a line under `Unreleased` in
364
+ [CHANGELOG.md](https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md), in the same
365
+ commit. A release renames that section to its version; publishing refuses a version the
366
+ changelog does not name.
367
+
303
368
  ## License
304
369
 
305
370
  MIT. See [LICENSE](https://github.com/danielriege/reqdeck/blob/main/LICENSE).
@@ -5,7 +5,7 @@
5
5
  Think of <a href="https://www.ibm.com/products/requirements-management">IBM DOORS</a>, but it lives in your git repo, fits in your terminal, and your AI agent can read it.
6
6
  <br><br>
7
7
 
8
- <a href="https://github.com/danielriege/reqdeck/actions/workflows/ci.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
8
+ <a href="https://github.com/danielriege/reqdeck/actions/workflows/release.yml"><img src="https://github.com/danielriege/reqdeck/actions/workflows/release.yml/badge.svg" alt="Release"></a>
9
9
  <img src="https://img.shields.io/badge/python-3.11%2B-blue" alt="Python 3.11+">
10
10
  <a href="https://pypi.org/project/reqdeck/"><img src="https://img.shields.io/pypi/v/reqdeck" alt="PyPI"></a>
11
11
  <a href="https://github.com/danielriege/reqdeck/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
@@ -131,6 +131,11 @@ verification method fits, where a marker goes.
131
131
  status, statement, detail tables and fields — beside the open questions by topic. The tree
132
132
  always has the cursor; the right pane shows what it highlights.
133
133
 
134
+ Beside each requirement in the tree are two marks. The letter is its review status: `A`ccepted,
135
+ `P`roposed, `R`eopened. The symbol is how far it is built, judged over it and everything that
136
+ satisfies it: `✓` done, `◑` untested, `◔` partial, `○` not implemented, `✗` unsatisfied. The
137
+ card says which requirements beneath it keep it from done, and `reqdeck get` prints the same.
138
+
134
139
  | | |
135
140
  |---|---|
136
141
  | `j` `k` `h` `l` · `gg` `G` | Move through the tree, as in vim |
@@ -156,6 +161,11 @@ Flexoki), each given a full colour suite by a file in [reqdeck/themes/](https://
156
161
  where you can add your own. Your theme and sidebar width are remembered in
157
162
  `~/.config/reqdeck/tui.json`.
158
163
 
164
+ It reloads from disk every 30 seconds, so what an agent or your editor writes shows up on its
165
+ own — `Reloading` spins at the left of the hint line while it does, and only a change redraws
166
+ anything. `r` reloads at once. Set `"reload"` in `tui.json` to the seconds between reloads, or
167
+ `0` to turn it off.
168
+
159
169
  ## The CLI
160
170
 
161
171
  ```bash
@@ -237,6 +247,7 @@ traced = false
237
247
  kind = "SW"
238
248
  satisfies = ["SYS"]
239
249
  topic = true
250
+ code = true # what it needs before it is done — see below
240
251
 
241
252
  [verification.T]
242
253
  name = "Test"
@@ -255,6 +266,51 @@ command = "nvim +{line} {file}" # or "code --goto {file}:{line}"
255
266
  Without `[verification]` the methods are A, I, D and T. Without `[markers]` every common
256
267
  source file is scanned and a marker may sit anywhere.
257
268
 
269
+ ### What a level needs
270
+
271
+ Three keys on a `[[levels]]` entry say what each of its requirements needs before it is done.
272
+ Each is `true` or `false`; left out, it takes the default in the last column, which is how
273
+ reqdeck behaved before the keys existed.
274
+
275
+ | Key | Met when | Left out |
276
+ |---|---|---|
277
+ | `satisfied` | a requirement of a lower level names it in `satisfies` — **or**, where the level also has `code = true`, a code marker names it itself | `true` if another level lists this one in its `satisfies` |
278
+ | `code` | a code `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by code, or is satisfied by requirements that all are | the unit's `code = true` |
279
+ | `test` | a test `@REQ_REF` names it — **or**, where the level also has `satisfied = true`, **every** requirement beneath it is named by a test, or is satisfied by requirements that all are | the verification method's `automated = true` |
280
+
281
+ `code` and `test` follow one rule: **a marker on the requirement itself, or a marker on every
282
+ branch beneath it.** "Beneath" is everything that satisfies it, all the way down, and *every
283
+ branch* means each requirement satisfying it is named by a marker, or is in turn satisfied by
284
+ requirements that all are. One branch without is enough to leave it short. With three
285
+ software requirements satisfying a system requirement and code on two, the system
286
+ requirement is not coded and shows as partial; code on the third makes it coded. Tests are
287
+ counted the same way, separately: code on all three and a test on two leaves it untested.
288
+
289
+ So a level with `satisfied = true` and `code = true` is done in either of two ways:
290
+
291
+ - **Broken down.** Lower requirements satisfy it, and code names every one of them (or every
292
+ one of theirs, where they are broken down further).
293
+ - **Implemented as it stands.** Code names it directly, and nothing needs to satisfy it.
294
+
295
+ `test = true` adds the same again for tests, on top of either way.
296
+
297
+ With `satisfied = false`, only a marker on the requirement itself counts, for `code` and for
298
+ `test`. A level with `traced = false` may never be named by a marker directly, so `code = true`
299
+ or `test = true` there needs `satisfied = true` — the markers can only be beneath it — and
300
+ `check` refuses the config otherwise.
301
+
302
+ `coverage`, `trace`, `get` and the progress marks in the TUI all judge by these same rules.
303
+
304
+ ```toml
305
+ [[levels]]
306
+ kind = "TSR" # a technical system requirement
307
+ satisfies = ["STORY"]
308
+ traced = false # never named by code itself...
309
+ satisfied = true # ...so a TSSR or an ICD must satisfy it,
310
+ code = true # and code must name every one of those,
311
+ test = true # and a test must name every one of those too
312
+ ```
313
+
258
314
  ## Limitations
259
315
 
260
316
  - **Small teams.** It is built for one person and their agents, or a handful of people. Two
@@ -273,6 +329,15 @@ source file is scanned and a marker may sit anywhere.
273
329
  python3 -m unittest discover tests # the TUI tests skip without textual
274
330
  ```
275
331
 
332
+ The demo GIF and the screenshot above are recorded, not drawn: `scripts/record-demo.sh`
333
+ installs this checkout into a throwaway virtualenv and plays `docs/demo.tape` and
334
+ `docs/document.tape` through [vhs](https://github.com/charmbracelet/vhs). It needs vhs and nvim.
335
+
336
+ Every change a user would notice gets a line under `Unreleased` in
337
+ [CHANGELOG.md](https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md), in the same
338
+ commit. A release renames that section to its version; publishing refuses a version the
339
+ changelog does not name.
340
+
276
341
  ## License
277
342
 
278
343
  MIT. See [LICENSE](https://github.com/danielriege/reqdeck/blob/main/LICENSE).
@@ -32,7 +32,7 @@ classifiers = [
32
32
  Homepage = "https://github.com/danielriege/reqdeck"
33
33
  Source = "https://github.com/danielriege/reqdeck"
34
34
  Issues = "https://github.com/danielriege/reqdeck/issues"
35
- Changelog = "https://github.com/danielriege/reqdeck/releases"
35
+ Changelog = "https://github.com/danielriege/reqdeck/blob/main/CHANGELOG.md"
36
36
 
37
37
  [project.scripts]
38
38
  reqdeck = "reqdeck.cli:main"
@@ -49,4 +49,4 @@ packages = ["reqdeck"]
49
49
  "skills" = "reqdeck/skills"
50
50
 
51
51
  [tool.hatch.build.targets.sdist]
52
- include = ["reqdeck", "skills", "tests", "examples", "README.md", "LICENSE", "pyproject.toml"]
52
+ include = ["reqdeck", "skills", "tests", "examples", "README.md", "CHANGELOG.md", "LICENSE", "pyproject.toml"]
@@ -1,3 +1,3 @@
1
1
  """reqdeck — requirements, open questions and traceability for a git repository."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -16,7 +16,7 @@ from pathlib import Path
16
16
  from . import __version__, config, ops, render, store
17
17
  from .config import STATUS
18
18
  from .markers import Ref
19
- from .model import Model, Req, split
19
+ from .model import PROGRESS, Model, Req, split
20
20
 
21
21
  BOLD, DIM, RED, RESET = "\033[1m", "\033[2m", "\033[31m", "\033[0m"
22
22
 
@@ -77,6 +77,7 @@ def cmd_get(m: Model, args) -> int:
77
77
  "code_refs": [asdict(x) for x in refs if not x.is_test],
78
78
  "test_refs": [asdict(x) for x in refs if x.is_test],
79
79
  "covering_test_refs": [asdict(x) for x in m.covering_refs(rid) if x.is_test],
80
+ "progress": asdict(m.progress(rid)),
80
81
  }, indent=2))
81
82
  return 0
82
83
 
@@ -97,6 +98,13 @@ def cmd_get(m: Model, args) -> int:
97
98
  print(f" {_c('Verification:', DIM)} {r.verification} — {v.name if v else '?'}")
98
99
  if r.status:
99
100
  print(f" {_c('Status:', DIM)} {r.status}")
101
+ progress = m.progress(rid)
102
+ if progress.state:
103
+ print(f" {_c('Progress:', DIM)} {progress.state} — {PROGRESS[progress.state]}")
104
+ for label, ids in (("nothing satisfies", progress.unsatisfied), ("no code", progress.uncoded),
105
+ ("no test", progress.untested)):
106
+ if ids:
107
+ print(f" {_c(label + ':', DIM)} {', '.join(ids)}")
100
108
  if r.security:
101
109
  print(f" {_c('Security relevant:', DIM)} {r.security}")
102
110
  for k, v in r.extra.items():
@@ -207,8 +215,8 @@ GAP_LABELS = [
207
215
  ("dangling", "Satisfies a requirement that does not exist", True),
208
216
  ("orphans", "No satisfies link — traces to nothing", False),
209
217
  ("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),
218
+ ("untested", "Needs a test, but none names it — or, where it must be satisfied, all that is beneath it", False),
219
+ ("uncoded", "Needs code, but none names it — or, where it must be satisfied, all that is beneath it", False),
212
220
  ("not_accepted", "Not yet accepted — awaiting review by the user", False),
213
221
  ]
214
222
 
@@ -259,15 +267,16 @@ def cmd_trace(m: Model, args) -> int:
259
267
  problems.append(f"{rid}: is a {r.kind}, which no code references directly — "
260
268
  f"name what satisfies it instead ({below})")
261
269
  continue
262
- code = [x for x in m.refs_for(rid) if not x.is_test]
270
+ code = m.code_refs(r)
263
271
  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")
272
+ satisfied, needs_code, needs_test = m.needs(r)
273
+ missing = m.missing(r)
274
+ if "code" in missing:
275
+ beneath = ", or on what implements every requirement beneath it" if satisfied else ""
276
+ problems.append(f"{rid}: no code reference — put @REQ_REF({rid}) on what implements it{beneath}")
277
+ if "test" in missing:
278
+ beneath = ", or test every requirement beneath it" if satisfied else ""
279
+ problems.append(f"{rid}: needs a test — add @REQ_REF({rid}) to the test function{beneath}")
271
280
  results.append({"id": rid, "verification": r.verification, "status": r.status,
272
281
  "code_refs": [asdict(x) for x in code],
273
282
  "test_refs": [asdict(x) for x in test],
@@ -54,6 +54,18 @@ class Level:
54
54
  traced: bool = True
55
55
  separator: str = "_"
56
56
  digits: int = 3
57
+ #: What a requirement of this level must have before it is done; see
58
+ #: `Model.missing`. Unset keeps the defaults: `satisfied` where another
59
+ #: level names this one in its `satisfies`, `code` where the unit says
60
+ #: `code = true`, `test` where the verification method is automated.
61
+ #: A lower level must satisfy it — or, with `code`, its own code marker.
62
+ satisfied: bool | None = None
63
+ #: A code marker must name it — or, with `satisfied`, every requirement
64
+ #: beneath it must be named by code, or be satisfied by ones that are.
65
+ code: bool | None = None
66
+ #: A test marker must name it — or, with `satisfied`, every requirement
67
+ #: beneath it must be named by a test, or be satisfied by ones that are.
68
+ test: bool | None = None
57
69
 
58
70
 
59
71
  @dataclass
@@ -150,6 +162,17 @@ def load(root: Path) -> Config:
150
162
  for k in lv.satisfies:
151
163
  if k not in kinds:
152
164
  raise ConfigError(f"{path}: level {lv.kind} satisfies unknown level {k}")
165
+ for key in ("satisfied", "code", "test"):
166
+ if getattr(lv, key) not in (None, True, False):
167
+ raise ConfigError(f"{path}: level {lv.kind}: {key} is true or false")
168
+ below = any(lv.kind in other.satisfies for other in levels)
169
+ for key in ("code", "test"):
170
+ if getattr(lv, key) and not lv.traced and not (lv.satisfied if lv.satisfied is not None else below):
171
+ raise ConfigError(f"{path}: level {lv.kind}: {key} = true with traced = false can only be "
172
+ f"met by markers beneath it, so it needs satisfied = true")
173
+ if lv.satisfied and not below:
174
+ raise ConfigError(f"{path}: level {lv.kind}: satisfied = true, but no level "
175
+ f"names {lv.kind} in its satisfies")
153
176
 
154
177
  verification = {}
155
178
  for code, (name, definition, automated) in DEFAULT_VERIFICATION.items():
@@ -112,7 +112,12 @@ def _config(shape: str, req_dir: str, questions: str, sys_: str, sub: str, icd:
112
112
  split_tests: bool, headers: bool) -> str:
113
113
  out = ["# reqdeck configuration — https://github.com/danielriege/reqdeck", "",
114
114
  f'requirements = "{req_dir}"', f'questions = "{questions}"', "",
115
- "# Most abstract first. `satisfies` names the levels a requirement may point at.", "",
115
+ "# Most abstract first. `satisfies` names the levels a requirement may point at.",
116
+ "# `satisfied`, `code` and `test` (true or false) set what a level needs before it is",
117
+ "# done; unset, they follow from `satisfies`, the unit's `code` and the verification.",
118
+ "# `code` and `test` are met by a marker on the requirement itself, or, with `satisfied`,",
119
+ "# by markers on every requirement beneath it; with `code`, its own marker also counts",
120
+ "# for `satisfied`. See the README.", "",
116
121
  '[[levels]]\nkind = "STORY"\ntitle = "User story"\nreviewed = false\ntraced = false\n'
117
122
  'separator = "-"\ndigits = 2\n']
118
123
  if shape == "3":
@@ -8,6 +8,7 @@
8
8
  m.up("ICD_BUS_014") # what it satisfies, transitively
9
9
  m.down("TSR_009") # what satisfies it, transitively
10
10
  m.covering_refs("TSR_009") # markers on it or on anything beneath it
11
+ m.progress("SYS_004") # how far it and everything beneath it is built
11
12
 
12
13
  Only `satisfies` is ever written, pointing one level up. The reverse direction
13
14
  is derived here, so the two can never disagree.
@@ -24,6 +25,17 @@ from .config import STATUS, Config
24
25
  from .markers import Ref
25
26
  from .store import Unit
26
27
 
28
+ #: How far a requirement is built, least first. Derived from the links and the
29
+ #: markers, never stored: the review status says whether it is right, this
30
+ #: says whether it is there.
31
+ PROGRESS = {
32
+ "Unsatisfied": "a lower level exists to satisfy it, and nothing does",
33
+ "Not implemented": "code is expected, here or beneath it, and none names it",
34
+ "Partial": "some of what is beneath it is built, not all",
35
+ "Untested": "the code is there; a test its verification asks for is not",
36
+ "Done": "coded, and tested where its verification asks, and so is everything beneath it",
37
+ }
38
+
27
39
  SPLIT = re.compile(r"[\s,;]+")
28
40
  NONE = {"", "—", "-"}
29
41
 
@@ -68,6 +80,17 @@ class Req:
68
80
  return asdict(self)
69
81
 
70
82
 
83
+ @dataclass
84
+ class Progress:
85
+ #: One of PROGRESS, or empty where nothing traceable is asked of it or
86
+ #: of anything beneath it.
87
+ state: str
88
+ #: Below, or the requirement itself: what keeps it from Done.
89
+ unsatisfied: list[str] = field(default_factory=list)
90
+ uncoded: list[str] = field(default_factory=list)
91
+ untested: list[str] = field(default_factory=list)
92
+
93
+
71
94
  class Model:
72
95
  def __init__(self, cfg: Config):
73
96
  self.cfg = cfg
@@ -79,6 +102,7 @@ class Model:
79
102
  #: Problems found while loading, reported by `check`.
80
103
  self.load_problems: list[str] = []
81
104
  self.satisfied_by: dict[str, list[str]] = {}
105
+ self._progress: dict[str, Progress] = {}
82
106
 
83
107
  # ---------------------------------------------------------------- loading
84
108
 
@@ -139,6 +163,7 @@ class Model:
139
163
  r.detail = text
140
164
 
141
165
  def relink(self) -> None:
166
+ self._progress = {}
142
167
  self.satisfied_by = {k: [] for k in self.reqs}
143
168
  for r in self.reqs.values():
144
169
  for target in r.satisfies:
@@ -221,6 +246,95 @@ class Model:
221
246
  lv = self.cfg.level(r.kind)
222
247
  return lv.traced if lv else True
223
248
 
249
+ def needs(self, r: Req) -> tuple[bool, bool, bool]:
250
+ """Whether `r` must be satisfied from below, named by code, named by a test.
251
+ Its level says so, or else the defaults described on `config.Level`."""
252
+ lv = self.cfg.level(r.kind)
253
+ satisfied = lv.satisfied if lv and lv.satisfied is not None else r.kind in self.cfg.parent_kinds
254
+ if lv and lv.code is not None:
255
+ code = lv.code
256
+ else:
257
+ code = self.reviewed(r) and self.code_bearing(r.id)
258
+ test = lv.test if lv and lv.test is not None else self.reviewed(r) and self.automated(r)
259
+ return satisfied, code, test
260
+
261
+ def code_refs(self, r: Req) -> list[Ref]:
262
+ """The code markers that bear on `r`: its own, and where its level must be
263
+ satisfied, those beneath it. Whether they are enough is `coded`."""
264
+ refs = self.covering_refs(r.id) if self.needs(r)[0] else self.refs_for(r.id)
265
+ return [x for x in refs if not x.is_test]
266
+
267
+ def _traced_by(self, r: Req, test: bool) -> bool:
268
+ """A code marker (or with `test`, a test marker) names `r` — or, where its
269
+ level must be satisfied, every branch beneath it ends in one: each
270
+ requirement satisfying it is named, or is in turn satisfied by
271
+ requirements that all are. One branch without leaves it short, and its
272
+ progress Partial."""
273
+ def named(rid: str) -> bool:
274
+ return any(x.is_test == test for x in self.refs_for(rid))
275
+
276
+ def reaches(rid: str, seen: frozenset) -> bool:
277
+ if named(rid):
278
+ return True
279
+ below = [c for c in self.satisfied_by.get(rid, []) if c not in seen]
280
+ return bool(below) and all(reaches(c, seen | {c}) for c in below)
281
+ return named(r.id) or (self.needs(r)[0] and reaches(r.id, frozenset({r.id})))
282
+
283
+ def coded(self, r: Req) -> bool:
284
+ """Code names `r`, or every branch beneath it — see `_traced_by`."""
285
+ return self._traced_by(r, test=False)
286
+
287
+ def tested(self, r: Req) -> bool:
288
+ """A test names `r`, or every branch beneath it — see `_traced_by`."""
289
+ return self._traced_by(r, test=True)
290
+
291
+ def missing(self, r: Req) -> set[str]:
292
+ """What `r` lacks of what it needs: "satisfied", "code", "test".
293
+
294
+ - satisfied: something satisfies it — or, needing code too, code names
295
+ it itself: it is implemented as it stands, not broken down further.
296
+ - code: code names it — or, needing to be satisfied, every branch
297
+ beneath it ends in code (see `coded`).
298
+ - test: the same, with a test (see `tested`)."""
299
+ satisfied, code, test = self.needs(r)
300
+ has_below = bool(self.satisfied_by.get(r.id))
301
+ own_code = any(not x.is_test for x in self.refs_for(r.id))
302
+ out = set()
303
+ if satisfied and not has_below and not (code and own_code):
304
+ out.add("satisfied")
305
+ if code and not self.coded(r):
306
+ out.add("code")
307
+ if test and not self.tested(r):
308
+ out.add("test")
309
+ return out
310
+
311
+ def progress(self, rid: str) -> Progress:
312
+ """How far `rid` is built, judged over it and everything that satisfies it,
313
+ by what each of them `needs`. A user story is Done once its whole chain is."""
314
+ if rid in self._progress:
315
+ return self._progress[rid]
316
+ below = [self.reqs[x] for x in [rid] + self.down(rid) if x in self.reqs]
317
+ missing = {r.id: self.missing(r) for r in below}
318
+ unsatisfied = [r.id for r in below if "satisfied" in missing[r.id]]
319
+ needs_code = [r.id for r in below if self.needs(r)[1]]
320
+ needs_test = [r.id for r in below if self.needs(r)[2]]
321
+ uncoded = [r.id for r in below if "code" in missing[r.id]]
322
+ untested = [r.id for r in below if "test" in missing[r.id]]
323
+ if rid in unsatisfied and rid not in uncoded:
324
+ state = "Unsatisfied"
325
+ elif needs_code and len(uncoded) == len(needs_code):
326
+ state = "Not implemented"
327
+ elif uncoded or unsatisfied:
328
+ state = "Partial"
329
+ elif untested:
330
+ state = "Untested"
331
+ elif needs_code or needs_test:
332
+ state = "Done"
333
+ else:
334
+ state = ""
335
+ p = self._progress[rid] = Progress(state, unsatisfied, uncoded, untested)
336
+ return p
337
+
224
338
  def next_id(self, prefix: str) -> str:
225
339
  unit = self.unit(prefix)
226
340
  lv = self.cfg.level(unit.level) if unit else None
@@ -234,7 +348,6 @@ class Model:
234
348
  def gaps(self) -> dict:
235
349
  g = {"dangling": [], "orphans": [], "unsatisfied": [], "untested": [],
236
350
  "uncoded": [], "not_accepted": []}
237
- parents = self.cfg.parent_kinds
238
351
  for r in self.reqs.values():
239
352
  lv = self.cfg.level(r.kind)
240
353
  for t in r.satisfies:
@@ -242,14 +355,12 @@ class Model:
242
355
  g["dangling"].append({"id": r.id, "missing": t})
243
356
  if lv and lv.satisfies and not r.satisfies:
244
357
  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)
358
+ missing = self.missing(r)
359
+ for need, key in (("satisfied", "unsatisfied"), ("test", "untested"), ("code", "uncoded")):
360
+ if need in missing:
361
+ g[key].append(r.id)
247
362
  if not self.reviewed(r):
248
363
  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
364
  if r.status != "Accepted":
254
365
  g["not_accepted"].append(r.id)
255
366
  for k in g:
@@ -18,11 +18,11 @@ except `selection`, the one filled row.
18
18
  | `heading` | Section headings and numbers in a document |
19
19
  | `id` | A requirement or question ID |
20
20
  | `link` | An ID in running text, which can be followed |
21
- | `link_active` | The link under the keyboard cursor, and the preview's border |
21
+ | `link_active` | The link under the keyboard cursor, the preview's border, and `Reloading` in the hint line |
22
22
  | `code` | Inline code — byte values, command codes |
23
23
  | `table_header` | A table's header row |
24
24
  | `statement_bar` | The bar beside a requirement's statement |
25
- | `accepted` `proposed` `reopened` | Review status |
25
+ | `accepted` `proposed` `reopened` | Review status, and progress: done, part built, not built |
26
26
  | `security` | The security-relevant marker |
27
27
  | `open` `answered` | An open question's state |
28
28
  | `keys` | Key names in the hint line |
@@ -3,7 +3,8 @@ questions beside them. Reads through the same model as the CLI and writes
3
3
  through the same operations.
4
4
 
5
5
  Needs `textual`, which the CLI does not. The chosen theme and sidebar width
6
- are remembered per user in `$XDG_CONFIG_HOME/reqdeck/tui.json`.
6
+ are remembered per user in `$XDG_CONFIG_HOME/reqdeck/tui.json`, and the seconds
7
+ between reloads read from it: `"reload": 30` by default, `0` for never.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -37,6 +38,9 @@ TARGET_RE = re.compile(r"(?:goto|link)\('(?:reqdeck:)?([^']+)'\)")
37
38
 
38
39
  #: Status letter in the tree, and the theme role that colours it.
39
40
  STATUS_MARK = {"Accepted": ("A", "accepted"), "Proposed": ("P", "proposed"), "Reopened": ("R", "reopened")}
41
+ #: Progress mark in the tree, and the theme role that colours it.
42
+ PROGRESS_MARK = {"Done": ("✓", "accepted"), "Untested": ("◑", "proposed"), "Partial": ("◔", "proposed"),
43
+ "Not implemented": ("○", "reopened"), "Unsatisfied": ("✗", "reopened")}
40
44
 
41
45
  HELP = """\
42
46
  # Keys
@@ -61,11 +65,25 @@ The tree always has the cursor; the reading pane shows what it highlights.
61
65
  | `a` | Accept the highlighted requirement |
62
66
  | `<` `>` | Narrow or widen the sidebar — or drag the divider |
63
67
  | `t` | Theme — previewed as you move, `enter` keeps it |
64
- | `r` | Reload from disk |
68
+ | `r` | Reload from disk — it also does so every 30 seconds |
65
69
  | `q` | Quit |
66
70
 
67
71
  Every requirement and question ID in the reading pane is a link, and so is every code
68
72
  and test reference. Hover one for a preview, click it to go there.
73
+
74
+ # Marks
75
+
76
+ Beside each requirement in the tree: its review status, then how far it is built — judged
77
+ over it and everything that satisfies it, from the links and the `@REQ_REF` markers.
78
+
79
+ | | |
80
+ |---|---|
81
+ | `A` `P` `R` | Accepted, Proposed, Reopened |
82
+ | `✓` | Done — coded, and tested where its verification asks, and so is everything beneath it |
83
+ | `◑` | Untested — the code is there, a test its verification asks for is not |
84
+ | `◔` | Partial — some of what is beneath it is built, not all |
85
+ | `○` | Not implemented — code is expected, and none names it |
86
+ | `✗` | Unsatisfied — a lower level exists to satisfy it, and nothing does |
69
87
  """
70
88
 
71
89
 
@@ -282,6 +300,11 @@ def _status_chip(status: str) -> Content:
282
300
  return Content.assemble((f"● {status}", f"bold $rk-{role}"))
283
301
 
284
302
 
303
+ def _progress_chip(state: str) -> Content:
304
+ mark, role = PROGRESS_MARK.get(state, ("?", "muted"))
305
+ return Content.assemble((f"{mark} {state}", f"bold $rk-{role}"))
306
+
307
+
285
308
  def _link(rid: str, sel: str = "") -> tuple[str, str]:
286
309
  """A clickable ID; the one under the keyboard link cursor is drawn reversed."""
287
310
  style = "bold $rk-link-active on $rk-selection" if rid == sel else "$rk-link underline"
@@ -371,6 +394,8 @@ def requirement_card(m: Model, r: Req, full: bool, sel: str = "") -> Vertical:
371
394
  head += [" ", (r.title, "bold $rk-foreground")]
372
395
  if m.reviewed(r) and r.status:
373
396
  head += [" ", _status_chip(r.status)]
397
+ if m.progress(r.id).state:
398
+ head += [" ", _progress_chip(m.progress(r.id).state)]
374
399
  if r.security:
375
400
  head += [" ", ("◆ security", "bold $rk-security")]
376
401
  parts.append(Static(Content.assemble(*head), classes="card-head"))
@@ -527,6 +552,12 @@ class ReqdeckApp(App):
527
552
  Binding("greater_than_sign", "resize(4)", show=False),
528
553
  Binding("q", "quit", "Quit"),
529
554
  ]
555
+ #: Seconds between reloads from disk, unless `tui.json` names its own; 0 is never.
556
+ RELOAD_EVERY = 30
557
+ #: Frames of the spinner drawn while a reload runs, as lazygit's.
558
+ SPINNER = "|/-\\"
559
+ #: The spinner stays up at least this long, so a fast reload is still seen.
560
+ SPIN_AT_LEAST = 0.5
530
561
 
531
562
  def __init__(self):
532
563
  # ansi_color leaves the terminal's own background unpainted: with it
@@ -546,6 +577,10 @@ class ReqdeckApp(App):
546
577
  self.hovering = ""
547
578
  self.back: list[tuple] = []
548
579
  self.forward: list[tuple] = []
580
+ #: The spinner's frame while a reload runs; None when none does.
581
+ self._spin: int | None = None
582
+ #: The last reload's error, so a file left broken is reported once.
583
+ self._reload_error = ""
549
584
  self.prefs = load_prefs()
550
585
  self.palettes = themes.load_all()
551
586
  for p in self.palettes.values():
@@ -584,6 +619,12 @@ class ReqdeckApp(App):
584
619
  self.show(("help", None))
585
620
  self.refresh_keys()
586
621
  self.run_worker(self._warm_origins, thread=True, group="warm")
622
+ try:
623
+ every = float(self.prefs.get("reload", self.RELOAD_EVERY))
624
+ except (TypeError, ValueError):
625
+ every = self.RELOAD_EVERY
626
+ if every > 0:
627
+ self.set_interval(every, self.reload)
587
628
 
588
629
  def _warm_origins(self) -> None:
589
630
  """What every reference is attached to, read in a thread at start, so a
@@ -666,6 +707,9 @@ class ReqdeckApp(App):
666
707
  if i:
667
708
  parts.append((" | ", "$rk-muted"))
668
709
  parts += [(f"{action}: ", "$rk-muted"), (key, "bold $rk-keys")]
710
+ if self._spin is not None:
711
+ frame = self.SPINNER[self._spin % len(self.SPINNER)]
712
+ parts = [(f"Reloading {frame}", "bold $rk-link-active"), " "] + parts
669
713
  line = Content.assemble(*parts)
670
714
  if str(line) != str(bar.content): # a repaint only when it changed
671
715
  bar.update(line)
@@ -686,6 +730,12 @@ class ReqdeckApp(App):
686
730
  if self.m.reviewed(r):
687
731
  mark, role = STATUS_MARK.get(r.status, ("?", "muted"))
688
732
  t.append(f"{mark} ", style=f"bold {self.colour(role)}")
733
+ state = self.m.progress(r.id).state
734
+ if state:
735
+ mark, role = PROGRESS_MARK[state]
736
+ t.append(f"{mark} ", style=f"bold {self.colour(role)}")
737
+ else:
738
+ t.append(" ")
689
739
  t.append(r.id, style=self.colour("id"))
690
740
  t.append(f" {r.label}")
691
741
  return t
@@ -782,6 +832,8 @@ class ReqdeckApp(App):
782
832
 
783
833
  @on(Tree.NodeHighlighted)
784
834
  def _highlighted(self, event: Tree.NodeHighlighted) -> None:
835
+ if event.node.data == self.shown:
836
+ return # the tree rebuilt under a view it already shows
785
837
  self.show(event.node.data)
786
838
 
787
839
  def _widgets_for(self, data, sel: str = "") -> list[Widget]:
@@ -818,12 +870,16 @@ class ReqdeckApp(App):
818
870
  return out
819
871
  if kind == "level":
820
872
  lv = m.cfg.level(key)
873
+ def done(u) -> str:
874
+ states = [m.progress(r.id).state for r in m.reqs.values() if r.unit == u.stem]
875
+ asked = [st for st in states if st]
876
+ return f"{asked.count('Done')}/{len(asked)}" if asked else "—"
821
877
  rows = "\n".join(
822
878
  f"| {u.title} | `{', '.join(u.prefixes)}` | {sum(1 for r in m.reqs.values() if r.unit == u.stem)} "
823
- f"| `{m.cfg.rel(u.csv_path)}` |" for u in m.units if u.level == key)
879
+ f"| {done(u)} | `{m.cfg.rel(u.csv_path)}` |" for u in m.units if u.level == key)
824
880
  return [heading(f"{lv.kind} — {lv.title}", 1),
825
881
  Markdown(f"Satisfies: **{', '.join(lv.satisfies) or 'top level'}**\n\n"
826
- f"| Unit | Prefix | Requirements | File |\n|---|---|---|---|\n{rows}\n",
882
+ f"| Unit | Prefix | Requirements | Done | File |\n|---|---|---|---|---|\n{rows}\n",
827
883
  classes="prose")]
828
884
  if kind == "topic":
829
885
  qs = [q for q in m.questions if q.topic == key]
@@ -945,6 +1001,8 @@ class ReqdeckApp(App):
945
1001
  head = [(r.id, "bold $rk-id")] + ([" ", (r.title, "bold $rk-foreground")] if r.title else [])
946
1002
  if m.reviewed(r) and r.status:
947
1003
  head += [" ", _status_chip(r.status)]
1004
+ if m.progress(r.id).state:
1005
+ head += [" ", _progress_chip(m.progress(r.id).state)]
948
1006
  else:
949
1007
  head = [(self._title_for(data), "bold $rk-heading")]
950
1008
  top = Static(Content.assemble(*head), classes="card-head")
@@ -1005,12 +1063,66 @@ class ReqdeckApp(App):
1005
1063
  def action_help(self) -> None:
1006
1064
  self.show(("help", None))
1007
1065
 
1008
- def action_reload(self, quiet: bool = False) -> None:
1009
- self.m = load_model()
1010
- self.relabel() # rebuilds the tree, keeping the cursor where it was
1011
- if not quiet:
1066
+ def action_reload(self) -> None:
1067
+ self.reload(say=True)
1068
+
1069
+ def reload(self, say: bool = False) -> None:
1070
+ """Read the model from disk again, in a thread, with the spinner up meanwhile.
1071
+ A dialog defers it: the next one comes soon enough."""
1072
+ if self._spin is not None or len(self.screen_stack) > 1:
1073
+ return
1074
+ self._spin = 0
1075
+ self.refresh_keys()
1076
+ self.run_worker(self._reload(say), exclusive=True, group="reload")
1077
+
1078
+ async def _reload(self, say: bool) -> None:
1079
+ start = time.monotonic()
1080
+ ticker = self.set_interval(0.1, self._spin_on)
1081
+ try:
1082
+ m, error = await asyncio.to_thread(load_model), ""
1083
+ except Exception as e: # a broken config or file, maybe half written
1084
+ m, error = None, str(e)
1085
+ await asyncio.sleep(max(0.0, self.SPIN_AT_LEAST - (time.monotonic() - start)))
1086
+ ticker.stop()
1087
+ self._spin = None
1088
+ self.refresh_keys()
1089
+ if error:
1090
+ if say or error != self._reload_error:
1091
+ self.notify(error, title="Reload failed", severity="error")
1092
+ self._reload_error = error
1093
+ return
1094
+ self._reload_error = ""
1095
+ if len(self.screen_stack) == 1 and not self._same(m, self.m):
1096
+ self.m = m
1097
+ self.drop_link()
1098
+ self.relabel() # rebuilds the tree, keeping the cursor where it was
1099
+ here = self.shown
1100
+ if here and (here[0] == "help" or self._node_for(here) is not None):
1101
+ # Redrawn from the new model, scrolled where it was.
1102
+ self.links = self._links_for(here) if here[0] in ("unit", "section") else None
1103
+ self.show(here, keep=True)
1104
+ else:
1105
+ self.call_after_refresh(self._show_cursor) # it is gone
1106
+ if say:
1012
1107
  self.notify("Reloaded")
1013
1108
 
1109
+ def _show_cursor(self) -> None:
1110
+ node = self.query_one("#nav", Tree).cursor_node
1111
+ if node is not None:
1112
+ self.show(node.data)
1113
+
1114
+ def _spin_on(self) -> None:
1115
+ if self._spin is not None:
1116
+ self._spin += 1
1117
+ self.refresh_keys()
1118
+
1119
+ @staticmethod
1120
+ def _same(a: Model, b: Model) -> bool:
1121
+ """Nothing on disk changed between two loads, so nothing need be redrawn."""
1122
+ def facts(m: Model) -> tuple:
1123
+ return m.cfg, m.units, m.reqs, m.refs, m.topics, m.questions, m.load_problems
1124
+ return facts(a) == facts(b)
1125
+
1014
1126
  def action_next_match(self, direction: int) -> None:
1015
1127
  tree = self.query_one("#nav", Tree)
1016
1128
  line = tree.cursor_line
@@ -1085,6 +1197,8 @@ class ReqdeckApp(App):
1085
1197
  head += [" ", (r.title, "bold")]
1086
1198
  if m.reviewed(r) and r.status:
1087
1199
  head += [" ", _status_chip(r.status)]
1200
+ if m.progress(r.id).state:
1201
+ head += [" ", _progress_chip(m.progress(r.id).state)]
1088
1202
  lines = [Content.assemble(*head), Content(""), Content(r.statement)]
1089
1203
  if r.satisfies:
1090
1204
  lines.append(Content.assemble(("Satisfies ", "$rk-muted"), ", ".join(r.satisfies)))
@@ -1301,7 +1415,7 @@ class ReqdeckApp(App):
1301
1415
  if terminal:
1302
1416
  with self.suspend():
1303
1417
  subprocess.run(argv, cwd=self.m.cfg.root)
1304
- self.action_reload(quiet=True)
1418
+ self.reload()
1305
1419
  else:
1306
1420
  subprocess.Popen(argv, cwd=self.m.cfg.root, stdin=subprocess.DEVNULL,
1307
1421
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
@@ -148,10 +148,31 @@ non-canonical CSV, a detail block for a requirement that is not in that unit, an
148
148
  | `dangling` | Satisfies something that does not exist — always a bug |
149
149
  | `orphans` | No satisfies link. Legitimate only with the reason in the rationale |
150
150
  | `unsatisfied` | A story, or a parent level, nothing satisfies yet |
151
- | `untested` | Verification is automated, but no test names it or anything beneath it |
152
- | `uncoded` | Unit has `code = true`, but no code names it |
151
+ | `untested` | Its level, or else its verification method, needs a test, and no test names it — or, where it must be satisfied, not every requirement beneath it |
152
+ | `uncoded` | Its level, or else its unit, needs code, and no code names it — or, where it must be satisfied, not every requirement beneath it |
153
153
  | `not_accepted` | Awaiting the user's review — not a gap to close by editing |
154
154
 
155
+ What each level needs is set by `satisfied`, `code` and `test` on its `[[levels]]` entry in
156
+ `.reqdeck.toml`:
157
+
158
+ - `satisfied` — something beneath satisfies it, or, where the level also needs code, code
159
+ names it itself.
160
+ - `code` — code names it, or, where the level also needs satisfying, code names every
161
+ requirement beneath it (each one, or all of the ones satisfying it in turn). One branch
162
+ without code leaves it uncoded.
163
+ - `test` — the same as `code`, with tests: a test names it, or, where the level also needs
164
+ satisfying, a test names every requirement beneath it.
165
+
166
+ So a requirement that needs both `satisfied` and `code` is done broken down (lower
167
+ requirements satisfy it, and code names every one of them) or implemented as it stands (code
168
+ names it directly). Put a marker on the requirement the code really implements; never add one to a
169
+ higher level just to close its gap.
170
+
171
+ `get --json` carries the same per requirement as `progress`, judged over it and everything
172
+ beneath it: `state` is `Unsatisfied`, `Not implemented`, `Partial`, `Untested` or `Done`
173
+ (empty where nothing traceable is asked), and `unsatisfied`, `uncoded`, `untested` list the
174
+ IDs keeping it from `Done`. A story is `Done` only once its whole chain is.
175
+
155
176
  Gaps are normal while topics are still being discussed. **Do not close them by inventing
156
177
  links or markers.** Report them.
157
178
 
@@ -250,6 +250,94 @@ class TestTrace(Base):
250
250
  self.assertEqual([r.id for r in m.covering_refs("SR_001")], ["SSR_APP_001"])
251
251
  self.assertNotIn("SR_001", m.gaps()["untested"])
252
252
 
253
+ def test_every_branch_beneath_needs_its_test(self):
254
+ self.p.write("tests/screen_test.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
255
+ ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
256
+ "verification": "T"})
257
+ m = self.p.model()
258
+ self.assertIn("SR_001", m.gaps()["untested"]) # SSR_APP_002 has no test
259
+ self.p.write("tests/sound_test.swift", "// @REQ_REF(SSR_APP_002) sounds\n")
260
+ self.assertNotIn("SR_001", self.p.model().gaps()["untested"])
261
+
262
+ def test_progress_rolls_up_the_chain(self):
263
+ def states():
264
+ m = self.p.model()
265
+ return [m.progress(rid).state for rid in ("A-01", "SR_001", "SSR_APP_001")]
266
+ self.assertEqual(states(), ["Not implemented"] * 3)
267
+ self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
268
+ self.assertEqual(states(), ["Untested"] * 3)
269
+ self.assertEqual(self.p.model().progress("A-01").untested, ["SR_001", "SSR_APP_001"])
270
+ self.p.write("tests/screen_test.swift", "// @REQ_REF(SSR_APP_001) shows the screen\n")
271
+ self.assertEqual(states(), ["Done"] * 3)
272
+ # A second story, which nothing satisfies, and a second child with no code.
273
+ ops.add(self.p.model(), "A", {"statement": "As a collector, I want sound."})
274
+ ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
275
+ "verification": "I"})
276
+ m = self.p.model()
277
+ self.assertEqual(m.progress("A-02").state, "Unsatisfied")
278
+ self.assertEqual(m.progress("SR_001").state, "Partial")
279
+ self.assertEqual(m.progress("SR_001").uncoded, ["SSR_APP_002"])
280
+
281
+
282
+ class TestLevelNeeds(Base):
283
+ def configure(self, sr: str) -> None:
284
+ """The SR level with `sr` in place of `traced = false`."""
285
+ path = self.p.root / ".reqdeck.toml"
286
+ path.write_text(CONFIG.replace("topic = true\ntraced = false\n", f"topic = true\n{sr}\n", 1))
287
+
288
+ def test_its_own_code_or_code_on_every_branch_beneath(self):
289
+ self.configure("code = true")
290
+ m = self.p.model()
291
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"satisfied", "code", "test"})
292
+ self.assertEqual(m.progress("SR_001").state, "Not implemented")
293
+ self.p.write("src/light.swift", "// @REQ_REF(SR_001) lights\n")
294
+ m = self.p.model()
295
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"}) # coded: no breakdown needed
296
+ self.p.write("src/light.swift", "")
297
+ ops.add(self.p.model(), "SSR_APP", {"statement": "S.", "satisfies": "SR_001", "source": "x",
298
+ "verification": "I"})
299
+ m = self.p.model()
300
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"code", "test"}) # broken down, but nothing coded
301
+ self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows it\n")
302
+ m = self.p.model()
303
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"}) # code beneath it counts
304
+ self.assertEqual([x.id for x in m.code_refs(m.reqs["SR_001"])], ["SSR_APP_001"])
305
+ ops.add(self.p.model(), "SSR_APP", {"statement": "T.", "satisfies": "SR_001", "source": "x",
306
+ "verification": "I"})
307
+ m = self.p.model()
308
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"code", "test"}) # every branch must end in code
309
+ self.assertEqual(m.progress("SR_001").state, "Partial")
310
+ self.p.write("src/sound.swift", "// @REQ_REF(SSR_APP_002) sounds\n")
311
+ m = self.p.model()
312
+ self.assertEqual(m.missing(m.reqs["SR_001"]), {"test"})
313
+
314
+ def test_code_beneath_counts_only_where_it_must_be_satisfied(self):
315
+ self.configure("satisfied = false\ncode = true")
316
+ ops.add(self.p.model(), "SSR_APP", {"statement": "S.", "satisfies": "SR_001", "source": "x",
317
+ "verification": "I"})
318
+ self.p.write("src/screen.swift", "// @REQ_REF(SSR_APP_001) shows it\n")
319
+ m = self.p.model()
320
+ self.assertIn("code", m.missing(m.reqs["SR_001"])) # only its own marker counts
321
+ self.configure("traced = false\ncode = true") # met only from beneath: fine
322
+ self.assertEqual(self.p.model().missing(self.p.model().reqs["SR_001"]), {"test"})
323
+ self.configure("traced = false\ncode = true\nsatisfied = false")
324
+ with self.assertRaisesRegex(config.ConfigError, "needs satisfied = true"):
325
+ self.p.model()
326
+
327
+ def test_levels_turn_needs_off(self):
328
+ self.configure("traced = false\nsatisfied = false\ntest = false")
329
+ m = self.p.model()
330
+ self.assertEqual(m.missing(m.reqs["SR_001"]), set())
331
+ self.assertNotIn("SR_001", m.gaps()["unsatisfied"])
332
+ self.assertNotIn("SR_001", m.gaps()["untested"])
333
+
334
+ def test_contradictions_are_refused(self):
335
+ self.configure("")
336
+ path = self.p.root / ".reqdeck.toml"
337
+ path.write_text(path.read_text().replace('kind = "SSR"\n', 'kind = "SSR"\nsatisfied = true\n'))
338
+ with self.assertRaisesRegex(config.ConfigError, "no level names SSR"):
339
+ self.p.model()
340
+
253
341
 
254
342
  class TestDocument(Base):
255
343
  def test_sections_are_numbered_by_declaration_order(self):
@@ -361,6 +361,45 @@ class TuiTest(unittest.TestCase):
361
361
  self.run_app(steps)
362
362
  self.assertEqual(self.p.model().reqs["SR_002"].status, "Accepted")
363
363
 
364
+ def test_reload_spins_while_it_runs_and_redraws_only_a_change(self):
365
+ async def steps(app, pilot):
366
+ bar = app.query_one("#keys")
367
+ self.assertNotIn("Reloading", str(bar.content))
368
+ app.action_goto("SR_002")
369
+ await self.settle(app, pilot)
370
+ page = app._page()
371
+ app.reload()
372
+ await pilot.pause()
373
+ self.assertTrue(str(bar.content).startswith("Reloading |"))
374
+ await self.settle(app, pilot)
375
+ self.assertNotIn("Reloading", str(bar.content))
376
+ self.assertIs(app._page(), page) # nothing changed: nothing redrawn
377
+
378
+ ops.edit(self.p.model(), "SR_002", {"title": "Shine"})
379
+ app.reload()
380
+ await self.settle(app, pilot)
381
+ self.assertEqual(self.cursor(app), ("req", "SR_002"))
382
+ self.assertIn("Shine", str(app._page().query_one(".card-head").content))
383
+ self.run_app(steps)
384
+
385
+ def test_reload_runs_on_its_own_unless_turned_off(self):
386
+ from reqdeck.viewer import ReqdeckApp, save_prefs
387
+
388
+ async def go(prefs):
389
+ save_prefs(prefs)
390
+ app = ReqdeckApp()
391
+ app.SPIN_AT_LEAST = 0
392
+ async with app.run_test(size=(160, 48)) as pilot:
393
+ await pilot.pause()
394
+ rid, _, _ = ops.add(self.p.model(), "SR", {
395
+ "title": "Hum", "statement": "The system shall hum.", "satisfies": "A-01",
396
+ "topic": "01", "source": "OQ-0102.", "verification": "T"})
397
+ await asyncio.sleep(0.5) # a tick after the change, at least
398
+ await self.settle(app, pilot)
399
+ return rid in app.nodes
400
+ self.assertTrue(asyncio.run(go({"reload": 0.1})))
401
+ self.assertFalse(asyncio.run(go({"reload": 0})))
402
+
364
403
 
365
404
  if __name__ == "__main__":
366
405
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes