devforgeai 1.0.5 → 1.0.7

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 (133) hide show
  1. package/CLAUDE.md +120 -0
  2. package/bin/devforgeai.js +0 -0
  3. package/package.json +9 -1
  4. package/src/CLAUDE.md +699 -0
  5. package/src/claude/hooks/phase-completion-gate.sh +0 -0
  6. package/src/claude/scripts/README.md +396 -0
  7. package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
  8. package/src/claude/scripts/check-hooks-fast.sh +70 -0
  9. package/src/claude/scripts/devforgeai-validate +6 -0
  10. package/src/claude/scripts/devforgeai_cli/README.md +531 -0
  11. package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
  12. package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
  13. package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
  14. package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
  15. package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
  16. package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
  17. package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
  18. package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
  19. package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
  20. package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
  21. package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
  22. package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
  23. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
  24. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
  25. package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
  26. package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
  27. package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
  28. package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
  29. package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
  30. package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
  31. package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
  32. package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
  33. package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
  34. package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
  35. package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
  36. package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
  37. package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
  38. package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
  39. package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
  40. package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
  41. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
  42. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
  43. package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
  44. package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
  45. package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
  46. package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
  47. package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
  48. package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
  49. package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
  50. package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
  51. package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
  52. package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
  53. package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
  54. package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
  55. package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
  56. package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
  57. package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
  58. package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
  59. package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
  60. package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
  61. package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
  62. package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
  63. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
  64. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
  65. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
  66. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
  67. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
  68. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
  69. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
  70. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
  71. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
  72. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
  73. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
  74. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
  75. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
  76. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
  77. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
  78. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
  79. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
  80. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
  81. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
  82. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
  83. package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
  84. package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
  85. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
  86. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
  87. package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
  88. package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
  89. package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
  90. package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
  91. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
  92. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
  93. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
  94. package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
  95. package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
  96. package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
  97. package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
  98. package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
  99. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
  100. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
  101. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
  102. package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
  103. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
  104. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
  105. package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
  106. package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
  107. package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
  108. package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
  109. package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
  110. package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
  111. package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
  112. package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
  113. package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
  114. package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
  115. package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
  116. package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
  117. package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
  118. package/src/claude/scripts/install_hooks.sh +186 -0
  119. package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
  120. package/src/claude/scripts/migrate-ac-headers.sh +122 -0
  121. package/src/claude/scripts/plan_file_kb.sh +704 -0
  122. package/src/claude/scripts/requirements.txt +8 -0
  123. package/src/claude/scripts/session_catalog.sh +543 -0
  124. package/src/claude/scripts/setup.py +55 -0
  125. package/src/claude/scripts/start-devforgeai.sh +16 -0
  126. package/src/claude/scripts/statusline.sh +27 -0
  127. package/src/claude/scripts/validate_deferrals.py +344 -0
  128. package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
  129. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
  130. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
  131. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
  132. package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
  133. package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
