wizz-method 1.14.0 → 1.16.0

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 (148) hide show
  1. package/package.json +3 -2
  2. package/skills-registry.yaml +4 -0
  3. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
  4. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
  5. package/src/bmm-skills/4-implementation/wizz-code-review/SKILL.md +3 -5
  6. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +71 -0
  7. package/src/bmm-skills/4-implementation/wizz-code-review/references/claims-check.md +14 -0
  8. package/src/bmm-skills/4-implementation/wizz-code-review/references/deletion-check.md +14 -0
  9. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/edge-case-hunter.md +110 -0
  10. package/src/bmm-skills/4-implementation/wizz-code-review/review-prompts/verification-gap.md +113 -0
  11. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-01-gather-context.md +32 -23
  12. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-02-review.md +10 -13
  13. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +16 -22
  14. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +4 -2
  15. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +76 -0
  16. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/claims-check.md +14 -0
  17. package/src/bmm-skills/4-implementation/wizz-quick-dev/references/deletion-check.md +14 -0
  18. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/edge-case-hunter.md +110 -0
  19. package/src/bmm-skills/4-implementation/wizz-quick-dev/review-prompts/verification-gap.md +113 -0
  20. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +38 -16
  21. package/src/bmm-skills/4-implementation/wizz-retrospective/SKILL.md +56 -1488
  22. package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +3 -5
  23. package/src/bmm-skills/4-implementation/wizz-retrospective/references/acceptance-verdict.md +55 -0
  24. package/src/bmm-skills/4-implementation/wizz-retrospective/references/aggregate-views.md +17 -0
  25. package/src/bmm-skills/4-implementation/wizz-retrospective/references/evidence-gathering.md +30 -0
  26. package/src/bmm-skills/4-implementation/wizz-retrospective/references/retro-document.md +84 -0
  27. package/src/bmm-skills/4-implementation/wizz-retrospective/references/team-discussion.md +22 -0
  28. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
  29. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/git_evidence.py +304 -0
  30. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/sprint_status.py +746 -0
  31. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  32. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
  33. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/fixtures/sprint-status-template.yaml +71 -0
  34. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_git_evidence.py +750 -0
  35. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/test_sprint_status.py +1579 -0
  36. package/src/bmm-skills/4-implementation/wizz-sprint-planning/SKILL.md +39 -296
  37. package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +1 -3
  38. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/fix-sprint-status.md +30 -0
  39. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/generate-tracking.md +25 -0
  40. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/readiness-gate.md +20 -0
  41. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/status-view.md +14 -0
  42. package/src/bmm-skills/4-implementation/wizz-sprint-planning/references/validate.md +10 -0
  43. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
  44. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/sprint_plan.py +697 -0
  45. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
  46. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/test_sprint_plan.py +524 -0
  47. package/src/bmm-skills/4-implementation/wizz-sprint-planning/sprint-status-template.yaml +9 -7
  48. package/src/bmm-skills/module-help.csv +3 -3
  49. package/src/core-skills/_shared/handoff-protocol.md +7 -8
  50. package/src/core-skills/module-help.csv +1 -0
  51. package/src/core-skills/wizz-advanced-elicitation/SKILL.md +36 -114
  52. package/src/core-skills/wizz-advanced-elicitation/{methods.csv → assets/methods.csv} +29 -27
  53. package/src/core-skills/wizz-advanced-elicitation/customize.toml +54 -0
  54. package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
  55. package/src/core-skills/wizz-advanced-elicitation/scripts/pick_methods.py +233 -0
  56. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/test_pick_methods.py +228 -0
  58. package/src/core-skills/wizz-brainstorming/SKILL.md +2 -2
  59. package/src/core-skills/wizz-brainstorming/assets/brain-selector.html +2 -0
  60. package/src/core-skills/wizz-brainstorming/references/converge.md +1 -1
  61. package/src/core-skills/wizz-brainstorming/references/finalize.md +1 -1
  62. package/src/core-skills/wizz-brainstorming/references/headless.md +1 -1
  63. package/src/core-skills/wizz-brainstorming/references/mode-autonomous.md +1 -1
  64. package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
  65. package/src/core-skills/wizz-brainstorming/scripts/brain.py +36 -6
  66. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
  68. package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +24 -2
  69. package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
  70. package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/src/core-skills/wizz-forge-idea/SKILL.md +107 -0
  72. package/src/core-skills/wizz-forge-idea/customize.toml +41 -0
  73. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
  74. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
  75. package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +275 -0
  76. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
  78. package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +138 -0
  79. package/src/core-skills/wizz-party-mode/SKILL.md +37 -54
  80. package/src/core-skills/wizz-party-mode/customize.toml +61 -2
  81. package/src/core-skills/wizz-party-mode/references/create-party.md +8 -3
  82. package/src/core-skills/wizz-party-mode/references/mode-agent-team.md +3 -1
  83. package/src/core-skills/wizz-party-mode/references/mode-subagent.md +16 -4
  84. package/src/core-skills/wizz-party-mode/references/party-memory.md +51 -0
  85. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
  86. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
  87. package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +22 -7
  88. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
  90. package/src/core-skills/wizz-party-mode/scripts/tests/{test-resolve_party.py → test_resolve_party.py} +8 -0
  91. package/src/core-skills/wizz-review-edge-case-hunter/SKILL.md +17 -3
  92. package/src/modules/wizz/README.md +1 -1
  93. package/src/modules/wizz/_shared/model-ladder.md +22 -0
  94. package/src/modules/wizz/_shared/token-economy.md +6 -2
  95. package/src/modules/wizz/agents/wizz-ads/customize.toml +1 -1
  96. package/src/modules/wizz/agents/wizz-copy/customize.toml +1 -1
  97. package/src/modules/wizz/agents/wizz-designer/customize.toml +1 -1
  98. package/src/modules/wizz/agents/wizz-growth/customize.toml +11 -1
  99. package/src/modules/wizz/agents/wizz-maestro/customize.toml +1 -1
  100. package/src/modules/wizz/agents/wizz-memoria/customize.toml +1 -1
  101. package/src/modules/wizz/agents/wizz-qa/customize.toml +1 -1
  102. package/src/modules/wizz/agents/wizz-seo/customize.toml +1 -1
  103. package/src/modules/wizz/agents/wizz-social/customize.toml +1 -1
  104. package/src/modules/wizz/overrides/wizz-agent-analyst.toml +1 -1
  105. package/src/modules/wizz/overrides/wizz-agent-architect.toml +1 -1
  106. package/src/modules/wizz/overrides/wizz-agent-dev.toml +1 -1
  107. package/src/modules/wizz/overrides/wizz-agent-pm.toml +1 -1
  108. package/src/modules/wizz/overrides/wizz-agent-tech-writer.toml +1 -1
  109. package/src/modules/wizz/overrides/wizz-agent-ux-designer.toml +1 -1
  110. package/src/modules/wizz/subagents/codex/wizz-exec-haiku.toml +16 -0
  111. package/src/modules/wizz/subagents/codex/wizz-exec-opus.toml +18 -0
  112. package/src/modules/wizz/subagents/codex/wizz-exec-review.toml +17 -0
  113. package/src/modules/wizz/subagents/codex/wizz-exec-sonnet.toml +17 -0
  114. package/src/modules/wizz/subagents/gemini/wizz-exec-haiku.md +15 -0
  115. package/src/modules/wizz/subagents/gemini/wizz-exec-opus.md +17 -0
  116. package/src/modules/wizz/subagents/gemini/wizz-exec-review.md +15 -0
  117. package/src/modules/wizz/subagents/gemini/wizz-exec-sonnet.md +16 -0
  118. package/src/modules/wizz/subagents/opencode/wizz-exec-haiku.md +14 -0
  119. package/src/modules/wizz/subagents/opencode/wizz-exec-opus.md +16 -0
  120. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +16 -0
  121. package/src/modules/wizz/subagents/opencode/wizz-exec-sonnet.md +15 -0
  122. package/src/modules/wizz/subagents/wizz-exec-opus.md +16 -0
  123. package/src/modules/wizz/subagents/wizz-exec-review.md +15 -0
  124. package/src/scripts/__pycache__/memlog.cpython-313.pyc +0 -0
  125. package/src/scripts/tests/__pycache__/test_memlog.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-313-pytest-9.1.1.pyc +0 -0
  127. package/src/skills-lib/pesquisa-de-publico-do-piva/README.md +83 -0
  128. package/src/skills-lib/pesquisa-de-publico-do-piva/SKILL.md +65 -0
  129. package/src/skills-lib/pesquisa-de-publico-do-piva/assets/template.html +414 -0
  130. package/src/skills-lib/pesquisa-de-publico-do-piva/prompts.md +131 -0
  131. package/src/skills-lib/wizz-offer-forge/README.md +13 -0
  132. package/src/skills-lib/wizz-offer-forge/SKILL.md +74 -0
  133. package/src/skills-lib/wizz-offer-forge/references/empilhamento-valor.md +25 -0
  134. package/src/skills-lib/wizz-offer-forge/references/garantia-reversa.md +22 -0
  135. package/src/skills-lib/wizz-offer-forge/references/mecanismo-unico.md +28 -0
  136. package/src/skills-lib/wizz-offer-forge/references/ponte-trafego.md +21 -0
  137. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -0
  138. package/tools/installer/core/installer.js +57 -0
  139. package/tools/installer/ide/_config-driven.js +44 -30
  140. package/tools/installer/ide/platform-codes.yaml +42 -3
  141. package/tools/installer/modules/external-manager.js +140 -2
  142. package/tools/installer/modules/official-modules.js +68 -21
  143. package/tools/installer/prompts.js +45 -103
  144. package/tools/installer/ui.js +64 -7
  145. package/wizz-modules.yaml +65 -11
  146. package/src/bmm-skills/4-implementation/wizz-sprint-planning/checklist.md +0 -34
  147. package/src/core-skills/wizz-brainstorming/scripts/memlog.py +0 -202
  148. package/src/core-skills/wizz-brainstorming/scripts/tests/test_memlog.py +0 -265
