prizmkit 1.0.13 → 1.0.14
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.
- package/bin/create-prizmkit.js +4 -1
- package/bundled/VERSION.json +3 -3
- package/bundled/adapters/claude/command-adapter.js +35 -4
- package/bundled/adapters/claude/rules-adapter.js +6 -58
- package/bundled/adapters/claude/team-adapter.js +2 -2
- package/bundled/adapters/codebuddy/agent-adapter.js +0 -1
- package/bundled/adapters/codebuddy/rules-adapter.js +30 -0
- package/bundled/adapters/shared/frontmatter.js +3 -1
- package/bundled/dev-pipeline/README.md +13 -3
- package/bundled/dev-pipeline/launch-bugfix-daemon.sh +10 -0
- package/bundled/dev-pipeline/launch-daemon.sh +18 -4
- package/bundled/dev-pipeline/lib/common.sh +105 -0
- package/bundled/dev-pipeline/run-bugfix.sh +57 -57
- package/bundled/dev-pipeline/run.sh +75 -59
- package/bundled/dev-pipeline/scripts/check-session-status.py +47 -2
- package/bundled/dev-pipeline/scripts/cleanup-logs.py +192 -0
- package/bundled/dev-pipeline/scripts/detect-stuck.py +15 -3
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +32 -27
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +23 -23
- package/bundled/dev-pipeline/scripts/update-feature-status.py +50 -2
- package/bundled/dev-pipeline/scripts/utils.py +22 -0
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +18 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +19 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +18 -2
- package/bundled/dev-pipeline/templates/session-status-schema.json +7 -1
- package/bundled/dev-pipeline/tests/__init__.py +0 -0
- package/bundled/dev-pipeline/tests/conftest.py +133 -0
- package/bundled/dev-pipeline/tests/test_check_session.py +127 -0
- package/bundled/dev-pipeline/tests/test_cleanup_logs.py +119 -0
- package/bundled/dev-pipeline/tests/test_detect_stuck.py +207 -0
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +181 -0
- package/bundled/dev-pipeline/tests/test_generate_prompt.py +190 -0
- package/bundled/dev-pipeline/tests/test_init_bugfix_pipeline.py +153 -0
- package/bundled/dev-pipeline/tests/test_init_pipeline.py +241 -0
- package/bundled/dev-pipeline/tests/test_update_bug_status.py +142 -0
- package/bundled/dev-pipeline/tests/test_update_feature_status.py +277 -0
- package/bundled/dev-pipeline/tests/test_utils.py +141 -0
- package/bundled/rules/USAGE.md +153 -0
- package/bundled/rules/_rules-metadata.json +43 -0
- package/bundled/rules/general/prefer-linux-commands.md +9 -0
- package/bundled/rules/prizm/prizm-commit-workflow.md +10 -0
- package/bundled/rules/prizm/prizm-documentation.md +19 -0
- package/bundled/rules/prizm/prizm-progressive-loading.md +11 -0
- package/bundled/skills/_metadata.json +130 -67
- package/bundled/skills/app-planner/SKILL.md +252 -499
- package/bundled/skills/app-planner/assets/evaluation-guide.md +44 -0
- package/bundled/skills/app-planner/scripts/validate-and-generate.py +143 -4
- package/bundled/skills/bug-planner/SKILL.md +58 -13
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +5 -7
- package/bundled/skills/dev-pipeline-launcher/SKILL.md +16 -7
- package/bundled/skills/feature-workflow/SKILL.md +175 -234
- package/bundled/skills/prizm-kit/SKILL.md +17 -31
- package/bundled/skills/{prizmkit-adr-manager → prizmkit-tool-adr-manager}/SKILL.md +6 -7
- package/bundled/skills/{prizmkit-api-doc-generator → prizmkit-tool-api-doc-generator}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-bug-reproducer → prizmkit-tool-bug-reproducer}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-ci-cd-generator → prizmkit-tool-ci-cd-generator}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-db-migration → prizmkit-tool-db-migration}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-dependency-health → prizmkit-tool-dependency-health}/SKILL.md +3 -4
- package/bundled/skills/{prizmkit-deployment-strategy → prizmkit-tool-deployment-strategy}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-error-triage → prizmkit-tool-error-triage}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-log-analyzer → prizmkit-tool-log-analyzer}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-monitoring-setup → prizmkit-tool-monitoring-setup}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-onboarding-generator → prizmkit-tool-onboarding-generator}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-perf-profiler → prizmkit-tool-perf-profiler}/SKILL.md +4 -5
- package/bundled/skills/{prizmkit-security-audit → prizmkit-tool-security-audit}/SKILL.md +3 -4
- package/bundled/skills/{prizmkit-tech-debt-tracker → prizmkit-tool-tech-debt-tracker}/SKILL.md +3 -4
- package/bundled/skills/refactor-skill/SKILL.md +371 -0
- package/bundled/skills/refactor-workflow/SKILL.md +17 -119
- package/package.json +1 -1
- package/src/external-skills.js +71 -0
- package/src/index.js +62 -4
- package/src/metadata.js +36 -0
- package/src/scaffold.js +136 -32
- package/bundled/skills/prizmkit-bug-fix-workflow/SKILL.md +0 -356
- package/bundled/templates/claude-md-template.md +0 -38
- package/bundled/templates/codebuddy-md-template.md +0 -35
- /package/bundled/skills/{prizmkit-adr-manager → prizmkit-tool-adr-manager}/assets/adr-template.md +0 -0
|
@@ -234,7 +234,7 @@ Wait for Reviewer to return.
|
|
|
234
234
|
|
|
235
235
|
### Phase 7: Summarize & Commit — DO NOT SKIP
|
|
236
236
|
|
|
237
|
-
**For bug fixes**: skip `prizmkit.summarize`, use `fix(<scope>):` commit prefix.
|
|
237
|
+
**For bug fixes**: skip `prizmkit.summarize`, skip retrospective, and use `fix(<scope>):` commit prefix.
|
|
238
238
|
|
|
239
239
|
**7a.** Check if feature already committed:
|
|
240
240
|
```bash
|
|
@@ -245,6 +245,20 @@ git log --oneline | grep "{{FEATURE_ID}}" | head -3
|
|
|
245
245
|
|
|
246
246
|
**7b.** Run `prizmkit.summarize` → archive to REGISTRY.md
|
|
247
247
|
|
|
248
|
+
**7b.5.** Update prizm-docs (feature sessions):
|
|
249
|
+
- Read the 'Implementation Log' in `context-snapshot.md` to identify changed files
|
|
250
|
+
- For each changed file, check if any related `.prizm-docs/` L1/L2 doc needs updating (new APIs, changed behavior, new TRAPS)
|
|
251
|
+
- Update `.prizm-docs/changelog.prizm`: append one-line entry `- feat({{FEATURE_ID}}): {{FEATURE_TITLE}}`
|
|
252
|
+
- Stage all modified prizm-docs: `git add .prizm-docs/`
|
|
253
|
+
- Pipeline docs pass condition is `REGISTRY.md` OR `.prizm-docs/` changed in final commit
|
|
254
|
+
|
|
255
|
+
**7b.6.** Run `prizmkit.retrospective` (feature sessions only, **before commit**):
|
|
256
|
+
- Extract lessons from completed feature — compare spec/plan/tasks vs actual
|
|
257
|
+
- Update relevant `.prizm-docs/` sections: TRAPS, RULES, DECISIONS
|
|
258
|
+
- Write `.prizmkit/specs/{{FEATURE_SLUG}}/retrospective.md`
|
|
259
|
+
- Stage any `.prizm-docs/` changes: `git add .prizm-docs/`
|
|
260
|
+
- **Skip if this session is bug-fix-only**
|
|
261
|
+
|
|
248
262
|
**7c.** Mark feature complete:
|
|
249
263
|
```bash
|
|
250
264
|
python3 {{VALIDATOR_SCRIPTS_DIR}}/update-feature-status.py \
|
|
@@ -269,7 +283,7 @@ Write to: `{{SESSION_STATUS_PATH}}`
|
|
|
269
283
|
"feature_id": "{{FEATURE_ID}}",
|
|
270
284
|
"feature_slug": "{{FEATURE_SLUG}}",
|
|
271
285
|
"exec_tier": 3,
|
|
272
|
-
"status": "<success|partial|failed>",
|
|
286
|
+
"status": "<success|partial|failed|commit_missing|docs_missing>",
|
|
273
287
|
"completed_phases": [0, 1, 2, 3, 4, 5, 6, 7],
|
|
274
288
|
"current_phase": 7,
|
|
275
289
|
"checkpoint_reached": "CP-3",
|
|
@@ -278,6 +292,8 @@ Write to: `{{SESSION_STATUS_PATH}}`
|
|
|
278
292
|
"errors": [],
|
|
279
293
|
"can_resume": false,
|
|
280
294
|
"resume_from_phase": null,
|
|
295
|
+
"docs_maintained": true,
|
|
296
|
+
"retrospective_done": true,
|
|
281
297
|
"artifacts": {
|
|
282
298
|
"context_snapshot_path": ".prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md",
|
|
283
299
|
"spec_path": ".prizmkit/specs/{{FEATURE_SLUG}}/spec.md",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"status": {
|
|
17
17
|
"type": "string",
|
|
18
|
-
"enum": ["success", "partial", "failed"]
|
|
18
|
+
"enum": ["success", "partial", "failed", "commit_missing", "docs_missing"]
|
|
19
19
|
},
|
|
20
20
|
"completed_phases": {
|
|
21
21
|
"type": "array",
|
|
@@ -58,6 +58,12 @@
|
|
|
58
58
|
"can_resume": {
|
|
59
59
|
"type": "boolean"
|
|
60
60
|
},
|
|
61
|
+
"docs_maintained": {
|
|
62
|
+
"type": "boolean"
|
|
63
|
+
},
|
|
64
|
+
"retrospective_done": {
|
|
65
|
+
"type": "boolean"
|
|
66
|
+
},
|
|
61
67
|
"resume_from_phase": {
|
|
62
68
|
"type": ["integer", "null"]
|
|
63
69
|
},
|
|
File without changes
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Shared fixtures for dev-pipeline tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def sample_feature_list():
|
|
10
|
+
"""A valid feature-list.json structure."""
|
|
11
|
+
return {
|
|
12
|
+
"$schema": "dev-pipeline-feature-list-v1",
|
|
13
|
+
"app_name": "TestApp",
|
|
14
|
+
"global_context": {
|
|
15
|
+
"language": "Python",
|
|
16
|
+
"framework": "FastAPI",
|
|
17
|
+
},
|
|
18
|
+
"features": [
|
|
19
|
+
{
|
|
20
|
+
"id": "F-001",
|
|
21
|
+
"title": "Project Infrastructure Setup",
|
|
22
|
+
"description": "Set up the project structure.",
|
|
23
|
+
"priority": 1,
|
|
24
|
+
"estimated_complexity": "low",
|
|
25
|
+
"dependencies": [],
|
|
26
|
+
"acceptance_criteria": ["Has package.json", "Has README.md"],
|
|
27
|
+
"status": "pending",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "F-002",
|
|
31
|
+
"title": "User Authentication",
|
|
32
|
+
"description": "Implement user login and registration.",
|
|
33
|
+
"priority": 2,
|
|
34
|
+
"estimated_complexity": "high",
|
|
35
|
+
"dependencies": ["F-001"],
|
|
36
|
+
"acceptance_criteria": ["Login works", "Registration works"],
|
|
37
|
+
"status": "pending",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "F-003",
|
|
41
|
+
"title": "Dashboard View",
|
|
42
|
+
"description": "Create main dashboard.",
|
|
43
|
+
"priority": 3,
|
|
44
|
+
"estimated_complexity": "medium",
|
|
45
|
+
"dependencies": ["F-001", "F-002"],
|
|
46
|
+
"acceptance_criteria": ["Dashboard renders"],
|
|
47
|
+
"status": "pending",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def sample_bug_list():
|
|
55
|
+
"""A valid bug-fix-list.json structure."""
|
|
56
|
+
return {
|
|
57
|
+
"$schema": "dev-pipeline-bug-fix-list-v1",
|
|
58
|
+
"project_name": "TestProject",
|
|
59
|
+
"global_context": {
|
|
60
|
+
"language": "Python",
|
|
61
|
+
},
|
|
62
|
+
"bugs": [
|
|
63
|
+
{
|
|
64
|
+
"id": "B-001",
|
|
65
|
+
"title": "Login crash on empty password",
|
|
66
|
+
"description": "App crashes when password is empty.",
|
|
67
|
+
"severity": "critical",
|
|
68
|
+
"priority": 1,
|
|
69
|
+
"error_source": {
|
|
70
|
+
"type": "stack_trace",
|
|
71
|
+
"stack_trace": "Traceback ... ValueError",
|
|
72
|
+
"error_message": "Password cannot be empty",
|
|
73
|
+
},
|
|
74
|
+
"verification_type": "automated",
|
|
75
|
+
"acceptance_criteria": ["No crash on empty password"],
|
|
76
|
+
"status": "pending",
|
|
77
|
+
"affected_feature": "F-002",
|
|
78
|
+
"affected_modules": ["auth"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "B-002",
|
|
82
|
+
"title": "Slow dashboard loading",
|
|
83
|
+
"description": "Dashboard takes 10s to load.",
|
|
84
|
+
"severity": "medium",
|
|
85
|
+
"priority": 2,
|
|
86
|
+
"error_source": {
|
|
87
|
+
"type": "user_report",
|
|
88
|
+
"reproduction_steps": ["Open dashboard", "Wait 10 seconds"],
|
|
89
|
+
},
|
|
90
|
+
"verification_type": "manual",
|
|
91
|
+
"acceptance_criteria": ["Dashboard loads in <2s"],
|
|
92
|
+
"status": "pending",
|
|
93
|
+
"affected_feature": "F-003",
|
|
94
|
+
"environment": {"os": "macOS", "browser": "Chrome"},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def feature_list_file(tmp_path, sample_feature_list):
|
|
102
|
+
"""Write sample feature list to a temp file and return the path."""
|
|
103
|
+
path = tmp_path / "feature-list.json"
|
|
104
|
+
path.write_text(json.dumps(sample_feature_list, indent=2), encoding="utf-8")
|
|
105
|
+
return str(path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.fixture
|
|
109
|
+
def bug_list_file(tmp_path, sample_bug_list):
|
|
110
|
+
"""Write sample bug list to a temp file and return the path."""
|
|
111
|
+
path = tmp_path / "bug-fix-list.json"
|
|
112
|
+
path.write_text(json.dumps(sample_bug_list, indent=2), encoding="utf-8")
|
|
113
|
+
return str(path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def state_dir(tmp_path):
|
|
118
|
+
"""Create and return a basic state directory."""
|
|
119
|
+
sd = tmp_path / "state"
|
|
120
|
+
sd.mkdir()
|
|
121
|
+
features_dir = sd / "features"
|
|
122
|
+
features_dir.mkdir()
|
|
123
|
+
return str(sd)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.fixture
|
|
127
|
+
def bugfix_state_dir(tmp_path):
|
|
128
|
+
"""Create and return a basic bugfix state directory."""
|
|
129
|
+
sd = tmp_path / "bugfix-state"
|
|
130
|
+
sd.mkdir()
|
|
131
|
+
bugs_dir = sd / "bugs"
|
|
132
|
+
bugs_dir.mkdir()
|
|
133
|
+
return str(sd)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Tests for check-session-status.py."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _import_check_session():
|
|
10
|
+
import importlib.util
|
|
11
|
+
path = os.path.join(
|
|
12
|
+
os.path.dirname(__file__), "..", "scripts", "check-session-status.py"
|
|
13
|
+
)
|
|
14
|
+
spec = importlib.util.spec_from_file_location("check_session_status", path)
|
|
15
|
+
mod = importlib.util.module_from_spec(spec)
|
|
16
|
+
sys.modules["check_session_status"] = mod
|
|
17
|
+
spec.loader.exec_module(mod)
|
|
18
|
+
return mod
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
css = _import_check_session()
|
|
22
|
+
validate_required_fields = css.validate_required_fields
|
|
23
|
+
determine_status = css.determine_status
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestValidateRequiredFields:
|
|
27
|
+
def test_all_present(self):
|
|
28
|
+
data = {
|
|
29
|
+
"session_id": "s-001",
|
|
30
|
+
"feature_id": "F-001",
|
|
31
|
+
"status": "success",
|
|
32
|
+
"timestamp": "2024-01-01T00:00:00Z",
|
|
33
|
+
}
|
|
34
|
+
missing = validate_required_fields(data)
|
|
35
|
+
assert missing == []
|
|
36
|
+
|
|
37
|
+
def test_missing_session_id(self):
|
|
38
|
+
data = {
|
|
39
|
+
"feature_id": "F-001",
|
|
40
|
+
"status": "success",
|
|
41
|
+
"timestamp": "2024-01-01T00:00:00Z",
|
|
42
|
+
}
|
|
43
|
+
missing = validate_required_fields(data)
|
|
44
|
+
assert "session_id" in missing
|
|
45
|
+
|
|
46
|
+
def test_missing_multiple(self):
|
|
47
|
+
data = {"feature_id": "F-001"}
|
|
48
|
+
missing = validate_required_fields(data)
|
|
49
|
+
assert "session_id" in missing
|
|
50
|
+
assert "status" in missing
|
|
51
|
+
assert "timestamp" in missing
|
|
52
|
+
|
|
53
|
+
def test_empty_string_field(self):
|
|
54
|
+
data = {
|
|
55
|
+
"session_id": "",
|
|
56
|
+
"feature_id": "F-001",
|
|
57
|
+
"status": "success",
|
|
58
|
+
"timestamp": "2024-01-01T00:00:00Z",
|
|
59
|
+
}
|
|
60
|
+
missing = validate_required_fields(data)
|
|
61
|
+
assert "session_id" in missing
|
|
62
|
+
|
|
63
|
+
def test_whitespace_only_field(self):
|
|
64
|
+
data = {
|
|
65
|
+
"session_id": " ",
|
|
66
|
+
"feature_id": "F-001",
|
|
67
|
+
"status": "success",
|
|
68
|
+
"timestamp": "2024-01-01T00:00:00Z",
|
|
69
|
+
}
|
|
70
|
+
missing = validate_required_fields(data)
|
|
71
|
+
assert "session_id" in missing
|
|
72
|
+
|
|
73
|
+
def test_non_string_field(self):
|
|
74
|
+
data = {
|
|
75
|
+
"session_id": 123,
|
|
76
|
+
"feature_id": "F-001",
|
|
77
|
+
"status": "success",
|
|
78
|
+
"timestamp": "2024-01-01T00:00:00Z",
|
|
79
|
+
}
|
|
80
|
+
missing = validate_required_fields(data)
|
|
81
|
+
assert "session_id" in missing
|
|
82
|
+
|
|
83
|
+
def test_empty_data(self):
|
|
84
|
+
missing = validate_required_fields({})
|
|
85
|
+
assert len(missing) == 4
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestDetermineStatus:
|
|
89
|
+
def test_success(self):
|
|
90
|
+
data = {"status": "success"}
|
|
91
|
+
assert determine_status(data) == "success"
|
|
92
|
+
|
|
93
|
+
def test_failed(self):
|
|
94
|
+
data = {"status": "failed"}
|
|
95
|
+
assert determine_status(data) == "failed"
|
|
96
|
+
|
|
97
|
+
def test_partial_resumable(self):
|
|
98
|
+
data = {"status": "partial", "can_resume": True}
|
|
99
|
+
assert determine_status(data) == "partial_resumable"
|
|
100
|
+
|
|
101
|
+
def test_partial_not_resumable(self):
|
|
102
|
+
data = {"status": "partial", "can_resume": False}
|
|
103
|
+
assert determine_status(data) == "partial_not_resumable"
|
|
104
|
+
|
|
105
|
+
def test_partial_no_resume_key(self):
|
|
106
|
+
data = {"status": "partial"}
|
|
107
|
+
assert determine_status(data) == "partial_not_resumable"
|
|
108
|
+
|
|
109
|
+
def test_commit_missing(self):
|
|
110
|
+
data = {"status": "commit_missing"}
|
|
111
|
+
assert determine_status(data) == "commit_missing"
|
|
112
|
+
|
|
113
|
+
def test_docs_missing(self):
|
|
114
|
+
data = {"status": "docs_missing"}
|
|
115
|
+
assert determine_status(data) == "docs_missing"
|
|
116
|
+
|
|
117
|
+
def test_unknown_status(self):
|
|
118
|
+
data = {"status": "something_weird"}
|
|
119
|
+
assert determine_status(data) == "crashed"
|
|
120
|
+
|
|
121
|
+
def test_empty_status(self):
|
|
122
|
+
data = {"status": ""}
|
|
123
|
+
assert determine_status(data) == "crashed"
|
|
124
|
+
|
|
125
|
+
def test_missing_status(self):
|
|
126
|
+
data = {}
|
|
127
|
+
assert determine_status(data) == "crashed"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Tests for cleanup-logs.py."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _script_path():
|
|
11
|
+
return os.path.join(
|
|
12
|
+
os.path.dirname(__file__), "..", "scripts", "cleanup-logs.py"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _write_log_file(path, content):
|
|
17
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
18
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
19
|
+
f.write(content)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_iter_log_files_only_sessions_logs(state_dir):
|
|
23
|
+
# Targeted log path under sessions/*/logs/
|
|
24
|
+
valid_log = os.path.join(
|
|
25
|
+
state_dir, "features", "F-001", "sessions", "S-001", "logs", "session.log"
|
|
26
|
+
)
|
|
27
|
+
_write_log_file(valid_log, "ok")
|
|
28
|
+
|
|
29
|
+
# logs/ not under sessions should be ignored
|
|
30
|
+
ignored_log = os.path.join(state_dir, "features", "F-001", "logs", "misc.log")
|
|
31
|
+
_write_log_file(ignored_log, "ignore me")
|
|
32
|
+
|
|
33
|
+
# Import script module for direct function test
|
|
34
|
+
import importlib.util
|
|
35
|
+
|
|
36
|
+
path = _script_path()
|
|
37
|
+
spec = importlib.util.spec_from_file_location("cleanup_logs", path)
|
|
38
|
+
mod = importlib.util.module_from_spec(spec)
|
|
39
|
+
sys.modules["cleanup_logs"] = mod
|
|
40
|
+
spec.loader.exec_module(mod)
|
|
41
|
+
|
|
42
|
+
files = list(mod.iter_log_files(state_dir))
|
|
43
|
+
assert valid_log in files
|
|
44
|
+
assert ignored_log not in files
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_cleanup_removes_old_logs_by_retention(state_dir):
|
|
48
|
+
old_log = os.path.join(
|
|
49
|
+
state_dir, "features", "F-001", "sessions", "S-001", "logs", "session.log"
|
|
50
|
+
)
|
|
51
|
+
new_log = os.path.join(
|
|
52
|
+
state_dir, "features", "F-001", "sessions", "S-002", "logs", "session.log"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_write_log_file(old_log, "old")
|
|
56
|
+
_write_log_file(new_log, "new")
|
|
57
|
+
|
|
58
|
+
# Make old log 20 days old
|
|
59
|
+
old_ts = time.time() - (20 * 86400)
|
|
60
|
+
os.utime(old_log, (old_ts, old_ts))
|
|
61
|
+
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
[
|
|
64
|
+
sys.executable,
|
|
65
|
+
_script_path(),
|
|
66
|
+
"--state-dir",
|
|
67
|
+
state_dir,
|
|
68
|
+
"--retention-days",
|
|
69
|
+
"14",
|
|
70
|
+
"--max-total-mb",
|
|
71
|
+
"1024",
|
|
72
|
+
],
|
|
73
|
+
check=True,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
report = json.loads(result.stdout)
|
|
79
|
+
assert report["deleted_by_reason"]["retention"] >= 1
|
|
80
|
+
assert not os.path.exists(old_log)
|
|
81
|
+
assert os.path.exists(new_log)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_cleanup_enforces_total_size_cap(state_dir):
|
|
85
|
+
log1 = os.path.join(
|
|
86
|
+
state_dir, "features", "F-001", "sessions", "S-001", "logs", "session.log"
|
|
87
|
+
)
|
|
88
|
+
log2 = os.path.join(
|
|
89
|
+
state_dir, "features", "F-001", "sessions", "S-002", "logs", "session.log"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
_write_log_file(log1, "a" * 1024)
|
|
93
|
+
_write_log_file(log2, "b" * 1024)
|
|
94
|
+
|
|
95
|
+
# Ensure deterministic ordering: log1 older than log2
|
|
96
|
+
old_ts = time.time() - 120
|
|
97
|
+
new_ts = time.time() - 60
|
|
98
|
+
os.utime(log1, (old_ts, old_ts))
|
|
99
|
+
os.utime(log2, (new_ts, new_ts))
|
|
100
|
+
|
|
101
|
+
result = subprocess.run(
|
|
102
|
+
[
|
|
103
|
+
sys.executable,
|
|
104
|
+
_script_path(),
|
|
105
|
+
"--state-dir",
|
|
106
|
+
state_dir,
|
|
107
|
+
"--retention-days",
|
|
108
|
+
"999",
|
|
109
|
+
"--max-total-mb",
|
|
110
|
+
"0",
|
|
111
|
+
],
|
|
112
|
+
check=True,
|
|
113
|
+
capture_output=True,
|
|
114
|
+
text=True,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
report = json.loads(result.stdout)
|
|
118
|
+
assert report["deleted_by_reason"]["size"] >= 1
|
|
119
|
+
assert report["final_total_bytes"] == 0
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Tests for detect-stuck.py."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import pytest
|
|
7
|
+
from datetime import datetime, timezone, timedelta
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _import_detect_stuck():
|
|
11
|
+
import importlib.util
|
|
12
|
+
path = os.path.join(
|
|
13
|
+
os.path.dirname(__file__), "..", "scripts", "detect-stuck.py"
|
|
14
|
+
)
|
|
15
|
+
spec = importlib.util.spec_from_file_location("detect_stuck", path)
|
|
16
|
+
mod = importlib.util.module_from_spec(spec)
|
|
17
|
+
sys.modules["detect_stuck"] = mod
|
|
18
|
+
spec.loader.exec_module(mod)
|
|
19
|
+
return mod
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ds = _import_detect_stuck()
|
|
23
|
+
parse_iso_timestamp = ds.parse_iso_timestamp
|
|
24
|
+
check_max_retries = ds.check_max_retries
|
|
25
|
+
check_stuck_checkpoint = ds.check_stuck_checkpoint
|
|
26
|
+
check_dependency_deadlock = ds.check_dependency_deadlock
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestParseIsoTimestamp:
|
|
30
|
+
def test_standard_z_format(self):
|
|
31
|
+
result = parse_iso_timestamp("2024-01-15T10:30:00Z")
|
|
32
|
+
assert result is not None
|
|
33
|
+
assert result.year == 2024
|
|
34
|
+
assert result.month == 1
|
|
35
|
+
assert result.day == 15
|
|
36
|
+
assert result.hour == 10
|
|
37
|
+
assert result.minute == 30
|
|
38
|
+
|
|
39
|
+
def test_plus_zero_offset(self):
|
|
40
|
+
result = parse_iso_timestamp("2024-01-15T10:30:00+00:00")
|
|
41
|
+
assert result is not None
|
|
42
|
+
assert result.year == 2024
|
|
43
|
+
|
|
44
|
+
def test_with_microseconds_z(self):
|
|
45
|
+
result = parse_iso_timestamp("2024-01-15T10:30:00.123456Z")
|
|
46
|
+
assert result is not None
|
|
47
|
+
|
|
48
|
+
def test_with_microseconds_offset(self):
|
|
49
|
+
result = parse_iso_timestamp("2024-01-15T10:30:00.123456+00:00")
|
|
50
|
+
assert result is not None
|
|
51
|
+
|
|
52
|
+
def test_invalid_string(self):
|
|
53
|
+
result = parse_iso_timestamp("not a timestamp")
|
|
54
|
+
assert result is None
|
|
55
|
+
|
|
56
|
+
def test_none_input(self):
|
|
57
|
+
result = parse_iso_timestamp(None)
|
|
58
|
+
assert result is None
|
|
59
|
+
|
|
60
|
+
def test_integer_input(self):
|
|
61
|
+
result = parse_iso_timestamp(12345)
|
|
62
|
+
assert result is None
|
|
63
|
+
|
|
64
|
+
def test_empty_string(self):
|
|
65
|
+
result = parse_iso_timestamp("")
|
|
66
|
+
assert result is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestCheckMaxRetries:
|
|
70
|
+
def test_below_max(self):
|
|
71
|
+
status = {"retry_count": 1}
|
|
72
|
+
result = check_max_retries(status, 3)
|
|
73
|
+
assert result is None
|
|
74
|
+
|
|
75
|
+
def test_at_max(self):
|
|
76
|
+
status = {"retry_count": 3}
|
|
77
|
+
result = check_max_retries(status, 3)
|
|
78
|
+
assert result is not None
|
|
79
|
+
assert result["reason"] == "max_retries_exceeded"
|
|
80
|
+
|
|
81
|
+
def test_above_max(self):
|
|
82
|
+
status = {"retry_count": 5}
|
|
83
|
+
result = check_max_retries(status, 3)
|
|
84
|
+
assert result is not None
|
|
85
|
+
|
|
86
|
+
def test_zero_retries(self):
|
|
87
|
+
status = {"retry_count": 0}
|
|
88
|
+
result = check_max_retries(status, 3)
|
|
89
|
+
assert result is None
|
|
90
|
+
|
|
91
|
+
def test_missing_retry_count(self):
|
|
92
|
+
status = {}
|
|
93
|
+
result = check_max_retries(status, 3)
|
|
94
|
+
assert result is None # defaults to 0
|
|
95
|
+
|
|
96
|
+
def test_non_int_retry_count(self):
|
|
97
|
+
status = {"retry_count": "not a number"}
|
|
98
|
+
result = check_max_retries(status, 3)
|
|
99
|
+
assert result is None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestCheckStuckCheckpoint:
|
|
103
|
+
def _setup_sessions(self, tmp_path, checkpoints):
|
|
104
|
+
"""Create a feature dir with sessions having given checkpoints."""
|
|
105
|
+
feature_dir = tmp_path / "feature"
|
|
106
|
+
sessions_dir = feature_dir / "sessions"
|
|
107
|
+
for i, cp in enumerate(checkpoints):
|
|
108
|
+
session_dir = sessions_dir / "session-{:03d}".format(i)
|
|
109
|
+
session_dir.mkdir(parents=True)
|
|
110
|
+
data = {"checkpoint_reached": cp}
|
|
111
|
+
(session_dir / "session-status.json").write_text(
|
|
112
|
+
json.dumps(data), encoding="utf-8"
|
|
113
|
+
)
|
|
114
|
+
return str(feature_dir)
|
|
115
|
+
|
|
116
|
+
def test_stuck_at_same_checkpoint(self, tmp_path):
|
|
117
|
+
feature_dir = self._setup_sessions(tmp_path, ["CP-3", "CP-3", "CP-3"])
|
|
118
|
+
result = check_stuck_checkpoint(feature_dir)
|
|
119
|
+
assert result is not None
|
|
120
|
+
assert result["reason"] == "stuck_at_checkpoint"
|
|
121
|
+
assert "CP-3" in result["details"]
|
|
122
|
+
|
|
123
|
+
def test_different_checkpoints(self, tmp_path):
|
|
124
|
+
feature_dir = self._setup_sessions(tmp_path, ["CP-1", "CP-2", "CP-3"])
|
|
125
|
+
result = check_stuck_checkpoint(feature_dir)
|
|
126
|
+
assert result is None
|
|
127
|
+
|
|
128
|
+
def test_fewer_than_three_sessions(self, tmp_path):
|
|
129
|
+
feature_dir = self._setup_sessions(tmp_path, ["CP-3", "CP-3"])
|
|
130
|
+
result = check_stuck_checkpoint(feature_dir)
|
|
131
|
+
assert result is None
|
|
132
|
+
|
|
133
|
+
def test_no_sessions(self, tmp_path):
|
|
134
|
+
feature_dir = tmp_path / "feature"
|
|
135
|
+
feature_dir.mkdir()
|
|
136
|
+
result = check_stuck_checkpoint(str(feature_dir))
|
|
137
|
+
assert result is None
|
|
138
|
+
|
|
139
|
+
def test_none_checkpoints(self, tmp_path):
|
|
140
|
+
feature_dir = self._setup_sessions(tmp_path, [None, None, None])
|
|
141
|
+
result = check_stuck_checkpoint(feature_dir)
|
|
142
|
+
assert result is None # None checkpoints don't trigger stuck
|
|
143
|
+
|
|
144
|
+
def test_mixed_with_last_three_same(self, tmp_path):
|
|
145
|
+
feature_dir = self._setup_sessions(
|
|
146
|
+
tmp_path, ["CP-1", "CP-2", "CP-3", "CP-3", "CP-3"]
|
|
147
|
+
)
|
|
148
|
+
result = check_stuck_checkpoint(feature_dir)
|
|
149
|
+
assert result is not None
|
|
150
|
+
assert "CP-3" in result["details"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class TestCheckDependencyDeadlock:
|
|
154
|
+
def test_dependency_failed(self, state_dir):
|
|
155
|
+
# Create a failed dependency
|
|
156
|
+
dep_dir = os.path.join(state_dir, "features", "F-001")
|
|
157
|
+
os.makedirs(dep_dir, exist_ok=True)
|
|
158
|
+
with open(os.path.join(dep_dir, "status.json"), "w") as f:
|
|
159
|
+
json.dump({"status": "failed"}, f)
|
|
160
|
+
|
|
161
|
+
feature_list_data = {
|
|
162
|
+
"features": [
|
|
163
|
+
{"id": "F-001", "dependencies": []},
|
|
164
|
+
{"id": "F-002", "dependencies": ["F-001"]},
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
result = check_dependency_deadlock("F-002", feature_list_data, state_dir)
|
|
168
|
+
assert result is not None
|
|
169
|
+
assert result["reason"] == "dependency_failed"
|
|
170
|
+
assert "F-001" in result["details"]
|
|
171
|
+
|
|
172
|
+
def test_dependency_completed(self, state_dir):
|
|
173
|
+
dep_dir = os.path.join(state_dir, "features", "F-001")
|
|
174
|
+
os.makedirs(dep_dir, exist_ok=True)
|
|
175
|
+
with open(os.path.join(dep_dir, "status.json"), "w") as f:
|
|
176
|
+
json.dump({"status": "completed"}, f)
|
|
177
|
+
|
|
178
|
+
feature_list_data = {
|
|
179
|
+
"features": [
|
|
180
|
+
{"id": "F-001", "dependencies": []},
|
|
181
|
+
{"id": "F-002", "dependencies": ["F-001"]},
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
result = check_dependency_deadlock("F-002", feature_list_data, state_dir)
|
|
185
|
+
assert result is None
|
|
186
|
+
|
|
187
|
+
def test_no_dependencies(self, state_dir):
|
|
188
|
+
feature_list_data = {
|
|
189
|
+
"features": [
|
|
190
|
+
{"id": "F-001", "dependencies": []},
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
result = check_dependency_deadlock("F-001", feature_list_data, state_dir)
|
|
194
|
+
assert result is None
|
|
195
|
+
|
|
196
|
+
def test_none_feature_list(self, state_dir):
|
|
197
|
+
result = check_dependency_deadlock("F-001", None, state_dir)
|
|
198
|
+
assert result is None
|
|
199
|
+
|
|
200
|
+
def test_feature_not_found(self, state_dir):
|
|
201
|
+
feature_list_data = {
|
|
202
|
+
"features": [
|
|
203
|
+
{"id": "F-001", "dependencies": []},
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
result = check_dependency_deadlock("F-999", feature_list_data, state_dir)
|
|
207
|
+
assert result is None
|