@@ -0,0 +1,2187 @@
1
+ """
2
+ STORY-253: PhaseState Module Tests
3
+
4
+ Test suite for PhaseState class following TDD Red phase principles.
5
+ These tests are designed to FAIL initially because the module doesn't exist yet
6
+ at `.claude/scripts/devforgeai_cli/phase_state.py`.
7
+
8
+ Test Coverage Target: 95%+ for business logic
9
+
10
+ Test Framework: pytest (per tech-stack.md)
11
+ Test Pattern: AAA (Arrange, Act, Assert)
12
+ Test Naming: test_<function>_<scenario>_<expected>
13
+
14
+ Acceptance Criteria Mapping:
15
+ - AC#1: PhaseState class initialization and path resolution
16
+ - AC#2: Create new phase state file with complete schema
17
+ - AC#3: Idempotent state file creation
18
+ - AC#4: Read existing phase state
19
+ - AC#5: Read returns None for non-existent state
20
+ - AC#6: Complete phase with sequential enforcement
21
+ - AC#7: Phase transition validation (sequential order only)
22
+ - AC#8: Record subagent invocation
23
+ - AC#9: Add workflow observation
24
+ - AC#10: Input validation for story ID format
25
+ - AC#11: Input validation for phase ID
26
+ - AC#12: State file path helper method
27
+
28
+ Edge Cases (from story):
29
+ - Corrupted JSON state file
30
+ - Concurrent write protection (platform-aware)
31
+ - Missing workflows directory
32
+ - Duplicate subagent recording
33
+ - Phase 10 completion boundary
34
+ - Empty observation note
35
+ - Invalid observation category
36
+ - Invalid observation severity
37
+ - Atomic file writes
38
+ - Empty state file
39
+ """
40
+
41
+ import json
42
+ import os
43
+ import sys
44
+ import tempfile
45
+ import threading
46
+ import time
47
+ from datetime import datetime, timezone
48
+ from pathlib import Path
49
+ from typing import Generator
50
+ from unittest.mock import MagicMock, patch
51
+
52
+ import pytest
53
+
54
+
55
+ # =============================================================================
56
+ # Test Setup and Fixtures
57
+ # =============================================================================
58
+
59
+
60
+ @pytest.fixture
61
+ def temp_project_root() -> Generator[Path, None, None]:
62
+ """Create a temporary project root directory for testing."""
63
+ with tempfile.TemporaryDirectory() as tmpdir:
64
+ project_root = Path(tmpdir)
65
+ yield project_root
66
+
67
+
68
+ @pytest.fixture
69
+ def phase_state(temp_project_root: Path):
70
+ """Create a PhaseState instance with temporary project root."""
71
+ # Import will fail until module is created - this is expected for TDD Red
72
+ from devforgeai_cli.phase_state import PhaseState
73
+ return PhaseState(project_root=temp_project_root)
74
+
75
+
76
+ @pytest.fixture
77
+ def phase_state_with_existing_file(temp_project_root: Path):
78
+ """Create PhaseState instance with pre-existing state file.
79
+
80
+ STORY-307: Updated to populate subagents_required from PHASE_REQUIRED_SUBAGENTS
81
+ constant, matching STORY-306 subagent enforcement feature.
82
+ """
83
+ from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
84
+
85
+ ps = PhaseState(project_root=temp_project_root)
86
+
87
+ # Create workflows directory and state file
88
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
89
+ workflows_dir.mkdir(parents=True, exist_ok=True)
90
+
91
+ # Build phases dict with populated subagents_required (AC1)
92
+ # Includes decimal phases 4.5 and 5.5 for AC verification
93
+ valid_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
94
+ phases = {}
95
+ for phase in valid_phases:
96
+ # Convert tuples to lists for JSON serialization (Phase 03 OR logic)
97
+ required = []
98
+ for item in PHASE_REQUIRED_SUBAGENTS.get(phase, []):
99
+ if isinstance(item, tuple):
100
+ required.append(list(item)) # OR group as list
101
+ else:
102
+ required.append(item)
103
+
104
+ phases[phase] = {
105
+ "status": "pending",
106
+ "subagents_required": required,
107
+ "subagents_invoked": []
108
+ }
109
+
110
+ # Create a valid state file
111
+ state = {
112
+ "story_id": "STORY-001",
113
+ "current_phase": "01",
114
+ "workflow_started": "2026-01-12T12:00:00Z",
115
+ "blocking_status": False,
116
+ "phases": phases,
117
+ "validation_errors": [],
118
+ "observations": []
119
+ }
120
+
121
+ state_path = workflows_dir / "STORY-001-phase-state.json"
122
+ state_path.write_text(json.dumps(state, indent=2))
123
+
124
+ return ps
125
+
126
+
127
+ @pytest.fixture
128
+ def corrupted_state_file(temp_project_root: Path):
129
+ """Create a corrupted state file for error handling tests."""
130
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
131
+ workflows_dir.mkdir(parents=True, exist_ok=True)
132
+
133
+ state_path = workflows_dir / "STORY-002-phase-state.json"
134
+ state_path.write_text("{invalid json content")
135
+
136
+ return temp_project_root
137
+
138
+
139
+ @pytest.fixture
140
+ def empty_state_file(temp_project_root: Path):
141
+ """Create an empty state file for edge case testing."""
142
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
143
+ workflows_dir.mkdir(parents=True, exist_ok=True)
144
+
145
+ state_path = workflows_dir / "STORY-003-phase-state.json"
146
+ state_path.write_text("")
147
+
148
+ return temp_project_root
149
+
150
+
151
+ # =============================================================================
152
+ # AC#1: PhaseState class initialization and path resolution
153
+ # =============================================================================
154
+
155
+
156
+ class TestAC1Initialization:
157
+ """Tests for AC#1: PhaseState class initialization and path resolution."""
158
+
159
+ def test_init_accepts_project_root_path(self, temp_project_root: Path):
160
+ """
161
+ Given: A project root directory path
162
+ When: PhaseState(project_root=Path("/path/to/project")) is instantiated
163
+ Then: The instance stores the project root
164
+ """
165
+ from devforgeai_cli.phase_state import PhaseState
166
+
167
+ # Arrange
168
+ project_root = temp_project_root
169
+
170
+ # Act
171
+ ps = PhaseState(project_root=project_root)
172
+
173
+ # Assert
174
+ assert ps.project_root == project_root
175
+
176
+ def test_init_resolves_workflows_dir_correctly(self, temp_project_root: Path):
177
+ """
178
+ Given: A project root directory path
179
+ When: PhaseState is instantiated
180
+ Then: workflows_dir resolves to {project_root}/devforgeai/workflows/
181
+ """
182
+ from devforgeai_cli.phase_state import PhaseState
183
+
184
+ # Arrange
185
+ project_root = temp_project_root
186
+ expected_workflows_dir = project_root / "devforgeai" / "workflows"
187
+
188
+ # Act
189
+ ps = PhaseState(project_root=project_root)
190
+
191
+ # Assert
192
+ assert ps.workflows_dir == expected_workflows_dir
193
+
194
+ def test_init_accepts_string_path_converts_to_path(self, temp_project_root: Path):
195
+ """
196
+ Given: A string path to project root
197
+ When: PhaseState is instantiated
198
+ Then: The path is converted to Path object
199
+ """
200
+ from devforgeai_cli.phase_state import PhaseState
201
+
202
+ # Arrange
203
+ project_root_str = str(temp_project_root)
204
+
205
+ # Act
206
+ ps = PhaseState(project_root=Path(project_root_str))
207
+
208
+ # Assert
209
+ assert isinstance(ps.project_root, Path)
210
+
211
+
212
+ # =============================================================================
213
+ # AC#2: Create new phase state file with complete schema
214
+ # =============================================================================
215
+
216
+
217
+ class TestAC2CreateNewState:
218
+ """Tests for AC#2: Create new phase state file with complete schema."""
219
+
220
+ def test_create_creates_json_file_at_correct_path(self, phase_state, temp_project_root: Path):
221
+ """
222
+ Given: A valid story ID
223
+ When: create(story_id="STORY-001") is called
224
+ Then: A JSON file is created at devforgeai/workflows/STORY-001-phase-state.json
225
+ """
226
+ # Arrange
227
+ story_id = "STORY-001"
228
+ expected_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
229
+
230
+ # Act
231
+ phase_state.create(story_id)
232
+
233
+ # Assert
234
+ assert expected_path.exists()
235
+
236
+ def test_create_returns_state_with_story_id(self, phase_state):
237
+ """
238
+ Given: A valid story ID
239
+ When: create() is called
240
+ Then: Returned state contains story_id field
241
+ """
242
+ # Arrange
243
+ story_id = "STORY-001"
244
+
245
+ # Act
246
+ state = phase_state.create(story_id)
247
+
248
+ # Assert
249
+ assert state["story_id"] == story_id
250
+
251
+ def test_create_returns_state_with_current_phase_01(self, phase_state):
252
+ """
253
+ Given: A new state file creation
254
+ When: create() is called
255
+ Then: current_phase is "01"
256
+ """
257
+ # Arrange & Act
258
+ state = phase_state.create("STORY-001")
259
+
260
+ # Assert
261
+ assert state["current_phase"] == "01"
262
+
263
+ def test_create_returns_state_with_workflow_started_timestamp(self, phase_state):
264
+ """
265
+ Given: A new state file creation
266
+ When: create() is called
267
+ Then: workflow_started contains ISO-8601 UTC timestamp
268
+ """
269
+ # Arrange & Act
270
+ state = phase_state.create("STORY-001")
271
+
272
+ # Assert
273
+ assert "workflow_started" in state
274
+ # Validate ISO-8601 format with Z suffix
275
+ assert state["workflow_started"].endswith("Z")
276
+ # Should be parseable as ISO-8601
277
+ datetime.fromisoformat(state["workflow_started"].replace("Z", "+00:00"))
278
+
279
+ def test_create_returns_state_with_blocking_status_false(self, phase_state):
280
+ """
281
+ Given: A new state file creation
282
+ When: create() is called
283
+ Then: blocking_status is false
284
+ """
285
+ # Arrange & Act
286
+ state = phase_state.create("STORY-001")
287
+
288
+ # Assert
289
+ assert state["blocking_status"] is False
290
+
291
+ def test_create_returns_state_with_all_12_phases(self, phase_state):
292
+ """
293
+ Given: A new state file creation
294
+ When: create() is called
295
+ Then: phases object contains all 12 valid phases (01-10 plus 4.5, 5.5)
296
+
297
+ STORY-307: Updated to expect 12 phases including decimal phases for AC verification.
298
+ """
299
+ # Arrange & Act
300
+ state = phase_state.create("STORY-001")
301
+
302
+ # Assert
303
+ expected_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
304
+ assert list(state["phases"].keys()) == expected_phases
305
+
306
+ def test_create_phases_have_status_subagents_required_invoked(self, phase_state):
307
+ """
308
+ Given: A new state file creation
309
+ When: create() is called
310
+ Then: Each phase has status, subagents_required, and subagents_invoked
311
+ """
312
+ # Arrange & Act
313
+ state = phase_state.create("STORY-001")
314
+
315
+ # Assert
316
+ for phase_id in state["phases"]:
317
+ phase = state["phases"][phase_id]
318
+ assert "status" in phase
319
+ assert "subagents_required" in phase
320
+ assert "subagents_invoked" in phase
321
+
322
+ def test_create_returns_state_with_empty_validation_errors(self, phase_state):
323
+ """
324
+ Given: A new state file creation
325
+ When: create() is called
326
+ Then: validation_errors is an empty array
327
+ """
328
+ # Arrange & Act
329
+ state = phase_state.create("STORY-001")
330
+
331
+ # Assert
332
+ assert state["validation_errors"] == []
333
+
334
+ def test_create_returns_state_with_empty_observations(self, phase_state):
335
+ """
336
+ Given: A new state file creation
337
+ When: create() is called
338
+ Then: observations is an empty array
339
+ """
340
+ # Arrange & Act
341
+ state = phase_state.create("STORY-001")
342
+
343
+ # Assert
344
+ assert state["observations"] == []
345
+
346
+ def test_create_creates_directories_if_missing(self, temp_project_root: Path):
347
+ """
348
+ Given: A fresh project without devforgeai/workflows/ directory
349
+ When: create() is called
350
+ Then: Directories are created automatically
351
+ """
352
+ from devforgeai_cli.phase_state import PhaseState
353
+
354
+ # Arrange
355
+ ps = PhaseState(project_root=temp_project_root)
356
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
357
+ assert not workflows_dir.exists()
358
+
359
+ # Act
360
+ ps.create("STORY-001")
361
+
362
+ # Assert
363
+ assert workflows_dir.exists()
364
+
365
+
366
+ # =============================================================================
367
+ # AC#3: Idempotent state file creation
368
+ # =============================================================================
369
+
370
+
371
+ class TestAC3IdempotentCreation:
372
+ """Tests for AC#3: Idempotent state file creation."""
373
+
374
+ def test_create_existing_file_returns_existing_state(
375
+ self, phase_state_with_existing_file, temp_project_root: Path
376
+ ):
377
+ """
378
+ Given: A state file already exists for STORY-001
379
+ When: create(story_id="STORY-001") is called again
380
+ Then: The existing state is returned without modification
381
+ """
382
+ # Arrange
383
+ ps = phase_state_with_existing_file
384
+ original_state = ps.read("STORY-001")
385
+ original_timestamp = original_state["workflow_started"]
386
+
387
+ # Act
388
+ returned_state = ps.create("STORY-001")
389
+
390
+ # Assert
391
+ assert returned_state["workflow_started"] == original_timestamp
392
+
393
+ def test_create_existing_file_does_not_overwrite(
394
+ self, phase_state_with_existing_file, temp_project_root: Path
395
+ ):
396
+ """
397
+ Given: A state file already exists for STORY-001
398
+ When: create() is called again
399
+ Then: The file is not modified (no overwrite)
400
+ """
401
+ # Arrange
402
+ ps = phase_state_with_existing_file
403
+ state_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
404
+ original_mtime = state_path.stat().st_mtime
405
+ time.sleep(0.1) # Ensure time difference
406
+
407
+ # Act
408
+ ps.create("STORY-001")
409
+
410
+ # Assert
411
+ new_mtime = state_path.stat().st_mtime
412
+ assert new_mtime == original_mtime
413
+
414
+ def test_create_idempotent_consecutive_calls_same_state(self, phase_state):
415
+ """
416
+ Given: Multiple consecutive create calls
417
+ When: create() is called twice
418
+ Then: Both return the same state (idempotent)
419
+ """
420
+ # Arrange & Act
421
+ state1 = phase_state.create("STORY-001")
422
+ state2 = phase_state.create("STORY-001")
423
+
424
+ # Assert
425
+ assert state1["story_id"] == state2["story_id"]
426
+ assert state1["workflow_started"] == state2["workflow_started"]
427
+
428
+
429
+ # =============================================================================
430
+ # AC#4: Read existing phase state
431
+ # =============================================================================
432
+
433
+
434
+ class TestAC4ReadExistingState:
435
+ """Tests for AC#4: Read existing phase state."""
436
+
437
+ def test_read_returns_complete_state_dict(self, phase_state_with_existing_file):
438
+ """
439
+ Given: A state file exists for STORY-001
440
+ When: read(story_id="STORY-001") is called
441
+ Then: The method returns the complete state dictionary parsed from JSON
442
+ """
443
+ # Arrange
444
+ ps = phase_state_with_existing_file
445
+
446
+ # Act
447
+ state = ps.read("STORY-001")
448
+
449
+ # Assert
450
+ assert state is not None
451
+ assert isinstance(state, dict)
452
+ assert "story_id" in state
453
+ assert "current_phase" in state
454
+ assert "phases" in state
455
+
456
+ def test_read_returns_correct_story_id(self, phase_state_with_existing_file):
457
+ """
458
+ Given: A state file exists
459
+ When: read() is called
460
+ Then: The returned state has correct story_id
461
+ """
462
+ # Arrange
463
+ ps = phase_state_with_existing_file
464
+
465
+ # Act
466
+ state = ps.read("STORY-001")
467
+
468
+ # Assert
469
+ assert state["story_id"] == "STORY-001"
470
+
471
+
472
+ # =============================================================================
473
+ # AC#5: Read returns None for non-existent state
474
+ # =============================================================================
475
+
476
+
477
+ class TestAC5ReadNonExistentState:
478
+ """Tests for AC#5: Read returns None for non-existent state."""
479
+
480
+ def test_read_non_existent_returns_none(self, phase_state):
481
+ """
482
+ Given: No state file exists for STORY-999
483
+ When: read(story_id="STORY-999") is called
484
+ Then: The method returns None (not an exception)
485
+ """
486
+ # Arrange & Act
487
+ result = phase_state.read("STORY-999")
488
+
489
+ # Assert
490
+ assert result is None
491
+
492
+ def test_read_non_existent_does_not_raise_exception(self, phase_state):
493
+ """
494
+ Given: No state file exists
495
+ When: read() is called for non-existent story
496
+ Then: No exception is raised
497
+ """
498
+ # Arrange & Act & Assert
499
+ try:
500
+ phase_state.read("STORY-999")
501
+ except Exception:
502
+ pytest.fail("read() should not raise exception for non-existent file")
503
+
504
+
505
+ # =============================================================================
506
+ # AC#6: Complete phase with sequential enforcement
507
+ # =============================================================================
508
+
509
+
510
+ class TestAC6CompletePhase:
511
+ """Tests for AC#6: Complete phase with sequential enforcement.
512
+
513
+ STORY-307: Updated to call record_subagent() before complete_phase()
514
+ to satisfy STORY-306 subagent enforcement requirements.
515
+ """
516
+
517
+ def test_complete_phase_updates_status_to_completed(
518
+ self, phase_state_with_existing_file
519
+ ):
520
+ """
521
+ Given: A state file exists with current_phase="01"
522
+ When: complete_phase(story_id, phase="01", checkpoint_passed=True) is called
523
+ Then: Phase "01" status becomes "completed"
524
+ """
525
+ # Arrange
526
+ ps = phase_state_with_existing_file
527
+
528
+ # STORY-307: Record required subagents before completing phase (AC2)
529
+ ps.record_subagent("STORY-001", "01", "git-validator")
530
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
531
+
532
+ # Act
533
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
534
+ state = ps.read("STORY-001")
535
+
536
+ # Assert
537
+ assert state["phases"]["01"]["status"] == "completed"
538
+
539
+ def test_complete_phase_records_completed_at_timestamp(
540
+ self, phase_state_with_existing_file
541
+ ):
542
+ """
543
+ Given: A phase is completed
544
+ When: complete_phase() is called
545
+ Then: completed_at timestamp is recorded
546
+ """
547
+ # Arrange
548
+ ps = phase_state_with_existing_file
549
+
550
+ # STORY-307: Record required subagents before completing phase (AC2)
551
+ ps.record_subagent("STORY-001", "01", "git-validator")
552
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
553
+
554
+ # Act
555
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
556
+ state = ps.read("STORY-001")
557
+
558
+ # Assert
559
+ assert "completed_at" in state["phases"]["01"]
560
+ assert state["phases"]["01"]["completed_at"].endswith("Z")
561
+
562
+ def test_complete_phase_stores_checkpoint_passed(
563
+ self, phase_state_with_existing_file
564
+ ):
565
+ """
566
+ Given: A phase is completed
567
+ When: complete_phase(checkpoint_passed=True) is called
568
+ Then: checkpoint_passed is stored
569
+ """
570
+ # Arrange
571
+ ps = phase_state_with_existing_file
572
+
573
+ # STORY-307: Record required subagents before completing phase (AC2)
574
+ ps.record_subagent("STORY-001", "01", "git-validator")
575
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
576
+
577
+ # Act
578
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
579
+ state = ps.read("STORY-001")
580
+
581
+ # Assert
582
+ assert state["phases"]["01"]["checkpoint_passed"] is True
583
+
584
+ def test_complete_phase_advances_current_phase(
585
+ self, phase_state_with_existing_file
586
+ ):
587
+ """
588
+ Given: Phase "01" is the current phase
589
+ When: complete_phase("01") is called
590
+ Then: current_phase advances to "02"
591
+ """
592
+ # Arrange
593
+ ps = phase_state_with_existing_file
594
+
595
+ # STORY-307: Record required subagents before completing phase (AC2)
596
+ ps.record_subagent("STORY-001", "01", "git-validator")
597
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
598
+
599
+ # Act
600
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
601
+ state = ps.read("STORY-001")
602
+
603
+ # Assert
604
+ assert state["current_phase"] == "02"
605
+
606
+ def test_complete_phase_10_stays_at_10(self, phase_state_with_existing_file):
607
+ """
608
+ Given: Phase "10" is the current phase
609
+ When: complete_phase("10") is called
610
+ Then: current_phase stays at "10" (no phase 11)
611
+ """
612
+ # Arrange
613
+ ps = phase_state_with_existing_file
614
+
615
+ # Complete phases 01-09 first
616
+ state = ps.read("STORY-001")
617
+ for i in range(1, 10):
618
+ phase_id = f"{i:02d}"
619
+ state["current_phase"] = phase_id
620
+ state["phases"][phase_id]["status"] = "pending"
621
+ # Set to phase 10
622
+ state["current_phase"] = "10"
623
+
624
+ # Write modified state directly
625
+ state_path = ps._get_state_path("STORY-001")
626
+ state_path.write_text(json.dumps(state, indent=2))
627
+
628
+ # STORY-307: Record required subagent before completing phase (AC2)
629
+ ps.record_subagent("STORY-001", "10", "dev-result-interpreter")
630
+
631
+ # Act
632
+ ps.complete_phase("STORY-001", "10", checkpoint_passed=True)
633
+ final_state = ps.read("STORY-001")
634
+
635
+ # Assert
636
+ assert final_state["current_phase"] == "10"
637
+ assert final_state["phases"]["10"]["status"] == "completed"
638
+
639
+
640
+ # =============================================================================
641
+ # AC#7: Phase transition validation (sequential order only)
642
+ # =============================================================================
643
+
644
+
645
+ class TestAC7PhaseTransitionValidation:
646
+ """Tests for AC#7: Phase transition validation."""
647
+
648
+ def test_complete_phase_skip_raises_phase_transition_error(
649
+ self, phase_state_with_existing_file
650
+ ):
651
+ """
652
+ Given: A state file exists with current_phase="02"
653
+ When: complete_phase(phase="05") is called (attempting to skip)
654
+ Then: A PhaseTransitionError is raised
655
+ """
656
+ from devforgeai_cli.phase_state import PhaseTransitionError
657
+
658
+ # Arrange
659
+ ps = phase_state_with_existing_file
660
+
661
+ # Set current_phase to "02"
662
+ state = ps.read("STORY-001")
663
+ state["current_phase"] = "02"
664
+ state["phases"]["01"]["status"] = "completed"
665
+ state_path = ps._get_state_path("STORY-001")
666
+ state_path.write_text(json.dumps(state, indent=2))
667
+
668
+ # Act & Assert
669
+ with pytest.raises(PhaseTransitionError) as exc_info:
670
+ ps.complete_phase("STORY-001", "05", checkpoint_passed=True)
671
+
672
+ assert "sequential" in str(exc_info.value).lower()
673
+
674
+ def test_complete_phase_previous_phase_raises_error(
675
+ self, phase_state_with_existing_file
676
+ ):
677
+ """
678
+ Given: current_phase is "03"
679
+ When: complete_phase(phase="01") is called (previous phase)
680
+ Then: A PhaseTransitionError is raised
681
+ """
682
+ from devforgeai_cli.phase_state import PhaseTransitionError
683
+
684
+ # Arrange
685
+ ps = phase_state_with_existing_file
686
+
687
+ # Set current_phase to "03"
688
+ state = ps.read("STORY-001")
689
+ state["current_phase"] = "03"
690
+ state_path = ps._get_state_path("STORY-001")
691
+ state_path.write_text(json.dumps(state, indent=2))
692
+
693
+ # Act & Assert
694
+ with pytest.raises(PhaseTransitionError):
695
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
696
+
697
+ def test_complete_phase_error_message_indicates_sequential(
698
+ self, phase_state_with_existing_file
699
+ ):
700
+ """
701
+ Given: An attempt to skip phases
702
+ When: PhaseTransitionError is raised
703
+ Then: Error message indicates sequential completion required
704
+ """
705
+ from devforgeai_cli.phase_state import PhaseTransitionError
706
+
707
+ # Arrange
708
+ ps = phase_state_with_existing_file
709
+
710
+ # Act & Assert
711
+ with pytest.raises(PhaseTransitionError) as exc_info:
712
+ ps.complete_phase("STORY-001", "05", checkpoint_passed=True)
713
+
714
+ error_message = str(exc_info.value)
715
+ assert "sequential" in error_message.lower() or "order" in error_message.lower()
716
+
717
+
718
+ # =============================================================================
719
+ # AC#8: Record subagent invocation
720
+ # =============================================================================
721
+
722
+
723
+ class TestAC8RecordSubagent:
724
+ """Tests for AC#8: Record subagent invocation."""
725
+
726
+ def test_record_subagent_appends_to_invoked_list(
727
+ self, phase_state_with_existing_file
728
+ ):
729
+ """
730
+ Given: A state file exists for STORY-001
731
+ When: record_subagent(story_id, phase="02", subagent="test-automator") is called
732
+ Then: "test-automator" is appended to phases["02"]["subagents_invoked"]
733
+ """
734
+ # Arrange
735
+ ps = phase_state_with_existing_file
736
+
737
+ # Act
738
+ ps.record_subagent("STORY-001", "02", "test-automator")
739
+ state = ps.read("STORY-001")
740
+
741
+ # Assert
742
+ assert "test-automator" in state["phases"]["02"]["subagents_invoked"]
743
+
744
+ def test_record_subagent_sets_started_at_timestamp(
745
+ self, phase_state_with_existing_file
746
+ ):
747
+ """
748
+ Given: A phase without started_at
749
+ When: record_subagent() is called
750
+ Then: started_at timestamp is recorded
751
+ """
752
+ # Arrange
753
+ ps = phase_state_with_existing_file
754
+
755
+ # Act
756
+ ps.record_subagent("STORY-001", "02", "test-automator")
757
+ state = ps.read("STORY-001")
758
+
759
+ # Assert
760
+ assert "started_at" in state["phases"]["02"]
761
+
762
+ def test_record_subagent_idempotent_no_duplicates(
763
+ self, phase_state_with_existing_file
764
+ ):
765
+ """
766
+ Given: Subagent already recorded for a phase
767
+ When: record_subagent() is called again with same subagent
768
+ Then: Subagent is not duplicated (idempotent)
769
+ """
770
+ # Arrange
771
+ ps = phase_state_with_existing_file
772
+
773
+ # Act
774
+ ps.record_subagent("STORY-001", "02", "test-automator")
775
+ ps.record_subagent("STORY-001", "02", "test-automator")
776
+ state = ps.read("STORY-001")
777
+
778
+ # Assert
779
+ count = state["phases"]["02"]["subagents_invoked"].count("test-automator")
780
+ assert count == 1
781
+
782
+ def test_record_subagent_returns_updated_state(
783
+ self, phase_state_with_existing_file
784
+ ):
785
+ """
786
+ Given: A valid state file
787
+ When: record_subagent() is called
788
+ Then: Returns updated state dictionary
789
+ """
790
+ # Arrange
791
+ ps = phase_state_with_existing_file
792
+
793
+ # Act
794
+ result = ps.record_subagent("STORY-001", "02", "test-automator")
795
+
796
+ # Assert
797
+ assert isinstance(result, dict)
798
+ assert "test-automator" in result["phases"]["02"]["subagents_invoked"]
799
+
800
+ def test_record_subagent_raises_for_nonexistent_state(self, phase_state):
801
+ """
802
+ Given: No state file exists
803
+ When: record_subagent() is called
804
+ Then: Raises FileNotFoundError
805
+ """
806
+ # Arrange & Act & Assert
807
+ with pytest.raises(FileNotFoundError):
808
+ phase_state.record_subagent("STORY-999", "02", "test-automator")
809
+
810
+
811
+ # =============================================================================
812
+ # AC#9: Add workflow observation
813
+ # =============================================================================
814
+
815
+
816
+ class TestAC9AddObservation:
817
+ """Tests for AC#9: Add workflow observation."""
818
+
819
+ def test_add_observation_appends_to_observations_array(
820
+ self, phase_state_with_existing_file
821
+ ):
822
+ """
823
+ Given: A state file exists for STORY-001
824
+ When: add_observation() is called
825
+ Then: An observation object is appended to the observations array
826
+ """
827
+ # Arrange
828
+ ps = phase_state_with_existing_file
829
+
830
+ # Act
831
+ ps.add_observation(
832
+ story_id="STORY-001",
833
+ phase_id="04",
834
+ category="friction",
835
+ note="Test took longer than expected",
836
+ severity="medium"
837
+ )
838
+ state = ps.read("STORY-001")
839
+
840
+ # Assert
841
+ assert len(state["observations"]) == 1
842
+
843
+ def test_add_observation_returns_unique_id_format(
844
+ self, phase_state_with_existing_file
845
+ ):
846
+ """
847
+ Given: An observation is added
848
+ When: add_observation() returns
849
+ Then: Returns ID matching format obs-{phase_id}-{8-char-uuid}
850
+ """
851
+ # Arrange
852
+ ps = phase_state_with_existing_file
853
+
854
+ # Act
855
+ observation_id = ps.add_observation(
856
+ story_id="STORY-001",
857
+ phase_id="04",
858
+ category="friction",
859
+ note="Test observation",
860
+ severity="medium"
861
+ )
862
+
863
+ # Assert
864
+ assert observation_id is not None
865
+ assert observation_id.startswith("obs-04-")
866
+ # 8-char uuid after the prefix
867
+ parts = observation_id.split("-")
868
+ assert len(parts) == 3
869
+ assert len(parts[2]) == 8
870
+
871
+ def test_add_observation_stores_all_fields(
872
+ self, phase_state_with_existing_file
873
+ ):
874
+ """
875
+ Given: An observation is added
876
+ When: add_observation() is called with all parameters
877
+ Then: All fields are stored correctly
878
+ """
879
+ # Arrange
880
+ ps = phase_state_with_existing_file
881
+
882
+ # Act
883
+ ps.add_observation(
884
+ story_id="STORY-001",
885
+ phase_id="04",
886
+ category="friction",
887
+ note="Test took longer than expected",
888
+ severity="medium"
889
+ )
890
+ state = ps.read("STORY-001")
891
+
892
+ # Assert
893
+ obs = state["observations"][0]
894
+ assert "id" in obs
895
+ assert obs["phase"] == "04"
896
+ assert obs["category"] == "friction"
897
+ assert obs["note"] == "Test took longer than expected"
898
+ assert obs["severity"] == "medium"
899
+ assert "timestamp" in obs
900
+
901
+ def test_add_observation_records_timestamp(
902
+ self, phase_state_with_existing_file
903
+ ):
904
+ """
905
+ Given: An observation is added
906
+ When: add_observation() is called
907
+ Then: Timestamp is recorded in ISO-8601 format
908
+ """
909
+ # Arrange
910
+ ps = phase_state_with_existing_file
911
+
912
+ # Act
913
+ ps.add_observation(
914
+ story_id="STORY-001",
915
+ phase_id="04",
916
+ category="friction",
917
+ note="Test observation",
918
+ severity="medium"
919
+ )
920
+ state = ps.read("STORY-001")
921
+
922
+ # Assert
923
+ timestamp = state["observations"][0]["timestamp"]
924
+ assert timestamp.endswith("Z")
925
+
926
+
927
+ # =============================================================================
928
+ # AC#10: Input validation for story ID format
929
+ # =============================================================================
930
+
931
+
932
+ class TestAC10StoryIdValidation:
933
+ """Tests for AC#10: Input validation for story ID format."""
934
+
935
+ def test_create_invalid_story_id_raises_value_error(self, phase_state):
936
+ """
937
+ Given: An invalid story ID "INVALID-ID"
938
+ When: create() is called with this story ID
939
+ Then: A ValueError is raised
940
+ """
941
+ # Arrange & Act & Assert
942
+ with pytest.raises(ValueError):
943
+ phase_state.create("INVALID-ID")
944
+
945
+ def test_create_invalid_story_id_error_message(self, phase_state):
946
+ """
947
+ Given: An invalid story ID
948
+ When: ValueError is raised
949
+ Then: Message contains "Invalid story_id" and pattern example
950
+ """
951
+ # Arrange & Act & Assert
952
+ with pytest.raises(ValueError) as exc_info:
953
+ phase_state.create("INVALID-ID")
954
+
955
+ error_message = str(exc_info.value)
956
+ assert "Invalid story_id" in error_message
957
+ assert "STORY-XXX" in error_message or "STORY-001" in error_message
958
+
959
+ def test_read_invalid_story_id_raises_value_error(self, phase_state):
960
+ """
961
+ Given: An invalid story ID
962
+ When: read() is called
963
+ Then: ValueError is raised (validation before file check)
964
+ """
965
+ # Note: Implementation may validate before checking file existence
966
+ # This test assumes validation happens first
967
+ # If not, read should return None without validation
968
+ pass # Implementation decision: may or may not validate on read
969
+
970
+ @pytest.mark.parametrize("invalid_id", [
971
+ "STORY-1", # Too few digits
972
+ "STORY-01", # Two digits (spec says 3)
973
+ "STORY-0001", # Too many digits
974
+ "story-001", # Lowercase
975
+ "STORY_001", # Underscore instead of dash
976
+ "STORY001", # No dash
977
+ "001-STORY", # Wrong order
978
+ "../STORY-001", # Path traversal attempt
979
+ "STORY-001/../", # Path traversal attempt
980
+ ])
981
+ def test_invalid_story_id_patterns_rejected(self, phase_state, invalid_id: str):
982
+ """
983
+ Given: Various invalid story ID patterns
984
+ When: create() is called
985
+ Then: ValueError is raised for all invalid patterns
986
+ """
987
+ # Arrange & Act & Assert
988
+ with pytest.raises(ValueError):
989
+ phase_state.create(invalid_id)
990
+
991
+ def test_path_traversal_story_id_rejected(self, phase_state):
992
+ """
993
+ Given: A story ID with path traversal attempt
994
+ When: Any method is called
995
+ Then: ValueError is raised (security)
996
+ """
997
+ # Arrange & Act & Assert
998
+ with pytest.raises(ValueError):
999
+ phase_state.create("../etc/passwd")
1000
+
1001
+
1002
+ # =============================================================================
1003
+ # AC#11: Input validation for phase ID
1004
+ # =============================================================================
1005
+
1006
+
1007
+ class TestAC11PhaseIdValidation:
1008
+ """Tests for AC#11: Input validation for phase ID."""
1009
+
1010
+ def test_complete_phase_invalid_phase_raises_phase_not_found(
1011
+ self, phase_state_with_existing_file
1012
+ ):
1013
+ """
1014
+ Given: An invalid phase ID "15"
1015
+ When: complete_phase() is called with this phase ID
1016
+ Then: A PhaseNotFoundError is raised
1017
+ """
1018
+ from devforgeai_cli.phase_state import PhaseNotFoundError
1019
+
1020
+ # Arrange
1021
+ ps = phase_state_with_existing_file
1022
+
1023
+ # Act & Assert
1024
+ with pytest.raises(PhaseNotFoundError):
1025
+ ps.complete_phase("STORY-001", "15", checkpoint_passed=True)
1026
+
1027
+ def test_record_subagent_invalid_phase_raises_phase_not_found(
1028
+ self, phase_state_with_existing_file
1029
+ ):
1030
+ """
1031
+ Given: An invalid phase ID
1032
+ When: record_subagent() is called
1033
+ Then: PhaseNotFoundError is raised
1034
+ """
1035
+ from devforgeai_cli.phase_state import PhaseNotFoundError
1036
+
1037
+ # Arrange
1038
+ ps = phase_state_with_existing_file
1039
+
1040
+ # Act & Assert
1041
+ with pytest.raises(PhaseNotFoundError):
1042
+ ps.record_subagent("STORY-001", "15", "test-automator")
1043
+
1044
+ def test_phase_not_found_error_message_shows_valid_phases(
1045
+ self, phase_state_with_existing_file
1046
+ ):
1047
+ """
1048
+ Given: An invalid phase ID
1049
+ When: PhaseNotFoundError is raised
1050
+ Then: Error message indicates valid phases are "01" through "10"
1051
+ """
1052
+ from devforgeai_cli.phase_state import PhaseNotFoundError
1053
+
1054
+ # Arrange
1055
+ ps = phase_state_with_existing_file
1056
+
1057
+ # Act & Assert
1058
+ with pytest.raises(PhaseNotFoundError) as exc_info:
1059
+ ps.complete_phase("STORY-001", "15", checkpoint_passed=True)
1060
+
1061
+ error_message = str(exc_info.value)
1062
+ assert "01" in error_message or "10" in error_message
1063
+
1064
+ @pytest.mark.parametrize("invalid_phase", [
1065
+ "0", # Below range
1066
+ "00", # Zero phase
1067
+ "11", # Above range
1068
+ "15", # Well above range
1069
+ "1", # Single digit
1070
+ "001", # Three digits
1071
+ "-1", # Negative
1072
+ "one", # Text
1073
+ ])
1074
+ def test_invalid_phase_id_patterns_rejected(
1075
+ self, phase_state_with_existing_file, invalid_phase: str
1076
+ ):
1077
+ """
1078
+ Given: Various invalid phase ID patterns
1079
+ When: complete_phase() is called
1080
+ Then: PhaseNotFoundError is raised
1081
+ """
1082
+ from devforgeai_cli.phase_state import PhaseNotFoundError
1083
+
1084
+ # Arrange
1085
+ ps = phase_state_with_existing_file
1086
+
1087
+ # Act & Assert
1088
+ with pytest.raises(PhaseNotFoundError):
1089
+ ps.complete_phase("STORY-001", invalid_phase, checkpoint_passed=True)
1090
+
1091
+
1092
+ # =============================================================================
1093
+ # AC#12: State file path helper method
1094
+ # =============================================================================
1095
+
1096
+
1097
+ class TestAC12StateFilePath:
1098
+ """Tests for AC#12: State file path helper method."""
1099
+
1100
+ def test_get_state_path_returns_correct_path(self, temp_project_root: Path):
1101
+ """
1102
+ Given: A PhaseState instance
1103
+ When: _get_state_path(story_id="STORY-001") is called
1104
+ Then: Returns Path("{project_root}/devforgeai/workflows/STORY-001-phase-state.json")
1105
+ """
1106
+ from devforgeai_cli.phase_state import PhaseState
1107
+
1108
+ # Arrange
1109
+ ps = PhaseState(project_root=temp_project_root)
1110
+ expected_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
1111
+
1112
+ # Act
1113
+ result = ps._get_state_path("STORY-001")
1114
+
1115
+ # Assert
1116
+ assert result == expected_path
1117
+
1118
+ def test_get_state_path_returns_path_object(self, phase_state):
1119
+ """
1120
+ Given: A PhaseState instance
1121
+ When: _get_state_path() is called
1122
+ Then: Returns a Path object
1123
+ """
1124
+ # Arrange & Act
1125
+ result = phase_state._get_state_path("STORY-001")
1126
+
1127
+ # Assert
1128
+ assert isinstance(result, Path)
1129
+
1130
+
1131
+ # =============================================================================
1132
+ # Edge Case Tests: Corrupted JSON
1133
+ # =============================================================================
1134
+
1135
+
1136
+ class TestEdgeCaseCorruptedJSON:
1137
+ """Tests for edge case: Corrupted JSON state file."""
1138
+
1139
+ def test_read_corrupted_json_raises_state_file_corruption_error(
1140
+ self, corrupted_state_file
1141
+ ):
1142
+ """
1143
+ Given: A corrupted JSON state file
1144
+ When: read() is called
1145
+ Then: StateFileCorruptionError is raised
1146
+ """
1147
+ from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
1148
+
1149
+ # Arrange
1150
+ ps = PhaseState(project_root=corrupted_state_file)
1151
+
1152
+ # Act & Assert
1153
+ with pytest.raises(StateFileCorruptionError):
1154
+ ps.read("STORY-002")
1155
+
1156
+ def test_corruption_error_includes_recovery_message(
1157
+ self, corrupted_state_file
1158
+ ):
1159
+ """
1160
+ Given: A corrupted JSON state file
1161
+ When: StateFileCorruptionError is raised
1162
+ Then: Error message includes recovery instructions
1163
+ """
1164
+ from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
1165
+
1166
+ # Arrange
1167
+ ps = PhaseState(project_root=corrupted_state_file)
1168
+
1169
+ # Act & Assert
1170
+ with pytest.raises(StateFileCorruptionError) as exc_info:
1171
+ ps.read("STORY-002")
1172
+
1173
+ error_message = str(exc_info.value)
1174
+ assert "recovery" in error_message.lower() or "delete" in error_message.lower()
1175
+
1176
+ def test_read_empty_file_raises_state_file_corruption_error(
1177
+ self, empty_state_file
1178
+ ):
1179
+ """
1180
+ Given: An empty state file
1181
+ When: read() is called
1182
+ Then: StateFileCorruptionError is raised (treat as corrupted)
1183
+ """
1184
+ from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
1185
+
1186
+ # Arrange
1187
+ ps = PhaseState(project_root=empty_state_file)
1188
+
1189
+ # Act & Assert
1190
+ with pytest.raises(StateFileCorruptionError):
1191
+ ps.read("STORY-003")
1192
+
1193
+
1194
+ # =============================================================================
1195
+ # Edge Case Tests: Observation Validation
1196
+ # =============================================================================
1197
+
1198
+
1199
+ class TestEdgeCaseObservationValidation:
1200
+ """Tests for edge cases: Observation input validation."""
1201
+
1202
+ def test_add_observation_empty_note_raises_value_error(
1203
+ self, phase_state_with_existing_file
1204
+ ):
1205
+ """
1206
+ Given: An empty observation note
1207
+ When: add_observation() is called
1208
+ Then: ValueError is raised
1209
+ """
1210
+ # Arrange
1211
+ ps = phase_state_with_existing_file
1212
+
1213
+ # Act & Assert
1214
+ with pytest.raises(ValueError) as exc_info:
1215
+ ps.add_observation(
1216
+ story_id="STORY-001",
1217
+ phase_id="04",
1218
+ category="friction",
1219
+ note="",
1220
+ severity="medium"
1221
+ )
1222
+
1223
+ assert "empty" in str(exc_info.value).lower()
1224
+
1225
+ def test_add_observation_whitespace_note_raises_value_error(
1226
+ self, phase_state_with_existing_file
1227
+ ):
1228
+ """
1229
+ Given: A whitespace-only observation note
1230
+ When: add_observation() is called
1231
+ Then: ValueError is raised
1232
+ """
1233
+ # Arrange
1234
+ ps = phase_state_with_existing_file
1235
+
1236
+ # Act & Assert
1237
+ with pytest.raises(ValueError):
1238
+ ps.add_observation(
1239
+ story_id="STORY-001",
1240
+ phase_id="04",
1241
+ category="friction",
1242
+ note=" ",
1243
+ severity="medium"
1244
+ )
1245
+
1246
+ def test_add_observation_invalid_category_raises_value_error(
1247
+ self, phase_state_with_existing_file
1248
+ ):
1249
+ """
1250
+ Given: An invalid observation category
1251
+ When: add_observation() is called
1252
+ Then: ValueError is raised with valid options
1253
+ """
1254
+ # Arrange
1255
+ ps = phase_state_with_existing_file
1256
+
1257
+ # Act & Assert
1258
+ with pytest.raises(ValueError) as exc_info:
1259
+ ps.add_observation(
1260
+ story_id="STORY-001",
1261
+ phase_id="04",
1262
+ category="invalid_category",
1263
+ note="Test observation",
1264
+ severity="medium"
1265
+ )
1266
+
1267
+ error_message = str(exc_info.value)
1268
+ # Should mention valid categories
1269
+ assert "friction" in error_message or "gap" in error_message
1270
+
1271
+ def test_add_observation_invalid_severity_raises_value_error(
1272
+ self, phase_state_with_existing_file
1273
+ ):
1274
+ """
1275
+ Given: An invalid observation severity
1276
+ When: add_observation() is called
1277
+ Then: ValueError is raised with valid options
1278
+ """
1279
+ # Arrange
1280
+ ps = phase_state_with_existing_file
1281
+
1282
+ # Act & Assert
1283
+ with pytest.raises(ValueError) as exc_info:
1284
+ ps.add_observation(
1285
+ story_id="STORY-001",
1286
+ phase_id="04",
1287
+ category="friction",
1288
+ note="Test observation",
1289
+ severity="critical" # Invalid
1290
+ )
1291
+
1292
+ error_message = str(exc_info.value)
1293
+ # Should mention valid severities
1294
+ assert "low" in error_message or "medium" in error_message or "high" in error_message
1295
+
1296
+ @pytest.mark.parametrize("valid_category", [
1297
+ "friction", "gap", "success", "pattern"
1298
+ ])
1299
+ def test_add_observation_valid_categories_accepted(
1300
+ self, phase_state_with_existing_file, valid_category: str
1301
+ ):
1302
+ """
1303
+ Given: A valid observation category
1304
+ When: add_observation() is called
1305
+ Then: No error is raised
1306
+ """
1307
+ # Arrange
1308
+ ps = phase_state_with_existing_file
1309
+
1310
+ # Act
1311
+ result = ps.add_observation(
1312
+ story_id="STORY-001",
1313
+ phase_id="04",
1314
+ category=valid_category,
1315
+ note="Test observation",
1316
+ severity="medium"
1317
+ )
1318
+
1319
+ # Assert
1320
+ assert result is not None
1321
+
1322
+ @pytest.mark.parametrize("valid_severity", ["low", "medium", "high"])
1323
+ def test_add_observation_valid_severities_accepted(
1324
+ self, phase_state_with_existing_file, valid_severity: str
1325
+ ):
1326
+ """
1327
+ Given: A valid observation severity
1328
+ When: add_observation() is called
1329
+ Then: No error is raised
1330
+ """
1331
+ # Arrange
1332
+ ps = phase_state_with_existing_file
1333
+
1334
+ # Act
1335
+ result = ps.add_observation(
1336
+ story_id="STORY-001",
1337
+ phase_id="04",
1338
+ category="friction",
1339
+ note="Test observation",
1340
+ severity=valid_severity
1341
+ )
1342
+
1343
+ # Assert
1344
+ assert result is not None
1345
+
1346
+ def test_add_observation_note_max_length_1000(
1347
+ self, phase_state_with_existing_file
1348
+ ):
1349
+ """
1350
+ Given: A note exceeding 1000 characters
1351
+ When: add_observation() is called
1352
+ Then: ValueError is raised (max 1000 chars)
1353
+ """
1354
+ # Arrange
1355
+ ps = phase_state_with_existing_file
1356
+ long_note = "x" * 1001
1357
+
1358
+ # Act & Assert
1359
+ with pytest.raises(ValueError):
1360
+ ps.add_observation(
1361
+ story_id="STORY-001",
1362
+ phase_id="04",
1363
+ category="friction",
1364
+ note=long_note,
1365
+ severity="medium"
1366
+ )
1367
+
1368
+
1369
+ # =============================================================================
1370
+ # Edge Case Tests: Atomic File Writes
1371
+ # =============================================================================
1372
+
1373
+
1374
+ class TestEdgeCaseAtomicWrites:
1375
+ """Tests for edge case: Atomic file writes."""
1376
+
1377
+ def test_create_uses_atomic_write(self, phase_state, temp_project_root: Path):
1378
+ """
1379
+ Given: A state file being created
1380
+ When: create() is called
1381
+ Then: File is written atomically (temp file + rename)
1382
+ """
1383
+ # This test verifies the behavior, not implementation
1384
+ # We can verify by checking no partial files exist
1385
+
1386
+ # Arrange
1387
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
1388
+
1389
+ # Act
1390
+ phase_state.create("STORY-001")
1391
+
1392
+ # Assert - no .tmp files left behind
1393
+ tmp_files = list(workflows_dir.glob("*.tmp"))
1394
+ assert len(tmp_files) == 0
1395
+
1396
+ def test_complete_phase_uses_atomic_write(
1397
+ self, phase_state_with_existing_file, temp_project_root: Path
1398
+ ):
1399
+ """
1400
+ Given: A phase being completed
1401
+ When: complete_phase() is called
1402
+ Then: File is written atomically
1403
+ """
1404
+ # Arrange
1405
+ ps = phase_state_with_existing_file
1406
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
1407
+
1408
+ # STORY-307: Record required subagents before completing phase (AC2)
1409
+ ps.record_subagent("STORY-001", "01", "git-validator")
1410
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
1411
+
1412
+ # Act
1413
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
1414
+
1415
+ # Assert - no .tmp files left behind
1416
+ tmp_files = list(workflows_dir.glob("*.tmp"))
1417
+ assert len(tmp_files) == 0
1418
+
1419
+
1420
+ # =============================================================================
1421
+ # Edge Case Tests: Platform-Aware File Locking
1422
+ # =============================================================================
1423
+
1424
+
1425
+ class TestEdgeCaseFileLocking:
1426
+ """Tests for edge case: Platform-aware file locking."""
1427
+
1428
+ @pytest.mark.skipif(os.name != 'posix', reason="Unix-only test")
1429
+ def test_unix_uses_fcntl_locking(self, phase_state):
1430
+ """
1431
+ Given: Unix platform (Linux/macOS)
1432
+ When: File operations occur
1433
+ Then: fcntl.flock() is used for locking
1434
+ """
1435
+ # This is an implementation detail test
1436
+ # Verifies fcntl is imported on Unix
1437
+ import importlib.util
1438
+
1439
+ spec = importlib.util.find_spec("fcntl")
1440
+ assert spec is not None, "fcntl should be available on Unix"
1441
+
1442
+ @pytest.mark.skipif(os.name != 'nt', reason="Windows-only test")
1443
+ def test_windows_uses_msvcrt_or_fallback(self, phase_state):
1444
+ """
1445
+ Given: Windows platform
1446
+ When: File operations occur
1447
+ Then: msvcrt.locking() or last-write-wins is used
1448
+ """
1449
+ # This is an implementation detail test
1450
+ # Verifies appropriate handling on Windows
1451
+ import importlib.util
1452
+
1453
+ # msvcrt should be available on Windows
1454
+ spec = importlib.util.find_spec("msvcrt")
1455
+ assert spec is not None, "msvcrt should be available on Windows"
1456
+
1457
+ def test_lock_timeout_raises_lock_timeout_error(
1458
+ self, phase_state_with_existing_file
1459
+ ):
1460
+ """
1461
+ Given: A file lock held by another process
1462
+ When: Lock acquisition times out (>5 seconds)
1463
+ Then: LockTimeoutError is raised
1464
+ """
1465
+ from devforgeai_cli.phase_state import LockTimeoutError
1466
+
1467
+ # This test is complex to implement in unit tests
1468
+ # Would require multiprocessing or threading
1469
+ # Marking as placeholder for integration testing
1470
+ pass
1471
+
1472
+ def test_concurrent_writes_dont_corrupt_file(
1473
+ self, phase_state_with_existing_file
1474
+ ):
1475
+ """
1476
+ Given: Multiple concurrent write attempts
1477
+ When: record_subagent() called from multiple threads
1478
+ Then: File is not corrupted
1479
+ """
1480
+ # Arrange
1481
+ ps = phase_state_with_existing_file
1482
+ errors = []
1483
+
1484
+ def record_subagent(subagent_name: str):
1485
+ try:
1486
+ ps.record_subagent("STORY-001", "02", subagent_name)
1487
+ except Exception as e:
1488
+ errors.append(e)
1489
+
1490
+ # Act
1491
+ threads = []
1492
+ for i in range(10):
1493
+ t = threading.Thread(
1494
+ target=record_subagent,
1495
+ args=(f"subagent-{i}",)
1496
+ )
1497
+ threads.append(t)
1498
+ t.start()
1499
+
1500
+ for t in threads:
1501
+ t.join()
1502
+
1503
+ # Assert
1504
+ assert len(errors) == 0, f"Errors during concurrent writes: {errors}"
1505
+
1506
+ # Verify file is valid JSON
1507
+ state = ps.read("STORY-001")
1508
+ assert state is not None
1509
+
1510
+
1511
+ # =============================================================================
1512
+ # Custom Exception Tests
1513
+ # =============================================================================
1514
+
1515
+
1516
+ class TestCustomExceptions:
1517
+ """Tests for custom exception classes."""
1518
+
1519
+ def test_phase_state_error_is_base_exception(self):
1520
+ """PhaseStateError should be base for all phase state exceptions."""
1521
+ from devforgeai_cli.phase_state import (
1522
+ PhaseStateError,
1523
+ PhaseNotFoundError,
1524
+ StateFileCorruptionError,
1525
+ PhaseTransitionError,
1526
+ LockTimeoutError
1527
+ )
1528
+
1529
+ assert issubclass(PhaseNotFoundError, PhaseStateError)
1530
+ assert issubclass(StateFileCorruptionError, PhaseStateError)
1531
+ assert issubclass(PhaseTransitionError, PhaseStateError)
1532
+ assert issubclass(LockTimeoutError, PhaseStateError)
1533
+
1534
+ def test_phase_not_found_error_stores_phase_id(self):
1535
+ """PhaseNotFoundError should store the invalid phase_id."""
1536
+ from devforgeai_cli.phase_state import PhaseNotFoundError
1537
+
1538
+ error = PhaseNotFoundError("15")
1539
+ assert error.phase_id == "15"
1540
+
1541
+ def test_state_file_corruption_error_stores_story_id(self):
1542
+ """StateFileCorruptionError should store story_id and original error."""
1543
+ from devforgeai_cli.phase_state import StateFileCorruptionError
1544
+
1545
+ error = StateFileCorruptionError("STORY-001", "JSON decode error")
1546
+ assert error.story_id == "STORY-001"
1547
+ assert error.original_error == "JSON decode error"
1548
+
1549
+ def test_phase_transition_error_stores_phases(self):
1550
+ """PhaseTransitionError should store current and attempted phases."""
1551
+ from devforgeai_cli.phase_state import PhaseTransitionError
1552
+
1553
+ error = PhaseTransitionError("STORY-001", "02", "05")
1554
+ assert error.story_id == "STORY-001"
1555
+ assert error.current_phase == "02"
1556
+ assert error.attempted_phase == "05"
1557
+
1558
+ def test_lock_timeout_error_stores_path_and_timeout(self):
1559
+ """LockTimeoutError should store file path and timeout."""
1560
+ from devforgeai_cli.phase_state import LockTimeoutError
1561
+
1562
+ error = LockTimeoutError("/path/to/file.json", 5)
1563
+ assert error.file_path == "/path/to/file.json"
1564
+ assert error.timeout == 5
1565
+
1566
+
1567
+ # =============================================================================
1568
+ # Performance Tests (NFR-001, NFR-002)
1569
+ # =============================================================================
1570
+
1571
+
1572
+ class TestPerformance:
1573
+ """Tests for non-functional requirements: Performance."""
1574
+
1575
+ @pytest.mark.slow
1576
+ def test_read_latency_under_10ms(self, phase_state_with_existing_file):
1577
+ """
1578
+ NFR-001: State file read latency < 10ms per read() operation (p99)
1579
+ """
1580
+ # Arrange
1581
+ ps = phase_state_with_existing_file
1582
+
1583
+ # Act - perform 100 reads
1584
+ times = []
1585
+ for _ in range(100):
1586
+ start = time.perf_counter()
1587
+ ps.read("STORY-001")
1588
+ end = time.perf_counter()
1589
+ times.append((end - start) * 1000) # Convert to ms
1590
+
1591
+ # Assert - p99 should be < 10ms
1592
+ times.sort()
1593
+ p99_index = int(len(times) * 0.99)
1594
+ p99_time = times[p99_index]
1595
+
1596
+ assert p99_time < 10, f"p99 read latency {p99_time:.2f}ms exceeds 10ms threshold"
1597
+
1598
+ @pytest.mark.slow
1599
+ def test_1000_reads_complete_in_10_seconds(
1600
+ self, phase_state_with_existing_file
1601
+ ):
1602
+ """
1603
+ NFR-001: 1000 consecutive reads complete in < 10 seconds
1604
+ """
1605
+ # Arrange
1606
+ ps = phase_state_with_existing_file
1607
+
1608
+ # Act
1609
+ start = time.perf_counter()
1610
+ for _ in range(1000):
1611
+ ps.read("STORY-001")
1612
+ end = time.perf_counter()
1613
+
1614
+ # Assert
1615
+ elapsed = end - start
1616
+ assert elapsed < 10, f"1000 reads took {elapsed:.2f}s (expected < 10s)"
1617
+
1618
+ @pytest.mark.slow
1619
+ def test_100_writes_complete_in_5_seconds(self, temp_project_root: Path):
1620
+ """
1621
+ NFR-002: 100 consecutive writes complete in < 5 seconds
1622
+ """
1623
+ from devforgeai_cli.phase_state import PhaseState
1624
+
1625
+ # Arrange
1626
+ ps = PhaseState(project_root=temp_project_root)
1627
+
1628
+ # Act
1629
+ start = time.perf_counter()
1630
+ for i in range(100):
1631
+ story_id = f"STORY-{i:03d}"
1632
+ ps.create(story_id)
1633
+ end = time.perf_counter()
1634
+
1635
+ # Assert
1636
+ elapsed = end - start
1637
+ assert elapsed < 5, f"100 writes took {elapsed:.2f}s (expected < 5s)"
1638
+
1639
+
1640
+ # =============================================================================
1641
+ # Security Tests (NFR-005)
1642
+ # =============================================================================
1643
+
1644
+
1645
+ class TestSecurity:
1646
+ """Tests for non-functional requirements: Security."""
1647
+
1648
+ @pytest.mark.parametrize("malicious_id", [
1649
+ "../etc/passwd",
1650
+ "STORY-001/../../../etc/passwd",
1651
+ "..\\windows\\system32\\config\\sam",
1652
+ "STORY-001/../../sensitive",
1653
+ "STORY-001\x00.json", # Null byte injection
1654
+ ])
1655
+ def test_path_traversal_prevention(self, phase_state, malicious_id: str):
1656
+ """
1657
+ NFR-005: Path traversal prevention - 0 successful traversal attempts
1658
+ """
1659
+ # Arrange & Act & Assert
1660
+ with pytest.raises(ValueError):
1661
+ phase_state.create(malicious_id)
1662
+
1663
+ def test_state_file_permissions(self, phase_state, temp_project_root: Path):
1664
+ """
1665
+ State files should have appropriate permissions (0644)
1666
+ """
1667
+ # Arrange
1668
+ phase_state.create("STORY-001")
1669
+ state_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
1670
+
1671
+ # Act
1672
+ mode = os.stat(state_path).st_mode & 0o777
1673
+
1674
+ # Assert - should be readable by owner and group, not world-writable
1675
+ # 0o644 = owner rw, group r, other r
1676
+ # Accept 0o664 or 0o644 depending on umask
1677
+ assert mode in [0o644, 0o664, 0o666], f"File permissions {oct(mode)} not secure"
1678
+
1679
+
1680
+ # =============================================================================
1681
+ # Scalability Tests (NFR-006)
1682
+ # =============================================================================
1683
+
1684
+
1685
+ class TestScalability:
1686
+ """Tests for non-functional requirements: Scalability."""
1687
+
1688
+ @pytest.mark.slow
1689
+ def test_100_concurrent_story_state_files(self, temp_project_root: Path):
1690
+ """
1691
+ NFR-006: Support 100+ concurrent story state files
1692
+ """
1693
+ from devforgeai_cli.phase_state import PhaseState
1694
+
1695
+ # Arrange
1696
+ ps = PhaseState(project_root=temp_project_root)
1697
+
1698
+ # Act - create 100 state files
1699
+ for i in range(100):
1700
+ story_id = f"STORY-{i:03d}"
1701
+ ps.create(story_id)
1702
+
1703
+ # Assert - all files exist and are readable
1704
+ for i in range(100):
1705
+ story_id = f"STORY-{i:03d}"
1706
+ state = ps.read(story_id)
1707
+ assert state is not None
1708
+ assert state["story_id"] == story_id
1709
+
1710
+
1711
+ # =============================================================================
1712
+ # Integration Tests: Full Workflow
1713
+ # =============================================================================
1714
+
1715
+
1716
+ class TestIntegrationFullWorkflow:
1717
+ """Integration tests for complete workflow scenarios.
1718
+
1719
+ STORY-307: Updated to handle all 12 phases (including 4.5, 5.5) and
1720
+ record required subagents from PHASE_REQUIRED_SUBAGENTS constant.
1721
+ """
1722
+
1723
+ def test_complete_workflow_from_creation_to_phase_10(
1724
+ self, temp_project_root: Path
1725
+ ):
1726
+ """
1727
+ Integration test: Create state, record subagents, complete all phases
1728
+
1729
+ STORY-307: Updated to complete all 12 phases with proper subagent recording.
1730
+ """
1731
+ from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
1732
+
1733
+ # Arrange
1734
+ ps = PhaseState(project_root=temp_project_root)
1735
+
1736
+ # Valid phases in order (includes decimal phases)
1737
+ valid_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
1738
+
1739
+ # Act
1740
+ # 1. Create state
1741
+ state = ps.create("STORY-001")
1742
+ assert state["current_phase"] == "01"
1743
+
1744
+ # 2. Complete all 12 phases
1745
+ for phase_id in valid_phases:
1746
+ # Record required subagents for this phase (AC2)
1747
+ required = PHASE_REQUIRED_SUBAGENTS.get(phase_id, [])
1748
+ for item in required:
1749
+ if isinstance(item, tuple):
1750
+ # OR logic: record first option (backend-architect for Phase 03)
1751
+ ps.record_subagent("STORY-001", phase_id, item[0])
1752
+ else:
1753
+ ps.record_subagent("STORY-001", phase_id, item)
1754
+
1755
+ # Add an observation
1756
+ ps.add_observation(
1757
+ story_id="STORY-001",
1758
+ phase_id=phase_id,
1759
+ category="success",
1760
+ note=f"Completed phase {phase_id}",
1761
+ severity="low"
1762
+ )
1763
+
1764
+ # Complete the phase
1765
+ ps.complete_phase("STORY-001", phase_id, checkpoint_passed=True)
1766
+
1767
+ # 3. Verify final state
1768
+ final_state = ps.read("STORY-001")
1769
+
1770
+ # Assert
1771
+ assert final_state["current_phase"] == "10"
1772
+ for phase_id in valid_phases:
1773
+ assert final_state["phases"][phase_id]["status"] == "completed"
1774
+
1775
+ assert len(final_state["observations"]) == 12 # Updated: 12 phases now
1776
+
1777
+
1778
+ # =============================================================================
1779
+ # STORY-307: SubagentEnforcementError Tests (AC3)
1780
+ # =============================================================================
1781
+
1782
+
1783
+ class TestSubagentEnforcementError:
1784
+ """Tests for AC3: SubagentEnforcementError exception behavior.
1785
+
1786
+ STORY-307: Tests for the new exception added in STORY-306.
1787
+ """
1788
+
1789
+ def test_subagent_enforcement_error_inherits_phase_state_error(self):
1790
+ """SubagentEnforcementError should inherit from PhaseStateError."""
1791
+ from devforgeai_cli.phase_state import SubagentEnforcementError, PhaseStateError
1792
+
1793
+ assert issubclass(SubagentEnforcementError, PhaseStateError)
1794
+
1795
+ def test_subagent_enforcement_error_stores_story_id(self):
1796
+ """SubagentEnforcementError should store story_id attribute."""
1797
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1798
+
1799
+ error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
1800
+ assert error.story_id == "STORY-001"
1801
+
1802
+ def test_subagent_enforcement_error_stores_phase(self):
1803
+ """SubagentEnforcementError should store phase attribute."""
1804
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1805
+
1806
+ error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
1807
+ assert error.phase == "02"
1808
+
1809
+ def test_subagent_enforcement_error_stores_missing_subagents(self):
1810
+ """SubagentEnforcementError should store missing_subagents attribute."""
1811
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1812
+
1813
+ error = SubagentEnforcementError("STORY-001", "04", ["refactoring-specialist", "code-reviewer"])
1814
+ assert error.missing_subagents == ["refactoring-specialist", "code-reviewer"]
1815
+
1816
+ def test_subagent_enforcement_error_message_contains_phase(self):
1817
+ """Error message should contain phase identifier."""
1818
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1819
+
1820
+ error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
1821
+ assert "02" in str(error)
1822
+
1823
+ def test_subagent_enforcement_error_message_contains_missing_subagents(self):
1824
+ """Error message should list missing subagent names."""
1825
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1826
+
1827
+ error = SubagentEnforcementError("STORY-001", "04", ["refactoring-specialist"])
1828
+ assert "refactoring-specialist" in str(error)
1829
+
1830
+ def test_complete_phase_raises_enforcement_error_when_missing(
1831
+ self, phase_state_with_existing_file
1832
+ ):
1833
+ """complete_phase should raise SubagentEnforcementError when subagents missing."""
1834
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1835
+
1836
+ ps = phase_state_with_existing_file
1837
+ # Don't record any subagents
1838
+
1839
+ with pytest.raises(SubagentEnforcementError) as exc_info:
1840
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
1841
+
1842
+ assert exc_info.value.story_id == "STORY-001"
1843
+ assert exc_info.value.phase == "01"
1844
+ assert "git-validator" in exc_info.value.missing_subagents
1845
+
1846
+
1847
+ # =============================================================================
1848
+ # STORY-307: PHASE_REQUIRED_SUBAGENTS Constant Tests (AC4)
1849
+ # =============================================================================
1850
+
1851
+
1852
+ class TestPHASE_REQUIRED_SUBAGENTS:
1853
+ """Tests for AC4: PHASE_REQUIRED_SUBAGENTS constant structure validation.
1854
+
1855
+ STORY-307: Validates the constant structure added in STORY-306.
1856
+ """
1857
+
1858
+ def test_constant_exists_in_module(self):
1859
+ """PHASE_REQUIRED_SUBAGENTS constant should exist."""
1860
+ from devforgeai_cli import phase_state
1861
+
1862
+ assert hasattr(phase_state, 'PHASE_REQUIRED_SUBAGENTS')
1863
+
1864
+ def test_constant_contains_all_12_phases(self):
1865
+ """Constant should have entries for all 12 valid phases."""
1866
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1867
+
1868
+ expected = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
1869
+ for phase in expected:
1870
+ assert phase in PHASE_REQUIRED_SUBAGENTS, f"Missing phase {phase}"
1871
+
1872
+ def test_phase_03_uses_tuple_for_or_logic(self):
1873
+ """Phase 03 should use tuple for OR logic (backend/frontend)."""
1874
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1875
+
1876
+ phase_03 = PHASE_REQUIRED_SUBAGENTS["03"]
1877
+ has_tuple = any(isinstance(item, tuple) for item in phase_03)
1878
+ assert has_tuple, "Phase 03 should have tuple for OR logic"
1879
+
1880
+ def test_phase_03_tuple_contains_backend_and_frontend(self):
1881
+ """Phase 03 tuple should contain both backend-architect and frontend-developer."""
1882
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1883
+
1884
+ phase_03 = PHASE_REQUIRED_SUBAGENTS["03"]
1885
+ for item in phase_03:
1886
+ if isinstance(item, tuple):
1887
+ assert "backend-architect" in item
1888
+ assert "frontend-developer" in item
1889
+ break
1890
+ else:
1891
+ pytest.fail("No tuple found in Phase 03 requirements")
1892
+
1893
+ def test_phase_09_includes_framework_analyst(self):
1894
+ """Phase 09 should require framework-analyst (RCA-027 fix)."""
1895
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1896
+
1897
+ assert "framework-analyst" in PHASE_REQUIRED_SUBAGENTS["09"]
1898
+
1899
+ def test_phase_01_requires_git_and_tech_stack(self):
1900
+ """Phase 01 should require git-validator and tech-stack-detector."""
1901
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1902
+
1903
+ assert "git-validator" in PHASE_REQUIRED_SUBAGENTS["01"]
1904
+ assert "tech-stack-detector" in PHASE_REQUIRED_SUBAGENTS["01"]
1905
+
1906
+ def test_phase_02_requires_test_automator(self):
1907
+ """Phase 02 should require test-automator."""
1908
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1909
+
1910
+ assert "test-automator" in PHASE_REQUIRED_SUBAGENTS["02"]
1911
+
1912
+ def test_phase_06_07_08_empty_requirements(self):
1913
+ """Phases 06, 07, 08 should have empty requirements (conditional/file/git ops)."""
1914
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1915
+
1916
+ assert PHASE_REQUIRED_SUBAGENTS["06"] == []
1917
+ assert PHASE_REQUIRED_SUBAGENTS["07"] == []
1918
+ assert PHASE_REQUIRED_SUBAGENTS["08"] == []
1919
+
1920
+ def test_ac_verification_phases_require_verifier(self):
1921
+ """Phases 4.5 and 5.5 should require ac-compliance-verifier."""
1922
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
1923
+
1924
+ assert "ac-compliance-verifier" in PHASE_REQUIRED_SUBAGENTS["4.5"]
1925
+ assert "ac-compliance-verifier" in PHASE_REQUIRED_SUBAGENTS["5.5"]
1926
+
1927
+
1928
+ # =============================================================================
1929
+ # STORY-307: OR Logic Phase 03 Tests (AC5)
1930
+ # =============================================================================
1931
+
1932
+
1933
+ class TestORLogicPhase03:
1934
+ """Tests for AC5: OR logic for Phase 03 subagent requirements.
1935
+
1936
+ STORY-307: Validates that either backend-architect OR frontend-developer
1937
+ satisfies Phase 03 architect requirement.
1938
+ """
1939
+
1940
+ def _advance_to_phase_03(self, ps):
1941
+ """Helper to advance state to Phase 03."""
1942
+ # Complete Phase 01
1943
+ ps.record_subagent("STORY-001", "01", "git-validator")
1944
+ ps.record_subagent("STORY-001", "01", "tech-stack-detector")
1945
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
1946
+
1947
+ # Complete Phase 02
1948
+ ps.record_subagent("STORY-001", "02", "test-automator")
1949
+ ps.complete_phase("STORY-001", "02", checkpoint_passed=True)
1950
+
1951
+ def test_phase03_succeeds_with_backend_architect_only(
1952
+ self, phase_state_with_existing_file
1953
+ ):
1954
+ """Phase 03 should complete with only backend-architect."""
1955
+ ps = phase_state_with_existing_file
1956
+ self._advance_to_phase_03(ps)
1957
+
1958
+ # Phase 03: backend-architect only (no frontend-developer)
1959
+ ps.record_subagent("STORY-001", "03", "backend-architect")
1960
+ ps.record_subagent("STORY-001", "03", "context-validator")
1961
+
1962
+ # Should succeed without error
1963
+ ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
1964
+ state = ps.read("STORY-001")
1965
+ assert state["phases"]["03"]["status"] == "completed"
1966
+
1967
+ def test_phase03_succeeds_with_frontend_developer_only(
1968
+ self, phase_state_with_existing_file
1969
+ ):
1970
+ """Phase 03 should complete with only frontend-developer."""
1971
+ ps = phase_state_with_existing_file
1972
+ self._advance_to_phase_03(ps)
1973
+
1974
+ # Phase 03: frontend-developer only (no backend-architect)
1975
+ ps.record_subagent("STORY-001", "03", "frontend-developer")
1976
+ ps.record_subagent("STORY-001", "03", "context-validator")
1977
+
1978
+ # Should succeed without error
1979
+ ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
1980
+ state = ps.read("STORY-001")
1981
+ assert state["phases"]["03"]["status"] == "completed"
1982
+
1983
+ def test_phase03_fails_with_neither_architect_subagent(
1984
+ self, phase_state_with_existing_file
1985
+ ):
1986
+ """Phase 03 should fail when neither backend/frontend invoked."""
1987
+ from devforgeai_cli.phase_state import SubagentEnforcementError
1988
+
1989
+ ps = phase_state_with_existing_file
1990
+ self._advance_to_phase_03(ps)
1991
+
1992
+ # Phase 03: only context-validator (missing architect)
1993
+ ps.record_subagent("STORY-001", "03", "context-validator")
1994
+
1995
+ with pytest.raises(SubagentEnforcementError):
1996
+ ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
1997
+
1998
+ def test_phase03_succeeds_with_both_architect_subagents(
1999
+ self, phase_state_with_existing_file
2000
+ ):
2001
+ """Phase 03 should complete when both architects invoked (over-satisfaction)."""
2002
+ ps = phase_state_with_existing_file
2003
+ self._advance_to_phase_03(ps)
2004
+
2005
+ # Phase 03: both architects (over-satisfied)
2006
+ ps.record_subagent("STORY-001", "03", "backend-architect")
2007
+ ps.record_subagent("STORY-001", "03", "frontend-developer")
2008
+ ps.record_subagent("STORY-001", "03", "context-validator")
2009
+
2010
+ # Should succeed without error
2011
+ ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
2012
+ state = ps.read("STORY-001")
2013
+ assert state["phases"]["03"]["status"] == "completed"
2014
+
2015
+ def test_phase03_fails_missing_context_validator(
2016
+ self, phase_state_with_existing_file
2017
+ ):
2018
+ """Phase 03 should fail when context-validator missing (even with architect)."""
2019
+ from devforgeai_cli.phase_state import SubagentEnforcementError
2020
+
2021
+ ps = phase_state_with_existing_file
2022
+ self._advance_to_phase_03(ps)
2023
+
2024
+ # Phase 03: architect only (missing context-validator)
2025
+ ps.record_subagent("STORY-001", "03", "backend-architect")
2026
+
2027
+ with pytest.raises(SubagentEnforcementError) as exc_info:
2028
+ ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
2029
+
2030
+ assert "context-validator" in str(exc_info.value)
2031
+
2032
+
2033
+ # =============================================================================
2034
+ # STORY-307: Escape Hatch Tests (AC6)
2035
+ # =============================================================================
2036
+
2037
+
2038
+ class TestEscapeHatch:
2039
+ """Tests for AC6: Escape hatch bypasses subagent enforcement.
2040
+
2041
+ STORY-307: Validates that checkpoint_passed=False bypasses
2042
+ subagent enforcement for emergency situations.
2043
+ """
2044
+
2045
+ def test_escape_hatch_bypasses_enforcement(
2046
+ self, phase_state_with_existing_file
2047
+ ):
2048
+ """checkpoint_passed=False should bypass subagent enforcement."""
2049
+ ps = phase_state_with_existing_file
2050
+
2051
+ # Do NOT record required subagents
2052
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
2053
+
2054
+ state = ps.read("STORY-001")
2055
+ assert state["phases"]["01"]["status"] == "completed"
2056
+ assert state["phases"]["01"]["checkpoint_passed"] is False
2057
+
2058
+ def test_escape_hatch_no_subagent_enforcement_error(
2059
+ self, phase_state_with_existing_file
2060
+ ):
2061
+ """checkpoint_passed=False should not raise SubagentEnforcementError."""
2062
+ from devforgeai_cli.phase_state import SubagentEnforcementError
2063
+
2064
+ ps = phase_state_with_existing_file
2065
+
2066
+ # Should NOT raise even without required subagents
2067
+ try:
2068
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
2069
+ except SubagentEnforcementError:
2070
+ pytest.fail("Escape hatch should bypass SubagentEnforcementError")
2071
+
2072
+ def test_escape_hatch_advances_current_phase(
2073
+ self, phase_state_with_existing_file
2074
+ ):
2075
+ """Escape hatch should still advance current_phase."""
2076
+ ps = phase_state_with_existing_file
2077
+
2078
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
2079
+
2080
+ state = ps.read("STORY-001")
2081
+ assert state["current_phase"] == "02"
2082
+
2083
+ def test_escape_hatch_records_completed_at(
2084
+ self, phase_state_with_existing_file
2085
+ ):
2086
+ """Escape hatch should still record completed_at timestamp."""
2087
+ ps = phase_state_with_existing_file
2088
+
2089
+ ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
2090
+
2091
+ state = ps.read("STORY-001")
2092
+ assert "completed_at" in state["phases"]["01"]
2093
+
2094
+
2095
+ # =============================================================================
2096
+ # STORY-307: Backward Compatibility Tests (AC7)
2097
+ # =============================================================================
2098
+
2099
+
2100
+ class TestBackwardCompatibility:
2101
+ """Tests for AC7: Legacy state file migration.
2102
+
2103
+ STORY-307: Validates that legacy state files with empty subagents_required
2104
+ arrays are populated from PHASE_REQUIRED_SUBAGENTS on read.
2105
+ """
2106
+
2107
+ def test_legacy_empty_arrays_populated_on_read(self, temp_project_root: Path):
2108
+ """Legacy state with empty subagents_required should be populated."""
2109
+ from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
2110
+
2111
+ ps = PhaseState(project_root=temp_project_root)
2112
+
2113
+ # Create legacy state with empty arrays
2114
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
2115
+ workflows_dir.mkdir(parents=True)
2116
+ legacy_state = {
2117
+ "story_id": "STORY-001",
2118
+ "current_phase": "01",
2119
+ "workflow_started": "2026-01-24T00:00:00Z",
2120
+ "blocking_status": False,
2121
+ "phases": {
2122
+ f"{i:02d}": {
2123
+ "status": "pending",
2124
+ "subagents_required": [], # Legacy: empty
2125
+ "subagents_invoked": []
2126
+ } for i in range(1, 11)
2127
+ },
2128
+ "validation_errors": [],
2129
+ "observations": []
2130
+ }
2131
+ (workflows_dir / "STORY-001-phase-state.json").write_text(json.dumps(legacy_state))
2132
+
2133
+ # Read should populate subagents_required
2134
+ state = ps.read("STORY-001")
2135
+
2136
+ # Phase 01 should now have populated subagents_required
2137
+ assert state["phases"]["01"]["subagents_required"] != []
2138
+ assert "git-validator" in state["phases"]["01"]["subagents_required"]
2139
+
2140
+ def test_legacy_missing_decimal_phases_added(self, temp_project_root: Path):
2141
+ """Legacy state missing 4.5/5.5 phases should have them added."""
2142
+ from devforgeai_cli.phase_state import PhaseState
2143
+
2144
+ ps = PhaseState(project_root=temp_project_root)
2145
+
2146
+ # Create legacy state without decimal phases
2147
+ workflows_dir = temp_project_root / "devforgeai" / "workflows"
2148
+ workflows_dir.mkdir(parents=True)
2149
+ legacy_state = {
2150
+ "story_id": "STORY-001",
2151
+ "current_phase": "01",
2152
+ "workflow_started": "2026-01-24T00:00:00Z",
2153
+ "blocking_status": False,
2154
+ "phases": {
2155
+ f"{i:02d}": {
2156
+ "status": "pending",
2157
+ "subagents_required": [],
2158
+ "subagents_invoked": []
2159
+ } for i in range(1, 11)
2160
+ },
2161
+ "validation_errors": [],
2162
+ "observations": []
2163
+ }
2164
+ (workflows_dir / "STORY-001-phase-state.json").write_text(json.dumps(legacy_state))
2165
+
2166
+ # Read should add missing decimal phases
2167
+ state = ps.read("STORY-001")
2168
+
2169
+ assert "4.5" in state["phases"]
2170
+ assert "5.5" in state["phases"]
2171
+ assert state["phases"]["4.5"]["status"] == "pending"
2172
+ assert state["phases"]["5.5"]["status"] == "pending"
2173
+
2174
+ def test_new_state_has_populated_subagents_required(self, phase_state):
2175
+ """Newly created state should have populated subagents_required."""
2176
+ from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
2177
+
2178
+ state = phase_state.create("STORY-001")
2179
+
2180
+ # Verify Phase 01 has populated requirements
2181
+ assert state["phases"]["01"]["subagents_required"] != []
2182
+ assert "git-validator" in state["phases"]["01"]["subagents_required"]
2183
+
2184
+ # Verify Phase 03 has OR logic (as list)
2185
+ phase_03_required = state["phases"]["03"]["subagents_required"]
2186
+ has_list = any(isinstance(item, list) for item in phase_03_required)
2187
+ assert has_list, "Phase 03 should have list (serialized tuple) for OR logic"