@@ -0,0 +1,1579 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pytest>=8.0", "ruamel.yaml>=0.18"]
4
+ # ///
5
+ """Corruption-critical tests for sprint-status.py.
6
+
7
+ Each test runs the script as a subprocess via ``python3`` against a temp copy of
8
+ an inline fixture, then re-reads the file to assert comments and formatting
9
+ survive and punctuation-heavy action values round-trip intact.
10
+
11
+ Run: python3 scripts/tests/test_sprint_status.py
12
+ or: python3 --with pytest --with ruamel.yaml -m pytest scripts/tests/test_sprint_status.py
13
+ """
14
+
15
+ import importlib.util
16
+ import json
17
+ import os
18
+ import re
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+ from ruamel.yaml import YAML
26
+
27
+ SCRIPT = Path(__file__).resolve().parents[1] / "sprint_status.py"
28
+ # Vendored copy of wizz-sprint-planning's sprint-status-template.yaml — skills
29
+ # must not path into each other's directories (PATH-05). The repo-level
30
+ # test/test-template-sync.js keeps this fixture identical to the source.
31
+ TEMPLATE = Path(__file__).resolve().parent / "fixtures" / "sprint-status-template.yaml"
32
+
33
+ FIXTURE = """\
34
+ # Sprint Status Tracking
35
+ # STATUS DEFINITIONS:
36
+ # backlog - not yet started
37
+ # ready-for-dev - ready to be implemented
38
+ # done - completed
39
+ generated: "01-01-2026 09:00"
40
+ last_updated: "01-01-2026 09:00"
41
+ project: "Demo Project"
42
+ project_key: "DEMO"
43
+ tracking_system: "file"
44
+ story_location: "docs/stories"
45
+ development_status:
46
+ epic-1: backlog
47
+ 1-1-user-authentication: done
48
+ 1-2-account-management: done
49
+ epic-1-retrospective: optional
50
+ epic-2: backlog
51
+ 2-1-dashboard: backlog
52
+ """
53
+
54
+
55
+ # Two epics' worth of items, because the flag's headline use is epic N's retro
56
+ # closing epic N-1's items: nothing may scope a selector to --epic. The two
57
+ # "Scripted item" entries share their action text and differ only by epic, so the
58
+ # legacy epic+action selector has to discriminate on the epic to resolve at all.
59
+ ACTION_FIXTURE = """\
60
+ # Sprint Status Tracking
61
+ # STATUS DEFINITIONS:
62
+ # open - committed during a retrospective, not yet addressed
63
+ generated: "01-01-2026 09:00"
64
+ last_updated: "01-01-2026 09:00"
65
+ development_status:
66
+ 1-1-a: done
67
+ epic-1-retrospective: optional
68
+ 2-1-b: done
69
+ epic-2-retrospective: optional
70
+
71
+ # Action items committed during retrospectives
72
+ action_items:
73
+ - id: "epic-1-retro-item-1-x"
74
+ epic: 1
75
+ action: "Scripted item"
76
+ owner: "Amelia"
77
+ status: "open"
78
+ ref: "docs/epic-1-retro.md"
79
+ - epic: 1
80
+ action: "Pre-existing item"
81
+ owner: "Charlie"
82
+ status: open
83
+ - id: "epic-2-retro-item-1-y"
84
+ epic: 2
85
+ action: "Scripted item"
86
+ owner: "Dana"
87
+ status: "in-progress"
88
+ ref: "docs/epic-2-retro.md"
89
+ """
90
+
91
+ # Three spellings of the same scalar, to pin that a status write never re-styles
92
+ # the line it lands on.
93
+ STYLE_FIXTURE = """\
94
+ last_updated: "01-01-2026 09:00"
95
+ development_status:
96
+ 1-1-a: done
97
+ epic-1-retrospective: optional
98
+ action_items:
99
+ - id: "double"
100
+ epic: 1
101
+ action: "Double"
102
+ status: "open"
103
+ - id: "single"
104
+ epic: 1
105
+ action: "Single"
106
+ status: 'open'
107
+ - id: "plain"
108
+ epic: 1
109
+ action: "Plain"
110
+ status: open
111
+ """
112
+
113
+
114
+ def _run(args):
115
+ cmd = ["uv", "run", str(SCRIPT), *args]
116
+ # LC_ALL=C keeps os.strerror text stable so error-string assertions do not
117
+ # depend on the developer's locale.
118
+ return subprocess.run(
119
+ cmd, capture_output=True, text=True, env={**os.environ, "LC_ALL": "C"}
120
+ )
121
+
122
+
123
+ def _module():
124
+ """Import the script as a module, for the few properties that cannot be
125
+ triggered through the CLI (a failure after the temp file already exists)."""
126
+ spec = importlib.util.spec_from_file_location("sprint_status", SCRIPT)
127
+ mod = importlib.util.module_from_spec(spec)
128
+ spec.loader.exec_module(mod)
129
+ return mod
130
+
131
+
132
+ def _write_fixture(tmp_path):
133
+ target = tmp_path / "sprint-status.yaml"
134
+ target.write_text(FIXTURE, encoding="utf-8")
135
+ return target
136
+
137
+
138
+ def _write_action_fixture(tmp_path):
139
+ target = tmp_path / "sprint-status.yaml"
140
+ target.write_text(ACTION_FIXTURE, encoding="utf-8")
141
+ return target
142
+
143
+
144
+ def _load(path):
145
+ yaml = YAML(typ="rt")
146
+ with open(path, "r", encoding="utf-8") as fh:
147
+ return yaml.load(fh)
148
+
149
+
150
+ def _json(proc):
151
+ """Parse the JSON-only stdout contract, surfacing a crash instead of hiding
152
+ it behind a JSONDecodeError."""
153
+ assert proc.stdout, f"empty stdout; stderr was: {proc.stderr}"
154
+ assert "Traceback" not in proc.stderr, proc.stderr
155
+ return json.loads(proc.stdout)
156
+
157
+
158
+ def test_detect_epic(tmp_path):
159
+ target = _write_fixture(tmp_path)
160
+ proc = _run(["detect-epic", "--file", str(target)])
161
+ assert proc.returncode == 0, proc.stderr
162
+ out = json.loads(proc.stdout)
163
+ assert out["epic"] == 1
164
+ assert out["story_count"] == 2
165
+ assert out["retro_key"] == "epic-1-retrospective"
166
+ assert out["retro_status"] == "optional"
167
+ assert set(out["done_stories"]) == {
168
+ "1-1-user-authentication",
169
+ "1-2-account-management",
170
+ }
171
+
172
+
173
+ def test_detect_epic_rejects_typed_retrospective_status_as_json(tmp_path):
174
+ fixture = (
175
+ "development_status:\n"
176
+ " 1-1-a: done\n"
177
+ " epic-1-retrospective: 2026-01-01\n"
178
+ )
179
+ target = tmp_path / "sprint-status.yaml"
180
+ target.write_text(fixture, encoding="utf-8")
181
+
182
+ proc = _run(["detect-epic", "--file", str(target)])
183
+
184
+ assert proc.returncode == 1
185
+ out = _json(proc)
186
+ assert out["ok"] is False
187
+ assert out["error"] == "epic-1-retrospective status must be a string or null"
188
+ assert "restored" not in out
189
+ assert target.read_text(encoding="utf-8") == fixture
190
+
191
+
192
+ # --- pending_stories: the unfinished-epic gate --------------------------------
193
+
194
+
195
+ def test_pending_stories_lists_the_selected_epics_unfinished_keys(tmp_path):
196
+ # The gate the skill branches on before Phase 1: an epic whose highest done
197
+ # story selected it, but which is not actually finished. Document order, so
198
+ # the listing the user confirms matches the file they can open.
199
+ fixture = (
200
+ "development_status:\n"
201
+ " epic-2: backlog\n"
202
+ " 2-1-a: done\n"
203
+ " 2-2-b: backlog\n"
204
+ " 2-3-c: ready-for-dev\n"
205
+ )
206
+ target = tmp_path / "sprint-status.yaml"
207
+ target.write_text(fixture, encoding="utf-8")
208
+ proc = _run(["detect-epic", "--file", str(target)])
209
+ assert proc.returncode == 0, proc.stderr
210
+ out = _json(proc)
211
+ assert out["epic"] == 2
212
+ assert out["pending_stories"] == ["2-2-b", "2-3-c"]
213
+ # A non-story key sitting beside them never leaks in: STORY_RE gates entry.
214
+ assert "epic-2" not in out["pending_stories"]
215
+
216
+
217
+ def test_pending_stories_is_empty_for_a_complete_epic(tmp_path):
218
+ fixture = (
219
+ "development_status:\n"
220
+ " 2-1-a: done\n"
221
+ " 2-2-b: done\n"
222
+ " epic-2-retrospective: optional\n"
223
+ )
224
+ target = tmp_path / "sprint-status.yaml"
225
+ target.write_text(fixture, encoding="utf-8")
226
+ proc = _run(["detect-epic", "--file", str(target)])
227
+ assert proc.returncode == 0, proc.stderr
228
+ out = _json(proc)
229
+ assert out["epic"] == 2
230
+ assert out["pending_stories"] == []
231
+
232
+
233
+ def test_pending_stories_ignores_other_epics(tmp_path):
234
+ # FIXTURE detects epic 1 while 2-1-dashboard sits at backlog. The key is
235
+ # scoped to the *selected* epic -- unlike done_stories, which spans the whole
236
+ # file -- so another epic's unfinished work must never block this retro.
237
+ target = _write_fixture(tmp_path)
238
+ proc = _run(["detect-epic", "--file", str(target)])
239
+ assert proc.returncode == 0, proc.stderr
240
+ out = _json(proc)
241
+ assert out["epic"] == 1
242
+ assert out["pending_stories"] == []
243
+ # done_stories keeps its whole-file scope, unchanged.
244
+ assert set(out["done_stories"]) == {
245
+ "1-1-user-authentication",
246
+ "1-2-account-management",
247
+ }
248
+
249
+
250
+ def test_pending_stories_present_when_no_epic_is_detected(tmp_path):
251
+ # No done story anywhere: the shape stays uniform so a caller can read
252
+ # pending_stories without first branching on epic.
253
+ fixture = (
254
+ "development_status:\n"
255
+ " 1-1-a: backlog\n"
256
+ " 2-1-b: ready-for-dev\n"
257
+ )
258
+ target = tmp_path / "sprint-status.yaml"
259
+ target.write_text(fixture, encoding="utf-8")
260
+ proc = _run(["detect-epic", "--file", str(target)])
261
+ assert proc.returncode == 0, proc.stderr
262
+ out = _json(proc)
263
+ assert out["epic"] is None
264
+ assert out["story_count"] == 0
265
+ assert out["pending_stories"] == []
266
+
267
+
268
+ def test_detect_epic_flag_aims_pending_stories_at_a_supplied_epic(tmp_path):
269
+ # Auto-detect would pick epic 2 (highest with a done story). --epic 1 aims
270
+ # the unfinished-epic gate at the orchestrator's explicit choice instead —
271
+ # the -H <epic> path that previously had no pending_stories at all.
272
+ fixture = (
273
+ "development_status:\n"
274
+ " 1-1-a: done\n"
275
+ " 1-2-b: backlog\n"
276
+ " 1-3-c: review\n"
277
+ " 2-1-a: done\n"
278
+ " 2-2-b: done\n"
279
+ " epic-1-retrospective: optional\n"
280
+ )
281
+ target = tmp_path / "sprint-status.yaml"
282
+ target.write_text(fixture, encoding="utf-8")
283
+ proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
284
+ assert proc.returncode == 0, proc.stderr
285
+ out = _json(proc)
286
+ assert out["epic"] == 1
287
+ assert out["retro_key"] == "epic-1-retrospective"
288
+ assert out["retro_status"] == "optional"
289
+ assert out["pending_stories"] == ["1-2-b", "1-3-c"]
290
+ # done_stories keeps its whole-file scope.
291
+ assert set(out["done_stories"]) == {"1-1-a", "2-1-a", "2-2-b"}
292
+
293
+
294
+ def test_detect_epic_flag_lists_pending_when_no_story_is_done(tmp_path):
295
+ # Without --epic, no done story means epic is null. With --epic, an
296
+ # unfinished epic that never landed a done story is still addressable —
297
+ # every story key of that epic is pending.
298
+ fixture = (
299
+ "development_status:\n"
300
+ " 3-1-a: backlog\n"
301
+ " 3-2-b: ready-for-dev\n"
302
+ )
303
+ target = tmp_path / "sprint-status.yaml"
304
+ target.write_text(fixture, encoding="utf-8")
305
+ proc = _run(["detect-epic", "--file", str(target), "--epic", "3"])
306
+ assert proc.returncode == 0, proc.stderr
307
+ out = _json(proc)
308
+ assert out["epic"] == 3
309
+ assert out["pending_stories"] == ["3-1-a", "3-2-b"]
310
+ assert out["retro_key"] == "epic-3-retrospective"
311
+ assert out["retro_status"] is None
312
+
313
+
314
+ def test_detect_epic_flag_empty_pending_for_a_complete_supplied_epic(tmp_path):
315
+ fixture = (
316
+ "development_status:\n"
317
+ " 1-1-a: done\n"
318
+ " 1-2-b: done\n"
319
+ " 2-1-a: backlog\n"
320
+ )
321
+ target = tmp_path / "sprint-status.yaml"
322
+ target.write_text(fixture, encoding="utf-8")
323
+ proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
324
+ assert proc.returncode == 0, proc.stderr
325
+ out = _json(proc)
326
+ assert out["epic"] == 1
327
+ assert out["pending_stories"] == []
328
+
329
+
330
+ def test_detect_epic_flag_zero_story_count_marks_a_nonexistent_epic(tmp_path):
331
+ # --epic 9 against a file that has no epic-9 stories: pending_stories is
332
+ # empty exactly as it is for a finished epic, so story_count is the only
333
+ # signal separating "complete" from "typo'd". The gate reads 0 as suspect,
334
+ # never as done.
335
+ fixture = (
336
+ "development_status:\n"
337
+ " 1-1-a: done\n"
338
+ " 1-2-b: done\n"
339
+ )
340
+ target = tmp_path / "sprint-status.yaml"
341
+ target.write_text(fixture, encoding="utf-8")
342
+ proc = _run(["detect-epic", "--file", str(target), "--epic", "9"])
343
+ assert proc.returncode == 0, proc.stderr
344
+ out = _json(proc)
345
+ assert out["epic"] == 9
346
+ assert out["story_count"] == 0
347
+ assert out["pending_stories"] == []
348
+ assert out["retro_status"] is None
349
+ # The finished epic it could be confused with reports its real count.
350
+ proc = _run(["detect-epic", "--file", str(target), "--epic", "1"])
351
+ out = _json(proc)
352
+ assert out["story_count"] == 2
353
+ assert out["pending_stories"] == []
354
+
355
+
356
+ def test_detect_epic_flag_rejects_non_positive_epic(tmp_path):
357
+ target = _write_fixture(tmp_path)
358
+ for bad in ("0", "-3"):
359
+ proc = _run(["detect-epic", "--file", str(target), "--epic", bad])
360
+ assert proc.returncode == 1, proc.stderr
361
+ out = _json(proc)
362
+ assert out["ok"] is False
363
+ assert "epic" in out["error"]
364
+ assert "restored" not in out
365
+
366
+
367
+ def test_update_rejects_non_positive_epic(tmp_path):
368
+ target = _write_fixture(tmp_path)
369
+ for bad in ("0", "-3"):
370
+ proc = _run(["update", "--file", str(target), "--epic", bad])
371
+ assert proc.returncode == 1, proc.stderr
372
+ assert "positive integer" in _json(proc)["error"]
373
+ assert target.read_text(encoding="utf-8") == FIXTURE
374
+
375
+
376
+ # --- The JSON-only contract covers the help paths ----------------------------
377
+
378
+
379
+ @pytest.mark.parametrize(
380
+ "args",
381
+ [["-h"], ["--help"], ["detect-epic", "-h"], ["detect-epic", "--help"]],
382
+ ids=["top-short", "top-long", "sub-short", "sub-long"],
383
+ )
384
+ def test_help_flags_emit_json_not_usage(args):
385
+ # argparse's built-in help action bypasses error() -- it prints usage text
386
+ # to stdout and exits 0, which is exactly the contract this script sells.
387
+ # add_help=False turns -h into an ordinary unrecognized argument instead.
388
+ proc = _run(args)
389
+ # Exit 2 specifically: the module docstring reserves 2 for argument errors
390
+ # and 1 for I/O failures, and retro-document.md teaches callers to tell the
391
+ # two apart, so collapsing them must fail here.
392
+ assert proc.returncode == 2
393
+ assert "usage:" not in proc.stdout
394
+ out = _json(proc)
395
+ assert out["ok"] is False
396
+ assert out["error"]
397
+ # An argparse rejection speaks for no file, so it carries no "restored" --
398
+ # the same rule test_only_the_write_path_reports_restored pins for exit 1.
399
+ assert "restored" not in out
400
+
401
+
402
+ @pytest.mark.parametrize("flag", ["-h", "--help"])
403
+ def test_update_help_flag_emits_json_not_usage(tmp_path, flag):
404
+ # The update subparser too, driven with its required arguments present so
405
+ # nothing but the help flag itself can be what argparse objects to.
406
+ target = _write_fixture(tmp_path)
407
+ proc = _run(["update", "--file", str(target), "--epic", "1", flag])
408
+ assert proc.returncode == 2
409
+ assert "usage:" not in proc.stdout
410
+ out = _json(proc)
411
+ assert out["ok"] is False
412
+ assert flag in out["error"]
413
+ assert "restored" not in out
414
+ # A rejected invocation must not have written anything.
415
+ assert target.read_text(encoding="utf-8") == FIXTURE
416
+
417
+
418
+ def test_update_sets_retro_and_appends_action(tmp_path):
419
+ target = _write_fixture(tmp_path)
420
+ payload = '[{"action":"Fix #42: colons: and # hashes","owner":"Amelia"}]'
421
+ proc = _run(
422
+ [
423
+ "update",
424
+ "--file",
425
+ str(target),
426
+ "--epic",
427
+ "1",
428
+ "--set-retro-done",
429
+ "--add-action",
430
+ payload,
431
+ ]
432
+ )
433
+ assert proc.returncode == 0, proc.stderr
434
+ out = json.loads(proc.stdout)
435
+ assert out["ok"] is True
436
+ assert out["retro_key_found"] is True
437
+ assert out["retro_status_after"] == "done"
438
+ assert out["action_items_added"] == 1
439
+
440
+ # File must still parse cleanly (punctuation did not corrupt it).
441
+ data = _load(target)
442
+ assert data is not None
443
+
444
+ # STATUS DEFINITIONS comment survived.
445
+ raw = target.read_text(encoding="utf-8")
446
+ assert "STATUS DEFINITIONS" in raw
447
+
448
+ # Retro status flipped to done.
449
+ assert data["development_status"]["epic-1-retrospective"] == "done"
450
+
451
+ # The action value round-trips with literal '#' and ':' intact.
452
+ action = data["action_items"][0]
453
+ assert action["action"] == "Fix #42: colons: and # hashes"
454
+ assert action["owner"] == "Amelia"
455
+ assert action["epic"] == 1
456
+ assert action["status"] == "open"
457
+
458
+
459
+ def test_update_rejects_typed_retrospective_status_before_writing(tmp_path):
460
+ fixture = (
461
+ "last_updated: 01-01-2026 09:00\n"
462
+ "development_status:\n"
463
+ " 1-1-a: done\n"
464
+ " epic-1-retrospective: 2026-01-01\n"
465
+ )
466
+ target = tmp_path / "sprint-status.yaml"
467
+ target.write_text(fixture, encoding="utf-8")
468
+
469
+ proc = _run(
470
+ [
471
+ "update",
472
+ "--file",
473
+ str(target),
474
+ "--epic",
475
+ "1",
476
+ "--set-retro-done",
477
+ "--add-action",
478
+ '[{"action":"Must not be appended","owner":"Amelia"}]',
479
+ ]
480
+ )
481
+
482
+ assert proc.returncode == 1
483
+ out = _json(proc)
484
+ assert out["ok"] is False
485
+ assert out["restored"] is True
486
+ assert out["error"] == "epic-1-retrospective status must be a string or null"
487
+ assert target.read_text(encoding="utf-8") == fixture
488
+
489
+
490
+ def test_detect_epic_matches_split_story_keys(tmp_path):
491
+ # A split-story key like 2-6a-... is first-class in Wizz (an oversized story
492
+ # split into 2-6a / 2-6b) and must not be invisible to detection — otherwise
493
+ # an epic whose only done stories are splits is silently skipped.
494
+ fixture = (
495
+ "development_status:\n"
496
+ " 1-1-first: done\n"
497
+ " 2-6a-split-auth: done\n"
498
+ " epic-2-retrospective: optional\n"
499
+ )
500
+ target = tmp_path / "sprint-status.yaml"
501
+ target.write_text(fixture, encoding="utf-8")
502
+ proc = _run(["detect-epic", "--file", str(target)])
503
+ assert proc.returncode == 0, proc.stderr
504
+ out = json.loads(proc.stdout)
505
+ assert out["epic"] == 2
506
+ assert "2-6a-split-auth" in out["done_stories"]
507
+ assert out["retro_key"] == "epic-2-retrospective"
508
+
509
+
510
+ def test_update_rejects_non_list_action_items(tmp_path):
511
+ # A hand-corrupted action_items must fail on the JSON contract, not crash.
512
+ fixture = (
513
+ "development_status:\n"
514
+ " 1-1-a: done\n"
515
+ " epic-1-retrospective: optional\n"
516
+ 'action_items: "oops-not-a-list"\n'
517
+ )
518
+ target = tmp_path / "sprint-status.yaml"
519
+ target.write_text(fixture, encoding="utf-8")
520
+ proc = _run(
521
+ ["update", "--file", str(target), "--epic", "1",
522
+ "--add-action", '[{"action":"x","owner":"y"}]']
523
+ )
524
+ assert proc.returncode == 1
525
+ out = json.loads(proc.stdout) # must be JSON, not a traceback
526
+ assert out["ok"] is False
527
+ assert "action_items" in out["error"]
528
+
529
+
530
+ def test_appended_items_carry_id_and_ref(tmp_path):
531
+ target = _write_fixture(tmp_path)
532
+ ref = "docs/stories/epic-1-retro-2026-07-21.md"
533
+ proc = _run(
534
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done",
535
+ "--add-action", '[{"action":"Fix the seam","owner":"Amelia"}]',
536
+ "--ref", ref, "--verdict", "accepted-with-open-items"]
537
+ )
538
+ assert proc.returncode == 0, proc.stderr
539
+ out = json.loads(proc.stdout)
540
+ assert out["verdict"] == "accepted-with-open-items" # echoed, not written to a key
541
+
542
+ item = _load(target)["action_items"][0]
543
+ assert item["id"].startswith("epic-1-retro-item-1-")
544
+ assert item["ref"] == ref
545
+ # The retro key value stays "done" — verdict is not encoded into it.
546
+ assert _load(target)["development_status"]["epic-1-retrospective"] == "done"
547
+
548
+
549
+ def test_free_spelled_verdict_is_rejected_before_the_file_is_touched(tmp_path):
550
+ # The SKILL's prose verdict ("accepted with open items") and the frontmatter
551
+ # token (accepted-with-open-items) used to be two spellings of one value; an
552
+ # orchestrator branching on the echo would fall through both. Only the
553
+ # frontmatter vocabulary passes; anything else fails with the file intact.
554
+ target = _write_fixture(tmp_path)
555
+ for bad in ("accepted with open items", "ship it", "ACCEPTED"):
556
+ proc = _run(
557
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done",
558
+ "--verdict", bad]
559
+ )
560
+ assert proc.returncode == 1, f"accepted {bad!r}"
561
+ out = _json(proc)
562
+ assert out["ok"] is False and "--verdict" in out["error"]
563
+ assert out["restored"] is True
564
+ assert target.read_text(encoding="utf-8") == FIXTURE
565
+
566
+
567
+ def test_explicit_item_id_is_preserved(tmp_path):
568
+ target = _write_fixture(tmp_path)
569
+ proc = _run(
570
+ ["update", "--file", str(target), "--epic", "1",
571
+ "--add-action", '[{"action":"a","owner":"o","id":"custom-id-7"}]']
572
+ )
573
+ assert proc.returncode == 0, proc.stderr
574
+ assert _load(target)["action_items"][0]["id"] == "custom-id-7"
575
+
576
+
577
+ @pytest.mark.skipif(
578
+ hasattr(os, "geteuid") and os.geteuid() == 0,
579
+ reason="root bypasses file permission bits",
580
+ )
581
+ def test_write_failure_reports_restore_status(tmp_path):
582
+ # If the write cannot happen, the caller must be told whether the original
583
+ # was restored — a silent failure defeats the script's core guarantee.
584
+ #
585
+ # The write is atomic (temp file + os.replace), and os.replace needs write
586
+ # permission on the *directory*, not on the target — a read-only target is
587
+ # now replaceable. Making the containing directory read-only is what blocks
588
+ # the write: mkstemp fails, while the restore write to the still-writable
589
+ # target succeeds.
590
+ holder = tmp_path / "holder"
591
+ holder.mkdir()
592
+ target = _write_fixture(holder)
593
+ os.chmod(holder, 0o555)
594
+ try:
595
+ proc = _run(
596
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done"]
597
+ )
598
+ finally:
599
+ os.chmod(holder, 0o755)
600
+ assert proc.returncode == 1
601
+ out = _json(proc)
602
+ assert out["ok"] is False
603
+ assert out["restored"] is True
604
+ # The file was never touched: the temp file could not even be created.
605
+ assert target.read_text(encoding="utf-8") == FIXTURE
606
+ assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"]
607
+
608
+
609
+ def test_punctuation_does_not_corrupt_file(tmp_path):
610
+ # Explicit re-parse guarantee for YAML-breaking punctuation.
611
+ target = _write_fixture(tmp_path)
612
+ payload = '[{"action":"weird: value # with: hashes","owner":"Bob # Smith"}]'
613
+ proc = _run(
614
+ [
615
+ "update",
616
+ "--file",
617
+ str(target),
618
+ "--epic",
619
+ "1",
620
+ "--add-action",
621
+ payload,
622
+ ]
623
+ )
624
+ assert proc.returncode == 0, proc.stderr
625
+ # Re-parse must succeed and preserve the literal punctuation.
626
+ data = _load(target)
627
+ assert data["action_items"][0]["action"] == "weird: value # with: hashes"
628
+ assert data["action_items"][0]["owner"] == "Bob # Smith"
629
+
630
+
631
+ # --- Formatting fidelity -----------------------------------------------------
632
+
633
+
634
+ def test_template_round_trip_changes_only_last_updated(tmp_path):
635
+ # The repo's own sprint-status template is the shape every generated file
636
+ # inherits: 2-space sequence indent and a mid-file comment above
637
+ # action_items. An update must touch nothing but last_updated — a re-indent
638
+ # of a pre-existing, untouched entry defeats the preservation guarantee that
639
+ # motivates "do not hand-edit this file".
640
+ source = TEMPLATE.read_text(encoding="utf-8")
641
+ target = tmp_path / "sprint-status.yaml"
642
+ target.write_text(source, encoding="utf-8")
643
+
644
+ proc = _run(
645
+ ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00"]
646
+ )
647
+ assert proc.returncode == 0, proc.stderr
648
+ assert _json(proc)["ok"] is True
649
+
650
+ before = source.splitlines()
651
+ after = target.read_text(encoding="utf-8").splitlines()
652
+ assert len(before) == len(after)
653
+ changed = [(b, a) for b, a in zip(before, after) if b != a]
654
+ assert len(changed) == 1, changed
655
+ assert changed[0][1] == "last_updated: 01-01-2026 09:00"
656
+ # The pre-existing action item keeps its 2-space sequence indent.
657
+ assert " - epic: 1" in after
658
+
659
+
660
+ def test_mid_file_comment_survives_update(tmp_path):
661
+ fixture = (
662
+ "# header\n"
663
+ "development_status:\n"
664
+ " 1-1-a: done\n"
665
+ " epic-1-retrospective: optional\n"
666
+ "\n"
667
+ "# Action items committed during retrospectives\n"
668
+ "action_items:\n"
669
+ " - epic: 1\n"
670
+ ' action: "Pre-existing item"\n'
671
+ ' owner: "Charlie"\n'
672
+ " status: open\n"
673
+ )
674
+ target = tmp_path / "sprint-status.yaml"
675
+ target.write_text(fixture, encoding="utf-8")
676
+ proc = _run(
677
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done",
678
+ "--add-action", '[{"action":"New item","owner":"Amelia"}]']
679
+ )
680
+ assert proc.returncode == 0, proc.stderr
681
+ raw = target.read_text(encoding="utf-8")
682
+ assert "# Action items committed during retrospectives" in raw
683
+ assert "# header" in raw
684
+ assert ' action: "Pre-existing item"' in raw
685
+
686
+
687
+ def test_legacy_offset_zero_file_is_canonicalized(tmp_path):
688
+ # Files the previous version of this script wrote carry action_items at
689
+ # column 0. The indent pin re-indents them to the template's shape on the
690
+ # next write. That is a deliberate one-time canonicalization, not a silent
691
+ # failure: the update still succeeds and no comment is lost.
692
+ fixture = (
693
+ "# header\n"
694
+ "development_status:\n"
695
+ " 1-1-a: done\n"
696
+ " epic-1-retrospective: optional\n"
697
+ "action_items:\n"
698
+ '- id: "legacy"\n'
699
+ " epic: 1\n"
700
+ ' action: "written by the old code"\n'
701
+ " status: open\n"
702
+ )
703
+ target = tmp_path / "sprint-status.yaml"
704
+ target.write_text(fixture, encoding="utf-8")
705
+ proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
706
+ assert proc.returncode == 0, proc.stderr
707
+ raw = target.read_text(encoding="utf-8")
708
+ assert ' - id: "legacy"' in raw
709
+ assert ' action: "written by the old code"' in raw
710
+ assert "# header" in raw
711
+
712
+
713
+ def test_lost_comment_fails_with_restore(tmp_path):
714
+ # A standalone comment inside a flow collection is genuinely dropped by the
715
+ # round-trip. The leading-block check never saw it; the full multiset does,
716
+ # and the original bytes must come back.
717
+ fixture = (
718
+ "# header\n"
719
+ "development_status:\n"
720
+ " 1-1-a: done\n"
721
+ " epic-1-retrospective: optional\n"
722
+ "tags: [\n"
723
+ " # a standalone comment the round-trip drops\n"
724
+ ' "alpha",\n'
725
+ ' "beta",\n'
726
+ "]\n"
727
+ )
728
+ target = tmp_path / "sprint-status.yaml"
729
+ target.write_text(fixture, encoding="utf-8")
730
+ proc = _run(
731
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done"]
732
+ )
733
+ assert proc.returncode == 1
734
+ out = _json(proc)
735
+ assert out["ok"] is False
736
+ assert out["restored"] is True
737
+ assert "comment line lost" in out["error"]
738
+ assert "a standalone comment the round-trip drops" in out["error"]
739
+ assert target.read_text(encoding="utf-8") == fixture
740
+
741
+
742
+ # --- Malformed input stays on the JSON contract ------------------------------
743
+
744
+
745
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
746
+ def test_non_mapping_root_is_json_error(tmp_path, command):
747
+ target = tmp_path / "sprint-status.yaml"
748
+ target.write_text("- a\n- b\n", encoding="utf-8")
749
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
750
+ proc = _run([command, *args])
751
+ assert proc.returncode == 1
752
+ out = _json(proc)
753
+ assert out["ok"] is False
754
+ assert "root document is not a mapping" in out["error"]
755
+ assert target.read_text(encoding="utf-8") == "- a\n- b\n"
756
+
757
+
758
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
759
+ @pytest.mark.parametrize(
760
+ "body",
761
+ ['development_status: "not-a-mapping"\n', "development_status:\n - a\n - b\n"],
762
+ ids=["scalar", "list"],
763
+ )
764
+ def test_non_mapping_development_status_is_json_error(tmp_path, command, body):
765
+ target = tmp_path / "sprint-status.yaml"
766
+ target.write_text(body, encoding="utf-8")
767
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
768
+ proc = _run([command, *args])
769
+ # update used to report ok:true here while silently doing nothing.
770
+ assert proc.returncode == 1
771
+ out = _json(proc)
772
+ assert out["ok"] is False
773
+ assert "development_status is not a mapping" in out["error"]
774
+
775
+
776
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
777
+ def test_directory_target_is_json_error(tmp_path, command):
778
+ target = tmp_path / "a-directory"
779
+ target.mkdir()
780
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
781
+ proc = _run([command, *args])
782
+ assert proc.returncode == 1
783
+ out = _json(proc)
784
+ assert out["ok"] is False
785
+ assert out["error"]
786
+
787
+
788
+ @pytest.mark.skipif(
789
+ hasattr(os, "geteuid") and os.geteuid() == 0,
790
+ reason="root bypasses file permission bits",
791
+ )
792
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
793
+ def test_unreadable_target_is_json_error(tmp_path, command):
794
+ # The other half of the OSError widening: PermissionError, not just
795
+ # IsADirectoryError, has to stay on the JSON contract.
796
+ target = _write_fixture(tmp_path)
797
+ os.chmod(target, 0o000)
798
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
799
+ try:
800
+ proc = _run([command, *args])
801
+ finally:
802
+ os.chmod(target, 0o644)
803
+ assert proc.returncode == 1
804
+ out = _json(proc)
805
+ assert out["ok"] is False
806
+ assert "denied" in out["error"].lower()
807
+
808
+
809
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
810
+ def test_invalid_utf8_is_json_error(tmp_path, command):
811
+ target = tmp_path / "sprint-status.yaml"
812
+ target.write_bytes(b"development_status:\n 1-1-a: d\xffone\n")
813
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
814
+ proc = _run([command, *args])
815
+ assert proc.returncode == 1
816
+ out = _json(proc)
817
+ assert out["ok"] is False
818
+ assert "utf-8" in out["error"].lower()
819
+
820
+
821
+ # --- Atomic write ------------------------------------------------------------
822
+
823
+
824
+ def test_atomic_write_failure_leaves_target_byte_identical(tmp_path, monkeypatch):
825
+ # The failure the atomic write exists for: something goes wrong after the
826
+ # temp file has been written. Nothing may reach the target and no temp file
827
+ # may survive. No CLI path reaches here -- a read-only directory fails at
828
+ # mkstemp instead -- so this drives the helper directly.
829
+ mod = _module()
830
+ target = _write_fixture(tmp_path)
831
+
832
+ def boom(*args, **kwargs):
833
+ raise OSError(28, "No space left on device")
834
+
835
+ monkeypatch.setattr(mod.os, "replace", boom)
836
+ with pytest.raises(OSError):
837
+ mod._atomic_write(str(target), b"replacement bytes\n", 0o644)
838
+ assert target.read_text(encoding="utf-8") == FIXTURE
839
+ assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
840
+
841
+
842
+ def test_dir_fsync_failure_after_rename_is_not_a_write_failure(tmp_path, monkeypatch):
843
+ # Once os.replace has returned, the new bytes ARE the file. The directory
844
+ # fsync that follows is durability polish; if it raised, cmd_update would
845
+ # emit "restored": true about a write that in fact landed — the one lie the
846
+ # restored contract exists to prevent. Deny opening the directory (the only
847
+ # thing _atomic_write opens by path after the rename) and require success.
848
+ mod = _module()
849
+ target = _write_fixture(tmp_path)
850
+ directory = os.path.dirname(os.path.realpath(str(target)))
851
+ real_open = os.open
852
+
853
+ def deny_directory_open(p, *args, **kwargs):
854
+ if p == directory:
855
+ raise OSError(5, "Input/output error")
856
+ return real_open(p, *args, **kwargs)
857
+
858
+ monkeypatch.setattr(mod.os, "open", deny_directory_open)
859
+ mod._atomic_write(str(target), b"replacement bytes\n", 0o644)
860
+ assert target.read_bytes() == b"replacement bytes\n"
861
+ assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
862
+
863
+
864
+ def test_restore_is_atomic(tmp_path, monkeypatch):
865
+ # _restore is the rollback the reference sells as the safety net. A
866
+ # truncating rewrite that dies halfway would destroy the very bytes it is
867
+ # putting back, which is how a full disk used to corrupt the file.
868
+ mod = _module()
869
+ target = tmp_path / "sprint-status.yaml"
870
+ target.write_text("damaged\n", encoding="utf-8")
871
+
872
+ def boom(*args, **kwargs):
873
+ raise OSError(28, "No space left on device")
874
+
875
+ monkeypatch.setattr(mod.os, "replace", boom)
876
+ assert mod._restore(str(target), FIXTURE.encode("utf-8"), 0o644) is False
877
+ # It reported failure honestly and left the file no worse than it found it.
878
+ assert target.read_text(encoding="utf-8") == "damaged\n"
879
+ assert [p.name for p in tmp_path.iterdir()] == ["sprint-status.yaml"]
880
+
881
+
882
+ def test_symlinked_target_is_written_through(tmp_path):
883
+ # os.replace onto a symlink would detach the link and leave the real file
884
+ # stale while reporting ok:true.
885
+ real = tmp_path / "real-sprint-status.yaml"
886
+ real.write_text(FIXTURE, encoding="utf-8")
887
+ link = tmp_path / "sprint-status.yaml"
888
+ link.symlink_to(real)
889
+ proc = _run(["update", "--file", str(link), "--epic", "1", "--set-retro-done"])
890
+ assert proc.returncode == 0, proc.stderr
891
+ assert link.is_symlink(), "the symlink was replaced by a regular file"
892
+ assert _load(real)["development_status"]["epic-1-retrospective"] == "done"
893
+
894
+
895
+ def test_atomic_write_preserves_mode_and_leaves_no_temp_file(tmp_path):
896
+ # mkstemp creates 0600; without carrying the target's mode over, every
897
+ # update would silently narrow the file.
898
+ holder = tmp_path / "holder"
899
+ holder.mkdir()
900
+ target = _write_fixture(holder)
901
+ os.chmod(target, 0o640)
902
+ proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
903
+ assert proc.returncode == 0, proc.stderr
904
+ assert stat.S_IMODE(target.stat().st_mode) == 0o640
905
+ assert [p.name for p in holder.iterdir()] == ["sprint-status.yaml"]
906
+
907
+
908
+ # --- Result-JSON precision ---------------------------------------------------
909
+
910
+
911
+ def test_retro_key_found_is_null_without_the_flag(tmp_path):
912
+ # No development_status key at all: the update must not conjure one, and
913
+ # retro_key_found must say "not asked" rather than "absent".
914
+ fixture = 'project: "Demo"\nlast_updated: "01-01-2026 09:00"\n'
915
+ target = tmp_path / "sprint-status.yaml"
916
+ target.write_text(fixture, encoding="utf-8")
917
+ proc = _run(["update", "--file", str(target), "--epic", "1"])
918
+ assert proc.returncode == 0, proc.stderr
919
+ out = _json(proc)
920
+ assert out["ok"] is True
921
+ assert out["retro_key_found"] is None
922
+ assert "development_status" not in target.read_text(encoding="utf-8")
923
+
924
+
925
+ def test_retro_key_found_is_false_when_the_key_is_absent(tmp_path):
926
+ target = _write_fixture(tmp_path)
927
+ proc = _run(["update", "--file", str(target), "--epic", "2", "--set-retro-done"])
928
+ assert proc.returncode == 0, proc.stderr
929
+ out = _json(proc)
930
+ assert out["ok"] is True
931
+ assert out["retro_key_found"] is False
932
+ # Nothing was written into the mapping.
933
+ assert "epic-2-retrospective" not in _load(target)["development_status"]
934
+
935
+
936
+ @pytest.mark.parametrize("command", ["detect-epic", "update"])
937
+ def test_only_the_write_path_reports_restored(tmp_path, command):
938
+ # "restored" speaks to the state of a file the command may have written.
939
+ # detect-epic never writes, so inventing the key there would mislead callers
940
+ # that branch on it.
941
+ target = tmp_path / "sprint-status.yaml"
942
+ target.write_text("- a\n- b\n", encoding="utf-8")
943
+ args = ["--file", str(target)] + (["--epic", "1"] if command == "update" else [])
944
+ out = _json(_run([command, *args]))
945
+ assert out["ok"] is False
946
+ assert ("restored" in out) is (command == "update")
947
+
948
+
949
+ def test_pre_write_failure_reports_restored(tmp_path):
950
+ # retro-document.md teaches callers that ok:false carries restored:true;
951
+ # a failure before the write must not read as "the file may be incomplete".
952
+ target = _write_fixture(tmp_path)
953
+ proc = _run(
954
+ ["update", "--file", str(target), "--epic", "1", "--add-action", "{not json"]
955
+ )
956
+ assert proc.returncode == 1
957
+ out = _json(proc)
958
+ assert out["ok"] is False
959
+ assert out["restored"] is True
960
+ assert target.read_text(encoding="utf-8") == FIXTURE
961
+
962
+
963
+ # --- Action-item validation and identity -------------------------------------
964
+
965
+
966
+ def test_non_latin_action_keeps_its_text_in_the_id(tmp_path):
967
+ target = _write_fixture(tmp_path)
968
+ payload = json.dumps(
969
+ [{"action": "Улучшить обработку ошибок", "owner": "Amelia"}],
970
+ ensure_ascii=False,
971
+ )
972
+ proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload])
973
+ assert proc.returncode == 0, proc.stderr
974
+ item_id = _load(target)["action_items"][0]["id"]
975
+ assert item_id == "epic-1-retro-item-1-улучшить-обработку-ошибок"
976
+
977
+
978
+ def test_unsluggable_action_falls_back_to_a_hash(tmp_path):
979
+ target = _write_fixture(tmp_path)
980
+ payload = json.dumps([{"action": "!!! 🎉", "owner": "Amelia"}], ensure_ascii=False)
981
+ proc = _run(["update", "--file", str(target), "--epic", "1", "--add-action", payload])
982
+ assert proc.returncode == 0, proc.stderr
983
+ item_id = _load(target)["action_items"][0]["id"]
984
+ assert not item_id.endswith("-item")
985
+ assert re.fullmatch(r"epic-1-retro-item-1-[0-9a-f]{8}", item_id), item_id
986
+
987
+
988
+ def test_empty_action_is_rejected(tmp_path):
989
+ target = _write_fixture(tmp_path)
990
+ proc = _run(
991
+ ["update", "--file", str(target), "--epic", "1",
992
+ "--add-action", '[{"action":" ","owner":"x"}]']
993
+ )
994
+ assert proc.returncode == 1
995
+ out = _json(proc)
996
+ assert out["ok"] is False
997
+ assert out["restored"] is True
998
+ assert "action" in out["error"]
999
+ assert target.read_text(encoding="utf-8") == FIXTURE
1000
+
1001
+
1002
+ def test_non_string_action_is_rejected(tmp_path):
1003
+ # A JSON null would otherwise be str()'d into a literal "None" and written
1004
+ # as a real action item, which the new emptiness check alone lets through.
1005
+ target = _write_fixture(tmp_path)
1006
+ proc = _run(
1007
+ ["update", "--file", str(target), "--epic", "1",
1008
+ "--add-action", '[{"action":null,"owner":"x"}]']
1009
+ )
1010
+ assert proc.returncode == 1
1011
+ out = _json(proc)
1012
+ assert out["ok"] is False
1013
+ assert out["restored"] is True
1014
+ assert target.read_text(encoding="utf-8") == FIXTURE
1015
+
1016
+
1017
+ def test_date_is_normalized_to_the_canonical_format(tmp_path):
1018
+ # strptime accepts unpadded spellings; writing those through would defeat
1019
+ # the point of validating the format.
1020
+ target = _write_fixture(tmp_path)
1021
+ proc = _run(
1022
+ ["update", "--file", str(target), "--epic", "1", "--date", "1-2-2026 9:05"]
1023
+ )
1024
+ assert proc.returncode == 0, proc.stderr
1025
+ assert _json(proc)["last_updated"] == "01-02-2026 09:05"
1026
+ assert _load(target)["last_updated"] == "01-02-2026 09:05"
1027
+
1028
+
1029
+ def test_malformed_date_is_rejected(tmp_path):
1030
+ target = _write_fixture(tmp_path)
1031
+ proc = _run(
1032
+ ["update", "--file", str(target), "--epic", "1", "--date", "not-a-date"]
1033
+ )
1034
+ assert proc.returncode == 1
1035
+ out = _json(proc)
1036
+ assert out["ok"] is False
1037
+ assert out["restored"] is True
1038
+ assert "--date" in out["error"]
1039
+ assert target.read_text(encoding="utf-8") == FIXTURE
1040
+
1041
+
1042
+ # --- Action-item status transitions ------------------------------------------
1043
+
1044
+
1045
+ def test_set_action_status_applies_both_selector_forms(tmp_path):
1046
+ # The whole point of the flag: an item written by this script (selected by
1047
+ # id) and a legacy item that predates ids (selected by epic + exact action
1048
+ # text) both move off "open" in a single call.
1049
+ target = _write_action_fixture(tmp_path)
1050
+ payload = json.dumps(
1051
+ [
1052
+ {"id": "epic-1-retro-item-1-x", "status": "done"},
1053
+ {"epic": 1, "action": "Pre-existing item", "status": "in-progress"},
1054
+ ]
1055
+ )
1056
+ proc = _run(
1057
+ ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
1058
+ )
1059
+ assert proc.returncode == 0, proc.stderr
1060
+ out = _json(proc)
1061
+ assert out["ok"] is True
1062
+ assert out["action_items_updated"] == 2
1063
+ assert out["action_items_added"] == 0
1064
+
1065
+ items = _load(target)["action_items"]
1066
+ assert items[0]["status"] == "done"
1067
+ assert items[1]["status"] == "in-progress"
1068
+ # Every other key of both items survived untouched, in place.
1069
+ assert items[0]["id"] == "epic-1-retro-item-1-x"
1070
+ assert items[0]["epic"] == 1
1071
+ assert items[0]["action"] == "Scripted item"
1072
+ assert items[0]["owner"] == "Amelia"
1073
+ assert items[0]["ref"] == "docs/epic-1-retro.md"
1074
+ assert "id" not in items[1]
1075
+ assert items[1]["epic"] == 1
1076
+ assert items[1]["action"] == "Pre-existing item"
1077
+ assert items[1]["owner"] == "Charlie"
1078
+ # The epic-2 item shares its action text with items[0]; the epic-1 selector
1079
+ # must not have touched it.
1080
+ assert items[2]["status"] == "in-progress"
1081
+ assert items[2]["id"] == "epic-2-retro-item-1-y"
1082
+ assert len(items) == 3
1083
+
1084
+
1085
+ def test_set_action_status_changes_exactly_one_line(tmp_path):
1086
+ # The status write is surgical: it must not re-style neighbouring lines, and
1087
+ # an item whose status was quoted keeps its quoting. last_updated is rewritten
1088
+ # with the same text it already held, so the whole file differs by one line.
1089
+ target = _write_action_fixture(tmp_path)
1090
+ proc = _run(
1091
+ ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00",
1092
+ "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]']
1093
+ )
1094
+ assert proc.returncode == 0, proc.stderr
1095
+
1096
+ before = ACTION_FIXTURE.splitlines()
1097
+ after = target.read_text(encoding="utf-8").splitlines()
1098
+ assert len(before) == len(after)
1099
+ changed = [(b, a) for b, a in zip(before, after) if b != a]
1100
+ assert changed == [(' status: "open"', ' status: "done"')], changed
1101
+
1102
+
1103
+ def test_set_action_status_composes_with_retro_done_and_add_action(tmp_path):
1104
+ target = _write_action_fixture(tmp_path)
1105
+ proc = _run(
1106
+ ["update", "--file", str(target), "--epic", "1", "--set-retro-done",
1107
+ "--add-action", '[{"action":"Brand new item","owner":"Amelia"}]',
1108
+ "--set-action-status",
1109
+ '[{"epic":1,"action":"Pre-existing item","status":"done"}]']
1110
+ )
1111
+ assert proc.returncode == 0, proc.stderr
1112
+ out = _json(proc)
1113
+ assert out["ok"] is True
1114
+ assert out["retro_status_after"] == "done"
1115
+ assert out["action_items_added"] == 1
1116
+ assert out["action_items_updated"] == 1
1117
+
1118
+ data = _load(target)
1119
+ assert data["development_status"]["epic-1-retrospective"] == "done"
1120
+ items = data["action_items"]
1121
+ assert len(items) == 4
1122
+ assert items[1]["status"] == "done" # the targeted pre-existing item
1123
+ assert items[3]["action"] == "Brand new item"
1124
+ assert items[3]["status"] == "open" # the appended item is always open
1125
+
1126
+
1127
+ def test_set_action_status_cannot_target_an_item_added_in_the_same_run(tmp_path):
1128
+ # Selectors resolve against action_items as loaded, so the append cannot be
1129
+ # observed by the same invocation. Silently succeeding here would make the
1130
+ # flag a back door for writing a non-open status onto a brand-new item.
1131
+ target = _write_action_fixture(tmp_path)
1132
+ proc = _run(
1133
+ ["update", "--file", str(target), "--epic", "1",
1134
+ "--add-action", '[{"action":"Brand new","owner":"A","id":"brand-new"}]',
1135
+ "--set-action-status", '[{"id":"brand-new","status":"done"}]']
1136
+ )
1137
+ assert proc.returncode == 1
1138
+ out = _json(proc)
1139
+ assert out["ok"] is False
1140
+ assert out["restored"] is True
1141
+ assert "no action item matches" in out["error"]
1142
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1143
+
1144
+
1145
+ def test_set_action_status_rejects_unknown_id(tmp_path):
1146
+ target = _write_action_fixture(tmp_path)
1147
+ proc = _run(
1148
+ ["update", "--file", str(target), "--epic", "1",
1149
+ "--set-action-status", '[{"id":"not-in-the-file","status":"done"}]']
1150
+ )
1151
+ assert proc.returncode == 1
1152
+ out = _json(proc)
1153
+ assert out["ok"] is False
1154
+ assert out["restored"] is True
1155
+ assert "not-in-the-file" in out["error"]
1156
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1157
+
1158
+
1159
+ def test_set_action_status_rejects_selector_when_action_items_is_absent(tmp_path):
1160
+ # No action_items key at all must read as "no match", not as a crash.
1161
+ target = _write_fixture(tmp_path)
1162
+ proc = _run(
1163
+ ["update", "--file", str(target), "--epic", "1",
1164
+ "--set-action-status", '[{"id":"anything","status":"done"}]']
1165
+ )
1166
+ assert proc.returncode == 1
1167
+ out = _json(proc)
1168
+ assert out["ok"] is False
1169
+ assert out["restored"] is True
1170
+ assert "no action item matches" in out["error"]
1171
+ assert target.read_text(encoding="utf-8") == FIXTURE
1172
+
1173
+
1174
+ def test_set_action_status_rejects_ambiguous_selector(tmp_path):
1175
+ # Two legacy items with identical epic + action text: guessing between them
1176
+ # would write the wrong row half the time.
1177
+ fixture = (
1178
+ "development_status:\n"
1179
+ " 1-1-a: done\n"
1180
+ " epic-1-retrospective: optional\n"
1181
+ "action_items:\n"
1182
+ " - epic: 1\n"
1183
+ ' action: "Same text"\n'
1184
+ ' owner: "Charlie"\n'
1185
+ " status: open\n"
1186
+ " - epic: 1\n"
1187
+ ' action: "Same text"\n'
1188
+ ' owner: "Dana"\n'
1189
+ " status: open\n"
1190
+ )
1191
+ target = tmp_path / "sprint-status.yaml"
1192
+ target.write_text(fixture, encoding="utf-8")
1193
+ proc = _run(
1194
+ ["update", "--file", str(target), "--epic", "1",
1195
+ "--set-action-status", '[{"epic":1,"action":"Same text","status":"done"}]']
1196
+ )
1197
+ assert proc.returncode == 1
1198
+ out = _json(proc)
1199
+ assert out["ok"] is False
1200
+ assert out["restored"] is True
1201
+ assert "ambiguous" in out["error"]
1202
+ assert "Same text" in out["error"]
1203
+ assert "2 matches" in out["error"]
1204
+ assert target.read_text(encoding="utf-8") == fixture
1205
+
1206
+
1207
+ def test_set_action_status_rejects_two_entries_hitting_the_same_item(tmp_path):
1208
+ # The id form and the epic/action form can name the same row; applying both
1209
+ # would overcount action_items_updated and hide a conflicting pair.
1210
+ target = _write_action_fixture(tmp_path)
1211
+ payload = json.dumps(
1212
+ [
1213
+ {"id": "epic-1-retro-item-1-x", "status": "done"},
1214
+ {"epic": 1, "action": "Scripted item", "status": "in-progress"},
1215
+ ]
1216
+ )
1217
+ proc = _run(
1218
+ ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
1219
+ )
1220
+ assert proc.returncode == 1
1221
+ out = _json(proc)
1222
+ assert out["ok"] is False
1223
+ assert out["restored"] is True
1224
+ assert "same action item" in out["error"]
1225
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1226
+
1227
+
1228
+ def test_entry_with_both_selector_forms_uses_the_id(tmp_path):
1229
+ # A caller that copied a whole item through supplies both. The id is the
1230
+ # precise form and wins; the extra keys are ignored, not rejected. The two
1231
+ # forms are pointed at *different* rows so the precedence is observable:
1232
+ # the id names items[0], the epic/action pair names items[1].
1233
+ target = _write_action_fixture(tmp_path)
1234
+ payload = json.dumps(
1235
+ [
1236
+ {
1237
+ "id": "epic-1-retro-item-1-x",
1238
+ "epic": 1,
1239
+ "action": "Pre-existing item",
1240
+ "owner": "Charlie",
1241
+ "status": "done",
1242
+ }
1243
+ ]
1244
+ )
1245
+ proc = _run(
1246
+ ["update", "--file", str(target), "--epic", "1", "--set-action-status", payload]
1247
+ )
1248
+ assert proc.returncode == 0, proc.stderr
1249
+ assert _json(proc)["action_items_updated"] == 1
1250
+ items = _load(target)["action_items"]
1251
+ assert items[0]["status"] == "done" # the id's item
1252
+ assert items[1]["status"] == "open" # the epic/action item, untouched
1253
+
1254
+
1255
+ def test_set_action_status_rejects_invalid_status(tmp_path):
1256
+ target = _write_action_fixture(tmp_path)
1257
+ proc = _run(
1258
+ ["update", "--file", str(target), "--epic", "1",
1259
+ "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"closed"}]']
1260
+ )
1261
+ assert proc.returncode == 1
1262
+ out = _json(proc)
1263
+ assert out["ok"] is False
1264
+ assert out["restored"] is True
1265
+ assert "closed" in out["error"]
1266
+ # The allowed vocabulary is named so the caller can correct the call.
1267
+ assert "open, in-progress, done" in out["error"]
1268
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1269
+
1270
+
1271
+ def test_set_action_status_rejects_malformed_json(tmp_path):
1272
+ target = _write_action_fixture(tmp_path)
1273
+ proc = _run(
1274
+ ["update", "--file", str(target), "--epic", "1",
1275
+ "--set-action-status", "{not json"]
1276
+ )
1277
+ assert proc.returncode == 1
1278
+ out = _json(proc)
1279
+ assert out["ok"] is False
1280
+ assert out["restored"] is True
1281
+ assert "invalid --set-action-status JSON" in out["error"]
1282
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1283
+
1284
+
1285
+ _SELECTOR_SHAPE_ERROR = (
1286
+ "each --set-action-status entry must have a non-empty string id, "
1287
+ "or an integer epic and a non-empty string action"
1288
+ )
1289
+
1290
+
1291
+ @pytest.mark.parametrize(
1292
+ ("payload", "expected_error"),
1293
+ [
1294
+ (
1295
+ '{"id":"epic-1-retro-item-1-x","status":"done"}',
1296
+ "--set-action-status must be a JSON array",
1297
+ ),
1298
+ (
1299
+ '["epic-1-retro-item-1-x"]',
1300
+ "each --set-action-status entry must be an object",
1301
+ ),
1302
+ ('[{"status":"done"}]', _SELECTOR_SHAPE_ERROR),
1303
+ (
1304
+ '[{"id":"","status":"done"}]',
1305
+ "each --set-action-status id must be a non-empty string",
1306
+ ),
1307
+ (
1308
+ '[{"id":42,"status":"done"}]',
1309
+ "each --set-action-status id must be a non-empty string",
1310
+ ),
1311
+ (
1312
+ '[{"epic":"1","action":"Pre-existing item","status":"done"}]',
1313
+ _SELECTOR_SHAPE_ERROR,
1314
+ ),
1315
+ (
1316
+ '[{"epic":true,"action":"Pre-existing item","status":"done"}]',
1317
+ _SELECTOR_SHAPE_ERROR,
1318
+ ),
1319
+ ('[{"epic":1,"action":" ","status":"done"}]', _SELECTOR_SHAPE_ERROR),
1320
+ (
1321
+ # No status key at all: the status validator runs before the selector
1322
+ # validator, so this is the status branch, not the selector branch.
1323
+ '[{"epic":1,"action":"Pre-existing item"}]',
1324
+ "invalid --set-action-status status None",
1325
+ ),
1326
+ (
1327
+ '[{"id":"epic-1-retro-item-1-x","status":3}]',
1328
+ "invalid --set-action-status status 3",
1329
+ ),
1330
+ ],
1331
+ ids=[
1332
+ "not-a-list",
1333
+ "entry-not-an-object",
1334
+ "no-selector",
1335
+ "empty-id",
1336
+ "non-string-id",
1337
+ "string-epic",
1338
+ "bool-epic",
1339
+ "blank-action",
1340
+ "no-status-key",
1341
+ "non-string-status",
1342
+ ],
1343
+ )
1344
+ def test_set_action_status_rejects_bad_shapes(tmp_path, payload, expected_error):
1345
+ # Each case pins its own message: collapsing the branches into one generic
1346
+ # error would leave a caller unable to tell which part of the array is wrong.
1347
+ target = _write_action_fixture(tmp_path)
1348
+ proc = _run(
1349
+ ["update", "--file", str(target), "--epic", "1",
1350
+ "--set-action-status", payload]
1351
+ )
1352
+ assert proc.returncode == 1
1353
+ out = _json(proc)
1354
+ assert out["ok"] is False
1355
+ assert out["restored"] is True
1356
+ assert expected_error in out["error"], out["error"]
1357
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1358
+
1359
+
1360
+ def test_selectors_are_not_scoped_to_the_epic_flag(tmp_path):
1361
+ # The flag's headline use: epic 2's retro closing epic 1's items. --epic only
1362
+ # names the retro key and stamps appended items; scoping selectors to it would
1363
+ # silently break the documented cross-epic workflow while every same-epic test
1364
+ # kept passing.
1365
+ target = _write_action_fixture(tmp_path)
1366
+ payload = json.dumps(
1367
+ [
1368
+ {"id": "epic-1-retro-item-1-x", "status": "done"},
1369
+ {"epic": 1, "action": "Pre-existing item", "status": "done"},
1370
+ ]
1371
+ )
1372
+ proc = _run(
1373
+ ["update", "--file", str(target), "--epic", "2", "--set-retro-done",
1374
+ "--set-action-status", payload]
1375
+ )
1376
+ assert proc.returncode == 0, proc.stderr
1377
+ assert _json(proc)["action_items_updated"] == 2
1378
+
1379
+ data = _load(target)
1380
+ assert data["development_status"]["epic-2-retrospective"] == "done"
1381
+ items = data["action_items"]
1382
+ assert items[0]["status"] == "done"
1383
+ assert items[1]["status"] == "done"
1384
+ # Epic 2's own item is not swept along.
1385
+ assert items[2]["status"] == "in-progress"
1386
+
1387
+
1388
+ def test_legacy_selector_discriminates_on_the_epic(tmp_path):
1389
+ # Two items share the action text "Scripted item" and differ only by epic, so
1390
+ # an epic-blind text match would be ambiguous -- or worse, silently pick one.
1391
+ target = _write_action_fixture(tmp_path)
1392
+ proc = _run(
1393
+ ["update", "--file", str(target), "--epic", "1", "--set-action-status",
1394
+ '[{"epic":2,"action":"Scripted item","status":"done"}]']
1395
+ )
1396
+ assert proc.returncode == 0, proc.stderr
1397
+ assert _json(proc)["action_items_updated"] == 1
1398
+ items = _load(target)["action_items"]
1399
+ assert items[2]["status"] == "done"
1400
+ assert items[0]["status"] == "open" # the epic-1 namesake, untouched
1401
+
1402
+
1403
+ def test_non_mapping_action_item_does_not_crash_the_selector(tmp_path):
1404
+ # A hand-edited scalar in the action_items list must be skipped, not
1405
+ # AttributeError'd into an empty stdout with a traceback.
1406
+ fixture = (
1407
+ "development_status:\n"
1408
+ " 1-1-a: done\n"
1409
+ " epic-1-retrospective: optional\n"
1410
+ "action_items:\n"
1411
+ ' - "a bare string someone hand-edited in"\n'
1412
+ ' - id: "real"\n'
1413
+ " epic: 1\n"
1414
+ ' action: "Real item"\n'
1415
+ " status: open\n"
1416
+ )
1417
+ target = tmp_path / "sprint-status.yaml"
1418
+ target.write_text(fixture, encoding="utf-8")
1419
+ proc = _run(
1420
+ ["update", "--file", str(target), "--epic", "1",
1421
+ "--set-action-status", '[{"id":"real","status":"done"}]']
1422
+ )
1423
+ assert proc.returncode == 0, proc.stderr
1424
+ assert _json(proc)["action_items_updated"] == 1
1425
+ items = _load(target)["action_items"]
1426
+ assert items[0] == "a bare string someone hand-edited in"
1427
+ assert items[1]["status"] == "done"
1428
+
1429
+
1430
+ def test_non_mapping_action_item_stays_on_the_json_contract_when_unmatched(tmp_path):
1431
+ # Same guard, reject path: the scalar must not be dereferenced while looking
1432
+ # for a selector that is not there.
1433
+ fixture = (
1434
+ "development_status:\n"
1435
+ " 1-1-a: done\n"
1436
+ "action_items:\n"
1437
+ " - 42\n"
1438
+ )
1439
+ target = tmp_path / "sprint-status.yaml"
1440
+ target.write_text(fixture, encoding="utf-8")
1441
+ proc = _run(
1442
+ ["update", "--file", str(target), "--epic", "1",
1443
+ "--set-action-status", '[{"epic":1,"action":"Nothing","status":"done"}]']
1444
+ )
1445
+ assert proc.returncode == 1
1446
+ out = _json(proc) # asserts stdout is JSON and stderr carries no traceback
1447
+ assert out["ok"] is False
1448
+ assert out["restored"] is True
1449
+ assert "no action item matches" in out["error"]
1450
+ assert target.read_text(encoding="utf-8") == fixture
1451
+
1452
+
1453
+ def test_boolean_epic_in_the_file_does_not_match_epic_one(tmp_path):
1454
+ # True == 1 in Python, so without the file-side bool guard a hand-edited
1455
+ # "epic: true" row would be silently rewritten by a selector aimed at epic 1.
1456
+ fixture = (
1457
+ "development_status:\n"
1458
+ " 1-1-a: done\n"
1459
+ "action_items:\n"
1460
+ " - epic: true\n"
1461
+ ' action: "Boolean epic"\n'
1462
+ " status: open\n"
1463
+ )
1464
+ target = tmp_path / "sprint-status.yaml"
1465
+ target.write_text(fixture, encoding="utf-8")
1466
+ proc = _run(
1467
+ ["update", "--file", str(target), "--epic", "1", "--set-action-status",
1468
+ '[{"epic":1,"action":"Boolean epic","status":"done"}]']
1469
+ )
1470
+ assert proc.returncode == 1
1471
+ out = _json(proc)
1472
+ assert out["ok"] is False
1473
+ assert out["restored"] is True
1474
+ assert "no action item matches" in out["error"]
1475
+ assert target.read_text(encoding="utf-8") == fixture
1476
+
1477
+
1478
+ def test_status_write_preserves_every_scalar_style(tmp_path):
1479
+ # The status write must land on the line without re-styling it, whichever way
1480
+ # the file spells the scalar.
1481
+ target = tmp_path / "sprint-status.yaml"
1482
+ target.write_text(STYLE_FIXTURE, encoding="utf-8")
1483
+ payload = json.dumps(
1484
+ [
1485
+ {"id": "double", "status": "done"},
1486
+ {"id": "single", "status": "done"},
1487
+ {"id": "plain", "status": "done"},
1488
+ ]
1489
+ )
1490
+ proc = _run(
1491
+ ["update", "--file", str(target), "--epic", "1", "--date",
1492
+ "01-01-2026 09:00", "--set-action-status", payload]
1493
+ )
1494
+ assert proc.returncode == 0, proc.stderr
1495
+ assert _json(proc)["action_items_updated"] == 3
1496
+
1497
+ before = STYLE_FIXTURE.splitlines()
1498
+ after = target.read_text(encoding="utf-8").splitlines()
1499
+ assert len(before) == len(after)
1500
+ changed = [(b, a) for b, a in zip(before, after) if b != a]
1501
+ assert changed == [
1502
+ (' status: "open"', ' status: "done"'),
1503
+ (" status: 'open'", " status: 'done'"),
1504
+ (" status: open", " status: done"),
1505
+ ], changed
1506
+
1507
+
1508
+ def test_action_status_vocabulary_is_exactly_the_three():
1509
+ # wizz-sprint-planning is the authority. Widening this tuple would let the
1510
+ # script write a value sprint-planning's status view reports as illegal.
1511
+ assert _module().ACTION_STATUSES == ("open", "in-progress", "done")
1512
+
1513
+
1514
+ def test_empty_status_array_is_a_no_op(tmp_path):
1515
+ target = _write_action_fixture(tmp_path)
1516
+ proc = _run(
1517
+ ["update", "--file", str(target), "--epic", "1", "--date",
1518
+ "01-01-2026 09:00", "--set-action-status", "[]"]
1519
+ )
1520
+ assert proc.returncode == 0, proc.stderr
1521
+ assert _json(proc)["action_items_updated"] == 0
1522
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1523
+
1524
+
1525
+ def test_in_progress_item_transitions_to_done(tmp_path):
1526
+ # Every other success case starts from "open"; the middle of the lifecycle
1527
+ # has to work too.
1528
+ target = _write_action_fixture(tmp_path)
1529
+ proc = _run(
1530
+ ["update", "--file", str(target), "--epic", "2", "--set-action-status",
1531
+ '[{"id":"epic-2-retro-item-1-y","status":"done"}]']
1532
+ )
1533
+ assert proc.returncode == 0, proc.stderr
1534
+ assert _json(proc)["action_items_updated"] == 1
1535
+ assert _load(target)["action_items"][2]["status"] == "done"
1536
+
1537
+
1538
+ def test_action_items_updated_is_always_reported(tmp_path):
1539
+ # Consumers read the counter unconditionally, so it must be present even when
1540
+ # the flag was not passed.
1541
+ target = _write_fixture(tmp_path)
1542
+ proc = _run(["update", "--file", str(target), "--epic", "1", "--set-retro-done"])
1543
+ assert proc.returncode == 0, proc.stderr
1544
+ assert _json(proc)["action_items_updated"] == 0
1545
+
1546
+
1547
+ def test_post_write_status_mismatch_restores(tmp_path, monkeypatch, capsys):
1548
+ # The last line of defence: the written file is re-parsed and every targeted
1549
+ # item is checked. No CLI path can fake a mismatch, so the re-parse is
1550
+ # doctored directly.
1551
+ mod = _module()
1552
+ target = _write_action_fixture(tmp_path)
1553
+ real_load_yaml = mod._load_yaml
1554
+ calls = {"n": 0}
1555
+
1556
+ def flaky(path):
1557
+ yaml, data = real_load_yaml(path)
1558
+ calls["n"] += 1
1559
+ if calls["n"] == 2: # the post-write re-parse
1560
+ data["action_items"][0]["status"] = "open"
1561
+ return yaml, data
1562
+
1563
+ monkeypatch.setattr(mod, "_load_yaml", flaky)
1564
+ args = mod.build_parser().parse_args(
1565
+ ["update", "--file", str(target), "--epic", "1", "--date", "01-01-2026 09:00",
1566
+ "--set-action-status", '[{"id":"epic-1-retro-item-1-x","status":"done"}]']
1567
+ )
1568
+ with pytest.raises(SystemExit) as excinfo:
1569
+ mod.cmd_update(args)
1570
+ assert excinfo.value.code == 1
1571
+ out = json.loads(capsys.readouterr().out)
1572
+ assert out["ok"] is False
1573
+ assert out["restored"] is True
1574
+ assert "after write" in out["error"]
1575
+ assert target.read_text(encoding="utf-8") == ACTION_FIXTURE
1576
+
1577
+
1578
+ if __name__ == "__main__":
1579
+ sys.exit(pytest.main([__file__, "-q"]))