prizmkit 1.0.147 → 1.0.148

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.
@@ -0,0 +1,446 @@
1
+ """Tests for auto-skip and unskip functionality in update-feature-status.py."""
2
+
3
+ import json
4
+ import os
5
+
6
+ from update_feature_status import (
7
+ auto_skip_blocked_features,
8
+ TERMINAL_STATUSES,
9
+ load_feature_status,
10
+ )
11
+
12
+ from utils import load_json_file, write_json_file
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Helpers
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def _make_feature(fid, title="Feature", deps=None, status="pending"):
20
+ return {
21
+ "id": fid,
22
+ "title": title,
23
+ "description": "desc",
24
+ "priority": "medium",
25
+ "dependencies": deps or [],
26
+ "acceptance_criteria": ["ok"],
27
+ "status": status,
28
+ }
29
+
30
+
31
+ def _make_feature_list(features):
32
+ return {
33
+ "$schema": "dev-pipeline-feature-list-v1",
34
+ "app_name": "test-app",
35
+ "features": features,
36
+ }
37
+
38
+
39
+ def _init_state(tmp_path, feature_ids):
40
+ """Create state dir with status.json for each feature."""
41
+ state_dir = str(tmp_path / "state")
42
+ for fid in feature_ids:
43
+ feature_dir = os.path.join(state_dir, "features", fid, "sessions")
44
+ os.makedirs(feature_dir, exist_ok=True)
45
+ status_path = os.path.join(state_dir, "features", fid, "status.json")
46
+ write_json_file(status_path, {
47
+ "feature_id": fid,
48
+ "status": "pending",
49
+ "retry_count": 0,
50
+ "max_retries": 3,
51
+ "sessions": [],
52
+ "last_session_id": None,
53
+ "resume_from_phase": None,
54
+ "created_at": "2026-01-01T00:00:00Z",
55
+ "updated_at": "2026-01-01T00:00:00Z",
56
+ })
57
+ return state_dir
58
+
59
+
60
+ def _write_fl(tmp_path, features):
61
+ """Write feature-list.json and return its path."""
62
+ fl_path = str(tmp_path / "feature-list.json")
63
+ write_json_file(fl_path, _make_feature_list(features))
64
+ return fl_path
65
+
66
+
67
+ def _read_statuses(fl_path):
68
+ """Return {feature_id: status} from feature-list.json."""
69
+ data, _ = load_json_file(fl_path)
70
+ return {f["id"]: f["status"] for f in data["features"]}
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # TERMINAL_STATUSES
75
+ # ---------------------------------------------------------------------------
76
+
77
+ class TestTerminalStatuses:
78
+ def test_auto_skipped_is_terminal(self):
79
+ assert "auto_skipped" in TERMINAL_STATUSES
80
+
81
+ def test_split_is_terminal(self):
82
+ assert "split" in TERMINAL_STATUSES
83
+
84
+ def test_all_expected_statuses(self):
85
+ assert TERMINAL_STATUSES == {"completed", "failed", "skipped", "auto_skipped", "split"}
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # auto_skip_blocked_features — linear chain
90
+ # ---------------------------------------------------------------------------
91
+
92
+ class TestAutoSkipLinearChain:
93
+ """F-001 → F-002 → F-003 linear dependency chain."""
94
+
95
+ def test_failing_root_skips_all_downstream(self, tmp_path):
96
+ features = [
97
+ _make_feature("F-001", "Root", status="failed"),
98
+ _make_feature("F-002", "Mid", deps=["F-001"]),
99
+ _make_feature("F-003", "Leaf", deps=["F-002"]),
100
+ ]
101
+ fl_path = _write_fl(tmp_path, features)
102
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
103
+
104
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
105
+
106
+ assert len(result) == 2
107
+ skipped_ids = {r["feature_id"] for r in result}
108
+ assert skipped_ids == {"F-002", "F-003"}
109
+
110
+ statuses = _read_statuses(fl_path)
111
+ assert statuses["F-001"] == "failed"
112
+ assert statuses["F-002"] == "auto_skipped"
113
+ assert statuses["F-003"] == "auto_skipped"
114
+
115
+ def test_status_json_also_updated(self, tmp_path):
116
+ features = [
117
+ _make_feature("F-001", "Root", status="failed"),
118
+ _make_feature("F-002", "Mid", deps=["F-001"]),
119
+ ]
120
+ fl_path = _write_fl(tmp_path, features)
121
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
122
+
123
+ auto_skip_blocked_features(fl_path, state_dir, "F-001")
124
+
125
+ fs = load_feature_status(state_dir, "F-002")
126
+ assert fs["status"] == "auto_skipped"
127
+
128
+ def test_failing_mid_skips_only_downstream(self, tmp_path):
129
+ features = [
130
+ _make_feature("F-001", "Root", status="completed"),
131
+ _make_feature("F-002", "Mid", deps=["F-001"], status="failed"),
132
+ _make_feature("F-003", "Leaf", deps=["F-002"]),
133
+ ]
134
+ fl_path = _write_fl(tmp_path, features)
135
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
136
+
137
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-002")
138
+
139
+ assert len(result) == 1
140
+ assert result[0]["feature_id"] == "F-003"
141
+
142
+ statuses = _read_statuses(fl_path)
143
+ assert statuses["F-001"] == "completed"
144
+ assert statuses["F-003"] == "auto_skipped"
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # auto_skip_blocked_features — diamond dependency
149
+ # ---------------------------------------------------------------------------
150
+
151
+ class TestAutoSkipDiamond:
152
+ """A → C, B → C diamond where only A fails."""
153
+
154
+ def test_diamond_skips_downstream(self, tmp_path):
155
+ features = [
156
+ _make_feature("F-001", "A", status="failed"),
157
+ _make_feature("F-002", "B", status="completed"),
158
+ _make_feature("F-003", "C", deps=["F-001", "F-002"]),
159
+ ]
160
+ fl_path = _write_fl(tmp_path, features)
161
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
162
+
163
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
164
+
165
+ assert len(result) == 1
166
+ assert result[0]["feature_id"] == "F-003"
167
+
168
+ def test_no_skip_when_all_deps_ok(self, tmp_path):
169
+ features = [
170
+ _make_feature("F-001", "A", status="completed"),
171
+ _make_feature("F-002", "B", status="completed"),
172
+ _make_feature("F-003", "C", deps=["F-001", "F-002"]),
173
+ ]
174
+ fl_path = _write_fl(tmp_path, features)
175
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
176
+
177
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
178
+
179
+ assert len(result) == 0
180
+ assert _read_statuses(fl_path)["F-003"] == "pending"
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # auto_skip_blocked_features — edge cases
185
+ # ---------------------------------------------------------------------------
186
+
187
+ class TestAutoSkipEdgeCases:
188
+ def test_no_dependencies_no_skip(self, tmp_path):
189
+ features = [
190
+ _make_feature("F-001", "A", status="failed"),
191
+ _make_feature("F-002", "B"), # independent
192
+ ]
193
+ fl_path = _write_fl(tmp_path, features)
194
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
195
+
196
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
197
+
198
+ assert len(result) == 0
199
+ assert _read_statuses(fl_path)["F-002"] == "pending"
200
+
201
+ def test_already_completed_not_skipped(self, tmp_path):
202
+ features = [
203
+ _make_feature("F-001", "A", status="failed"),
204
+ _make_feature("F-002", "B", deps=["F-001"], status="completed"),
205
+ ]
206
+ fl_path = _write_fl(tmp_path, features)
207
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
208
+
209
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
210
+
211
+ assert len(result) == 0
212
+ assert _read_statuses(fl_path)["F-002"] == "completed"
213
+
214
+ def test_already_auto_skipped_dep_propagates(self, tmp_path):
215
+ """If a dep is already auto_skipped (from a previous failure),
216
+ new downstream features depending on it should also be skipped."""
217
+ features = [
218
+ _make_feature("F-001", "A", status="failed"),
219
+ _make_feature("F-002", "B", deps=["F-001"], status="auto_skipped"),
220
+ _make_feature("F-003", "C", deps=["F-002"]), # pending
221
+ ]
222
+ fl_path = _write_fl(tmp_path, features)
223
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
224
+
225
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
226
+
227
+ # F-002 is already terminal (auto_skipped), so not re-processed
228
+ # F-003 should be auto_skipped because its dep F-002 is auto_skipped
229
+ skipped_ids = {r["feature_id"] for r in result}
230
+ assert "F-003" in skipped_ids
231
+ assert _read_statuses(fl_path)["F-003"] == "auto_skipped"
232
+
233
+ def test_split_feature_not_skipped(self, tmp_path):
234
+ features = [
235
+ _make_feature("F-001", "A", status="failed"),
236
+ _make_feature("F-002", "B", deps=["F-001"], status="split"),
237
+ ]
238
+ fl_path = _write_fl(tmp_path, features)
239
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
240
+
241
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
242
+
243
+ assert len(result) == 0
244
+ assert _read_statuses(fl_path)["F-002"] == "split"
245
+
246
+ def test_skipped_dep_triggers_auto_skip(self, tmp_path):
247
+ """When a dependency is manually skipped, downstream should be auto_skipped."""
248
+ features = [
249
+ _make_feature("F-001", "A", status="skipped"),
250
+ _make_feature("F-002", "B", deps=["F-001"]),
251
+ _make_feature("F-003", "C", deps=["F-002"]),
252
+ ]
253
+ fl_path = _write_fl(tmp_path, features)
254
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
255
+
256
+ result = auto_skip_blocked_features(fl_path, state_dir, "F-001")
257
+
258
+ assert len(result) == 2
259
+ statuses = _read_statuses(fl_path)
260
+ assert statuses["F-001"] == "skipped"
261
+ assert statuses["F-002"] == "auto_skipped"
262
+ assert statuses["F-003"] == "auto_skipped"
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # action_unskip — via subprocess (tests the full CLI interface)
267
+ # ---------------------------------------------------------------------------
268
+
269
+ import subprocess
270
+
271
+ _SCRIPT = os.path.join(
272
+ os.path.dirname(__file__), "..", "scripts", "update-feature-status.py"
273
+ )
274
+
275
+
276
+ def _run_unskip(fl_path, state_dir, feature_id=None):
277
+ cmd = [
278
+ "python3", _SCRIPT,
279
+ "--feature-list", fl_path,
280
+ "--state-dir", state_dir,
281
+ "--action", "unskip",
282
+ ]
283
+ if feature_id:
284
+ cmd += ["--feature-id", feature_id]
285
+ result = subprocess.run(cmd, capture_output=True, text=True)
286
+ assert result.returncode == 0, result.stderr
287
+ return json.loads(result.stdout)
288
+
289
+
290
+ def _run_get_next(fl_path, state_dir):
291
+ cmd = [
292
+ "python3", _SCRIPT,
293
+ "--feature-list", fl_path,
294
+ "--state-dir", state_dir,
295
+ "--action", "get_next",
296
+ ]
297
+ result = subprocess.run(cmd, capture_output=True, text=True)
298
+ return result.stdout.strip()
299
+
300
+
301
+ class TestUnskipByFeatureId:
302
+ """Unskip with --feature-id targets a specific failed feature + downstream."""
303
+
304
+ def test_unskip_failed_root_resets_all(self, tmp_path):
305
+ features = [
306
+ _make_feature("F-001", "Root", status="failed"),
307
+ _make_feature("F-002", "Mid", deps=["F-001"], status="auto_skipped"),
308
+ _make_feature("F-003", "Leaf", deps=["F-002"], status="auto_skipped"),
309
+ ]
310
+ fl_path = _write_fl(tmp_path, features)
311
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
312
+
313
+ result = _run_unskip(fl_path, state_dir, "F-001")
314
+
315
+ assert result["reset_count"] == 3
316
+ statuses = _read_statuses(fl_path)
317
+ assert all(s == "pending" for s in statuses.values())
318
+
319
+ def test_unskip_auto_skipped_leaf_resets_upstream(self, tmp_path):
320
+ """C2 fix: unskip F-003 must also find and reset F-001 (failed root)."""
321
+ features = [
322
+ _make_feature("F-001", "Root", status="failed"),
323
+ _make_feature("F-002", "Mid", deps=["F-001"], status="auto_skipped"),
324
+ _make_feature("F-003", "Leaf", deps=["F-002"], status="auto_skipped"),
325
+ ]
326
+ fl_path = _write_fl(tmp_path, features)
327
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
328
+
329
+ result = _run_unskip(fl_path, state_dir, "F-003")
330
+
331
+ assert result["reset_count"] == 3
332
+ reset_ids = {f["feature_id"] for f in result["features"]}
333
+ assert "F-001" in reset_ids # transitive upstream reset
334
+ assert all(s == "pending" for s in _read_statuses(fl_path).values())
335
+
336
+ def test_unskip_preserves_completed_features(self, tmp_path):
337
+ features = [
338
+ _make_feature("F-001", "Done", status="completed"),
339
+ _make_feature("F-002", "Failed", deps=["F-001"], status="failed"),
340
+ _make_feature("F-003", "Skipped", deps=["F-002"], status="auto_skipped"),
341
+ ]
342
+ fl_path = _write_fl(tmp_path, features)
343
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003"])
344
+
345
+ result = _run_unskip(fl_path, state_dir, "F-002")
346
+
347
+ assert result["reset_count"] == 2
348
+ statuses = _read_statuses(fl_path)
349
+ assert statuses["F-001"] == "completed" # not touched
350
+ assert statuses["F-002"] == "pending"
351
+ assert statuses["F-003"] == "pending"
352
+
353
+ def test_unskip_skipped_feature_resets_downstream(self, tmp_path):
354
+ """Unskip a manually-skipped feature should also reset its auto_skipped downstream."""
355
+ features = [
356
+ _make_feature("F-001", "Skipped", status="skipped"),
357
+ _make_feature("F-002", "Child", deps=["F-001"], status="auto_skipped"),
358
+ ]
359
+ fl_path = _write_fl(tmp_path, features)
360
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
361
+
362
+ result = _run_unskip(fl_path, state_dir, "F-001")
363
+
364
+ assert result["reset_count"] == 2
365
+ statuses = _read_statuses(fl_path)
366
+ assert statuses["F-001"] == "pending"
367
+ assert statuses["F-002"] == "pending"
368
+
369
+
370
+ class TestUnskipAll:
371
+ """Unskip without --feature-id resets all failed + auto_skipped."""
372
+
373
+ def test_resets_all_failed_and_auto_skipped(self, tmp_path):
374
+ features = [
375
+ _make_feature("F-001", "OK", status="completed"),
376
+ _make_feature("F-002", "Failed", status="failed"),
377
+ _make_feature("F-003", "Skipped", deps=["F-002"], status="auto_skipped"),
378
+ _make_feature("F-004", "Pending", status="pending"),
379
+ ]
380
+ fl_path = _write_fl(tmp_path, features)
381
+ state_dir = _init_state(tmp_path, ["F-001", "F-002", "F-003", "F-004"])
382
+
383
+ result = _run_unskip(fl_path, state_dir)
384
+
385
+ assert result["reset_count"] == 2
386
+ statuses = _read_statuses(fl_path)
387
+ assert statuses["F-001"] == "completed"
388
+ assert statuses["F-002"] == "pending"
389
+ assert statuses["F-003"] == "pending"
390
+ assert statuses["F-004"] == "pending"
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Integration: auto-skip → get_next → unskip → get_next
395
+ # ---------------------------------------------------------------------------
396
+
397
+ class TestAutoSkipIntegration:
398
+ """Full lifecycle: fail → auto-skip → pipeline complete → unskip → resume."""
399
+
400
+ def test_full_lifecycle(self, tmp_path):
401
+ features = [
402
+ _make_feature("F-001", "Root", status="failed"),
403
+ _make_feature("F-002", "Child", deps=["F-001"]),
404
+ ]
405
+ fl_path = _write_fl(tmp_path, features)
406
+ state_dir = _init_state(tmp_path, ["F-001", "F-002"])
407
+
408
+ # 1. Auto-skip cascades
409
+ auto_skip_blocked_features(fl_path, state_dir, "F-001")
410
+ assert _read_statuses(fl_path)["F-002"] == "auto_skipped"
411
+
412
+ # 2. get_next should return PIPELINE_COMPLETE
413
+ assert _run_get_next(fl_path, state_dir) == "PIPELINE_COMPLETE"
414
+
415
+ # 3. Unskip recovers everything
416
+ _run_unskip(fl_path, state_dir, "F-001")
417
+ statuses = _read_statuses(fl_path)
418
+ assert statuses["F-001"] == "pending"
419
+ assert statuses["F-002"] == "pending"
420
+
421
+ # 4. get_next should now return F-001
422
+ output = _run_get_next(fl_path, state_dir)
423
+ data = json.loads(output)
424
+ assert data["feature_id"] == "F-001"
425
+
426
+ def test_retry_count_reset_after_unskip(self, tmp_path):
427
+ features = [
428
+ _make_feature("F-001", "Root", status="failed"),
429
+ ]
430
+ fl_path = _write_fl(tmp_path, features)
431
+ state_dir = _init_state(tmp_path, ["F-001"])
432
+ # Simulate retry_count = 3
433
+ fs_path = os.path.join(state_dir, "features", "F-001", "status.json")
434
+ with open(fs_path) as f:
435
+ fs = json.load(f)
436
+ fs["retry_count"] = 3
437
+ fs["status"] = "failed"
438
+ with open(fs_path, "w") as f:
439
+ json.dump(fs, f)
440
+
441
+ _run_unskip(fl_path, state_dir, "F-001")
442
+
443
+ with open(fs_path) as f:
444
+ fs = json.load(f)
445
+ assert fs["retry_count"] == 0
446
+ assert fs["status"] == "pending"
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.147",
2
+ "version": "1.0.148",
3
3
  "skills": {
4
4
  "prizm-kit": {
5
5
  "description": "Full-lifecycle dev toolkit. Covers spec-driven development, Prizm context docs, code quality, debugging, deployment, and knowledge management.",
@@ -78,6 +78,13 @@
78
78
  "hasAssets": true,
79
79
  "hasScripts": false
80
80
  },
81
+ "prizmkit-deploy": {
82
+ "description": "Generate or update deployment documentation (DEPLOY.md) by scanning project state and deploy strategy. Fully autonomous.",
83
+ "tier": "1",
84
+ "category": "prizmkit-skill",
85
+ "hasAssets": true,
86
+ "hasScripts": false
87
+ },
81
88
  "prizmkit-tool-tech-debt-tracker": {
82
89
  "description": "Identify and track technical debt via code pattern analysis.",
83
90
  "tier": "1",
@@ -252,6 +259,7 @@
252
259
  "prizmkit-code-review",
253
260
  "prizmkit-committer",
254
261
  "prizmkit-retrospective",
262
+ "prizmkit-deploy",
255
263
  "feature-workflow",
256
264
  "refactor-workflow",
257
265
  "app-planner",