atdd 0.7.2__py3-none-any.whl → 0.7.3__py3-none-any.whl
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.
- atdd/coach/schemas/config.schema.json +26 -0
- atdd/coach/validators/test_session_archive_status.py +339 -0
- atdd/coach/validators/test_session_manifest_alignment.py +387 -0
- atdd/coach/validators/test_session_validation.py +161 -0
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/METADATA +1 -1
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/RECORD +10 -8
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/WHEEL +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/entry_points.txt +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.3.dist-info}/top_level.txt +0 -0
|
@@ -132,6 +132,32 @@
|
|
|
132
132
|
}
|
|
133
133
|
},
|
|
134
134
|
"additionalProperties": false
|
|
135
|
+
},
|
|
136
|
+
"coach": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"description": "Coach agent validation settings",
|
|
139
|
+
"properties": {
|
|
140
|
+
"session_gate_command_prefixes": {
|
|
141
|
+
"type": "array",
|
|
142
|
+
"description": "Allowed prefixes for gate commands in session files",
|
|
143
|
+
"items": {"type": "string"},
|
|
144
|
+
"default": ["atdd", "pytest", "python", "npm", "supabase"],
|
|
145
|
+
"examples": [["atdd", "pytest", "npm run test"]]
|
|
146
|
+
},
|
|
147
|
+
"session_gate_command_exceptions": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"description": "Session files exempt from gate command prefix validation (by filename pattern)",
|
|
150
|
+
"items": {"type": "string"},
|
|
151
|
+
"default": [],
|
|
152
|
+
"examples": [["SESSION-00-*.md", "SESSION-01-legacy.md"]]
|
|
153
|
+
},
|
|
154
|
+
"archive_status_warnings": {
|
|
155
|
+
"type": "boolean",
|
|
156
|
+
"description": "Warn when COMPLETE sessions not in archive or archived sessions not COMPLETE",
|
|
157
|
+
"default": true
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"additionalProperties": false
|
|
135
161
|
}
|
|
136
162
|
},
|
|
137
163
|
"required": ["version", "release"],
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session archive status validation.
|
|
3
|
+
|
|
4
|
+
Validates archive placement aligns with session status:
|
|
5
|
+
1. COMPLETE sessions should be in archive/ (warning)
|
|
6
|
+
2. Sessions in archive/ should be COMPLETE or OBSOLETE (warning)
|
|
7
|
+
|
|
8
|
+
These are warnings, not failures, to allow migration flexibility.
|
|
9
|
+
|
|
10
|
+
Convention: src/atdd/coach/conventions/session.convention.yaml
|
|
11
|
+
Config: .atdd/config.yaml (coach.archive_status_warnings)
|
|
12
|
+
|
|
13
|
+
Run: atdd validate coach
|
|
14
|
+
"""
|
|
15
|
+
import pytest
|
|
16
|
+
import yaml
|
|
17
|
+
import fnmatch
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, List, Any, Optional
|
|
20
|
+
|
|
21
|
+
from atdd.coach.utils.repo import find_repo_root
|
|
22
|
+
from atdd.coach.utils.config import load_atdd_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# Configuration
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
REPO_ROOT = find_repo_root()
|
|
30
|
+
SESSIONS_DIR = REPO_ROOT / "atdd-sessions"
|
|
31
|
+
ARCHIVE_DIR = SESSIONS_DIR / "archive"
|
|
32
|
+
|
|
33
|
+
# Terminal statuses that should be archived
|
|
34
|
+
ARCHIVABLE_STATUSES = {"COMPLETE", "OBSOLETE"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ============================================================================
|
|
38
|
+
# Fixtures
|
|
39
|
+
# ============================================================================
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def config() -> Dict[str, Any]:
|
|
43
|
+
"""Load ATDD configuration."""
|
|
44
|
+
return load_atdd_config(REPO_ROOT)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def archive_warnings_enabled(config: Dict[str, Any]) -> bool:
|
|
49
|
+
"""Check if archive status warnings are enabled."""
|
|
50
|
+
return config.get("coach", {}).get("archive_status_warnings", True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def session_files() -> List[Path]:
|
|
55
|
+
"""Get all session files in main directory (not archive)."""
|
|
56
|
+
if not SESSIONS_DIR.exists():
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
return sorted([
|
|
60
|
+
f for f in SESSIONS_DIR.glob("SESSION-*.md")
|
|
61
|
+
if f.name != "SESSION-TEMPLATE.md"
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def archived_files() -> List[Path]:
|
|
67
|
+
"""Get all session files in archive directory."""
|
|
68
|
+
if not ARCHIVE_DIR.exists():
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
return sorted(ARCHIVE_DIR.glob("SESSION-*.md"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_session_status(path: Path) -> Optional[str]:
|
|
75
|
+
"""Parse status from session file frontmatter."""
|
|
76
|
+
content = path.read_text()
|
|
77
|
+
|
|
78
|
+
if not content.startswith("---"):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
parts = content.split("---", 2)
|
|
82
|
+
if len(parts) < 3:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
frontmatter = yaml.safe_load(parts[1])
|
|
87
|
+
status = str(frontmatter.get("status", "")).upper()
|
|
88
|
+
# Handle status with extra info (e.g., "ACTIVE - working")
|
|
89
|
+
return status.split()[0] if status else None
|
|
90
|
+
except yaml.YAMLError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ============================================================================
|
|
95
|
+
# Archive Status Validation Tests
|
|
96
|
+
# ============================================================================
|
|
97
|
+
|
|
98
|
+
@pytest.mark.coach
|
|
99
|
+
def test_complete_sessions_should_be_archived(
|
|
100
|
+
session_files: List[Path],
|
|
101
|
+
archive_warnings_enabled: bool
|
|
102
|
+
):
|
|
103
|
+
"""
|
|
104
|
+
SPEC-COACH-ARCHIVE-001: COMPLETE sessions should be in archive/
|
|
105
|
+
|
|
106
|
+
Given: Session files in main directory
|
|
107
|
+
When: Checking status
|
|
108
|
+
Then: Sessions with COMPLETE/OBSOLETE status should be in archive/
|
|
109
|
+
|
|
110
|
+
Note: This is a warning, not a failure, to allow migration flexibility.
|
|
111
|
+
"""
|
|
112
|
+
if not archive_warnings_enabled:
|
|
113
|
+
pytest.skip("Archive status warnings disabled in config")
|
|
114
|
+
|
|
115
|
+
if not session_files:
|
|
116
|
+
pytest.skip("No session files found")
|
|
117
|
+
|
|
118
|
+
should_archive = []
|
|
119
|
+
|
|
120
|
+
for path in session_files:
|
|
121
|
+
status = parse_session_status(path)
|
|
122
|
+
if status in ARCHIVABLE_STATUSES:
|
|
123
|
+
should_archive.append(f"{path.name} (status: {status})")
|
|
124
|
+
|
|
125
|
+
if should_archive:
|
|
126
|
+
print(
|
|
127
|
+
f"\n⚠️ Found {len(should_archive)} completed sessions not in archive/:\n" +
|
|
128
|
+
"\n".join(f" - {s}" for s in should_archive) +
|
|
129
|
+
"\n\nConsider running 'atdd session archive <id>' to move them."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.coach
|
|
134
|
+
def test_archived_sessions_should_be_complete(
|
|
135
|
+
archived_files: List[Path],
|
|
136
|
+
archive_warnings_enabled: bool
|
|
137
|
+
):
|
|
138
|
+
"""
|
|
139
|
+
SPEC-COACH-ARCHIVE-002: Archived sessions should have COMPLETE/OBSOLETE status
|
|
140
|
+
|
|
141
|
+
Given: Session files in archive/ directory
|
|
142
|
+
When: Checking status
|
|
143
|
+
Then: All archived sessions should have terminal status
|
|
144
|
+
|
|
145
|
+
Note: This is a warning, not a failure, to allow migration flexibility.
|
|
146
|
+
"""
|
|
147
|
+
if not archive_warnings_enabled:
|
|
148
|
+
pytest.skip("Archive status warnings disabled in config")
|
|
149
|
+
|
|
150
|
+
if not archived_files:
|
|
151
|
+
pytest.skip("No archived session files found")
|
|
152
|
+
|
|
153
|
+
not_complete = []
|
|
154
|
+
|
|
155
|
+
for path in archived_files:
|
|
156
|
+
status = parse_session_status(path)
|
|
157
|
+
if status and status not in ARCHIVABLE_STATUSES:
|
|
158
|
+
not_complete.append(f"{path.name} (status: {status})")
|
|
159
|
+
|
|
160
|
+
if not_complete:
|
|
161
|
+
print(
|
|
162
|
+
f"\n⚠️ Found {len(not_complete)} archived sessions without terminal status:\n" +
|
|
163
|
+
"\n".join(f" - {s}" for s in not_complete) +
|
|
164
|
+
"\n\nEither update status to COMPLETE/OBSOLETE or move back to atdd-sessions/."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@pytest.mark.coach
|
|
169
|
+
def test_archive_directory_structure():
|
|
170
|
+
"""
|
|
171
|
+
SPEC-COACH-ARCHIVE-003: Archive directory exists if sessions directory exists
|
|
172
|
+
|
|
173
|
+
Given: atdd-sessions/ directory exists
|
|
174
|
+
When: Checking for archive/ subdirectory
|
|
175
|
+
Then: archive/ directory should exist (create if missing)
|
|
176
|
+
"""
|
|
177
|
+
if not SESSIONS_DIR.exists():
|
|
178
|
+
pytest.skip("Sessions directory not found")
|
|
179
|
+
|
|
180
|
+
if not ARCHIVE_DIR.exists():
|
|
181
|
+
print(
|
|
182
|
+
f"\n⚠️ Archive directory does not exist: {ARCHIVE_DIR}\n"
|
|
183
|
+
"Consider creating it: mkdir -p atdd-sessions/archive/"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ============================================================================
|
|
188
|
+
# Stale Session Detection
|
|
189
|
+
# ============================================================================
|
|
190
|
+
|
|
191
|
+
@pytest.mark.coach
|
|
192
|
+
def test_stale_active_sessions(session_files: List[Path]):
|
|
193
|
+
"""
|
|
194
|
+
SPEC-COACH-ARCHIVE-004: Detect potentially stale ACTIVE sessions
|
|
195
|
+
|
|
196
|
+
Given: Session files with ACTIVE status
|
|
197
|
+
When: Checking file modification time
|
|
198
|
+
Then: Warn about sessions not modified in 7+ days
|
|
199
|
+
"""
|
|
200
|
+
if not session_files:
|
|
201
|
+
pytest.skip("No session files found")
|
|
202
|
+
|
|
203
|
+
from datetime import datetime, timedelta
|
|
204
|
+
|
|
205
|
+
stale_threshold = timedelta(days=7)
|
|
206
|
+
now = datetime.now()
|
|
207
|
+
stale = []
|
|
208
|
+
|
|
209
|
+
for path in session_files:
|
|
210
|
+
status = parse_session_status(path)
|
|
211
|
+
if status != "ACTIVE":
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
|
215
|
+
age = now - mtime
|
|
216
|
+
|
|
217
|
+
if age > stale_threshold:
|
|
218
|
+
days = age.days
|
|
219
|
+
stale.append(f"{path.name} (last modified {days} days ago)")
|
|
220
|
+
|
|
221
|
+
if stale:
|
|
222
|
+
print(
|
|
223
|
+
f"\n⚠️ Found {len(stale)} potentially stale ACTIVE sessions:\n" +
|
|
224
|
+
"\n".join(f" - {s}" for s in stale) +
|
|
225
|
+
"\n\nConsider updating status to BLOCKED, COMPLETE, or OBSOLETE."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.coach
|
|
230
|
+
def test_blocked_sessions_have_reason(session_files: List[Path]):
|
|
231
|
+
"""
|
|
232
|
+
SPEC-COACH-ARCHIVE-005: BLOCKED sessions should document reason
|
|
233
|
+
|
|
234
|
+
Given: Session files with BLOCKED status
|
|
235
|
+
When: Checking session log
|
|
236
|
+
Then: Most recent log entry should explain the blocker
|
|
237
|
+
"""
|
|
238
|
+
if not session_files:
|
|
239
|
+
pytest.skip("No session files found")
|
|
240
|
+
|
|
241
|
+
import re
|
|
242
|
+
|
|
243
|
+
missing_reason = []
|
|
244
|
+
|
|
245
|
+
for path in session_files:
|
|
246
|
+
status = parse_session_status(path)
|
|
247
|
+
if status != "BLOCKED":
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
content = path.read_text()
|
|
251
|
+
|
|
252
|
+
# Check for Blocked: section in session log
|
|
253
|
+
has_blocked_entry = bool(re.search(
|
|
254
|
+
r"\*\*Blocked:\*\*\s*\n\s*-\s*\S",
|
|
255
|
+
content
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
# Or check frontmatter for blocker field
|
|
259
|
+
if content.startswith("---"):
|
|
260
|
+
parts = content.split("---", 2)
|
|
261
|
+
if len(parts) >= 3:
|
|
262
|
+
try:
|
|
263
|
+
frontmatter = yaml.safe_load(parts[1])
|
|
264
|
+
has_blocked_entry = has_blocked_entry or bool(
|
|
265
|
+
frontmatter.get("blocker") or
|
|
266
|
+
frontmatter.get("blocked_by") or
|
|
267
|
+
frontmatter.get("blocked_reason")
|
|
268
|
+
)
|
|
269
|
+
except yaml.YAMLError:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
if not has_blocked_entry:
|
|
273
|
+
missing_reason.append(path.name)
|
|
274
|
+
|
|
275
|
+
if missing_reason:
|
|
276
|
+
print(
|
|
277
|
+
f"\n⚠️ Found {len(missing_reason)} BLOCKED sessions without documented reason:\n" +
|
|
278
|
+
"\n".join(f" - {m}" for m in missing_reason) +
|
|
279
|
+
"\n\nAdd **Blocked:** entry to Session Log or 'blocker' field to frontmatter."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ============================================================================
|
|
284
|
+
# Summary
|
|
285
|
+
# ============================================================================
|
|
286
|
+
|
|
287
|
+
@pytest.mark.coach
|
|
288
|
+
def test_archive_status_summary(
|
|
289
|
+
session_files: List[Path],
|
|
290
|
+
archived_files: List[Path]
|
|
291
|
+
):
|
|
292
|
+
"""
|
|
293
|
+
Generate archive status summary.
|
|
294
|
+
|
|
295
|
+
This test always passes but prints a summary.
|
|
296
|
+
"""
|
|
297
|
+
print("\n" + "=" * 60)
|
|
298
|
+
print("SESSION ARCHIVE STATUS SUMMARY")
|
|
299
|
+
print("=" * 60)
|
|
300
|
+
|
|
301
|
+
# Count by status in main directory
|
|
302
|
+
main_statuses: Dict[str, int] = {}
|
|
303
|
+
for path in session_files:
|
|
304
|
+
status = parse_session_status(path) or "UNKNOWN"
|
|
305
|
+
main_statuses[status] = main_statuses.get(status, 0) + 1
|
|
306
|
+
|
|
307
|
+
# Count by status in archive
|
|
308
|
+
archive_statuses: Dict[str, int] = {}
|
|
309
|
+
for path in archived_files:
|
|
310
|
+
status = parse_session_status(path) or "UNKNOWN"
|
|
311
|
+
archive_statuses[status] = archive_statuses.get(status, 0) + 1
|
|
312
|
+
|
|
313
|
+
print(f"\nMain directory ({len(session_files)} files):")
|
|
314
|
+
for status, count in sorted(main_statuses.items()):
|
|
315
|
+
marker = "⚠️ " if status in ARCHIVABLE_STATUSES else " "
|
|
316
|
+
print(f" {marker}{status}: {count}")
|
|
317
|
+
|
|
318
|
+
print(f"\nArchive directory ({len(archived_files)} files):")
|
|
319
|
+
for status, count in sorted(archive_statuses.items()):
|
|
320
|
+
marker = "⚠️ " if status not in ARCHIVABLE_STATUSES else " "
|
|
321
|
+
print(f" {marker}{status}: {count}")
|
|
322
|
+
|
|
323
|
+
# Summary advice
|
|
324
|
+
main_archivable = sum(
|
|
325
|
+
main_statuses.get(s, 0) for s in ARCHIVABLE_STATUSES
|
|
326
|
+
)
|
|
327
|
+
archive_active = sum(
|
|
328
|
+
archive_statuses.get(s, 0) for s in archive_statuses
|
|
329
|
+
if s not in ARCHIVABLE_STATUSES
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if main_archivable > 0 or archive_active > 0:
|
|
333
|
+
print("\n⚠️ Recommendations:")
|
|
334
|
+
if main_archivable > 0:
|
|
335
|
+
print(f" - Archive {main_archivable} completed session(s)")
|
|
336
|
+
if archive_active > 0:
|
|
337
|
+
print(f" - Review {archive_active} non-terminal archived session(s)")
|
|
338
|
+
|
|
339
|
+
print("\n" + "=" * 60)
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session-manifest alignment validation.
|
|
3
|
+
|
|
4
|
+
Ensures session files and manifest entries are synchronized:
|
|
5
|
+
1. Every manifest entry has a corresponding session file
|
|
6
|
+
2. Every session file has a manifest entry
|
|
7
|
+
3. Status in session frontmatter matches manifest status
|
|
8
|
+
|
|
9
|
+
Convention: src/atdd/coach/conventions/session.convention.yaml
|
|
10
|
+
Schema: src/atdd/coach/schemas/manifest.schema.json
|
|
11
|
+
|
|
12
|
+
Run: atdd validate coach
|
|
13
|
+
"""
|
|
14
|
+
import pytest
|
|
15
|
+
import yaml
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from atdd.coach.utils.repo import find_repo_root
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# Configuration
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
REPO_ROOT = find_repo_root()
|
|
27
|
+
SESSIONS_DIR = REPO_ROOT / "atdd-sessions"
|
|
28
|
+
ARCHIVE_DIR = SESSIONS_DIR / "archive"
|
|
29
|
+
MANIFEST_FILE = REPO_ROOT / ".atdd" / "manifest.yaml"
|
|
30
|
+
|
|
31
|
+
# Valid statuses from convention
|
|
32
|
+
VALID_STATUSES = {"INIT", "PLANNED", "ACTIVE", "BLOCKED", "COMPLETE", "OBSOLETE", "UNKNOWN"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ============================================================================
|
|
36
|
+
# Fixtures
|
|
37
|
+
# ============================================================================
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def manifest() -> Optional[Dict[str, Any]]:
|
|
41
|
+
"""Load session manifest from .atdd/manifest.yaml."""
|
|
42
|
+
if not MANIFEST_FILE.exists():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
with open(MANIFEST_FILE) as f:
|
|
46
|
+
return yaml.safe_load(f)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def session_files() -> List[Path]:
|
|
51
|
+
"""Get all session files (excluding template, including archive)."""
|
|
52
|
+
if not SESSIONS_DIR.exists():
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
files = []
|
|
56
|
+
|
|
57
|
+
# Main sessions directory
|
|
58
|
+
for f in SESSIONS_DIR.glob("SESSION-*.md"):
|
|
59
|
+
if f.name != "SESSION-TEMPLATE.md":
|
|
60
|
+
files.append(f)
|
|
61
|
+
|
|
62
|
+
# Archive directory
|
|
63
|
+
if ARCHIVE_DIR.exists():
|
|
64
|
+
for f in ARCHIVE_DIR.glob("SESSION-*.md"):
|
|
65
|
+
files.append(f)
|
|
66
|
+
|
|
67
|
+
return sorted(files)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_session_frontmatter(path: Path) -> Optional[Dict[str, Any]]:
|
|
71
|
+
"""Parse YAML frontmatter from session file."""
|
|
72
|
+
content = path.read_text()
|
|
73
|
+
|
|
74
|
+
if not content.startswith("---"):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
parts = content.split("---", 2)
|
|
78
|
+
if len(parts) < 3:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return yaml.safe_load(parts[1])
|
|
83
|
+
except yaml.YAMLError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_session_id_from_filename(filename: str) -> Optional[str]:
|
|
88
|
+
"""Extract session ID from filename (e.g., SESSION-01-foo.md -> 01)."""
|
|
89
|
+
import re
|
|
90
|
+
match = re.match(r"SESSION-(\d{2})-", filename)
|
|
91
|
+
return match.group(1) if match else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ============================================================================
|
|
95
|
+
# Manifest Entry Validation Tests
|
|
96
|
+
# ============================================================================
|
|
97
|
+
|
|
98
|
+
@pytest.mark.coach
|
|
99
|
+
def test_manifest_exists():
|
|
100
|
+
"""
|
|
101
|
+
SPEC-COACH-MANIFEST-001: Manifest file exists
|
|
102
|
+
|
|
103
|
+
Given: Initialized ATDD project
|
|
104
|
+
When: Checking for manifest file
|
|
105
|
+
Then: .atdd/manifest.yaml exists
|
|
106
|
+
"""
|
|
107
|
+
if not (REPO_ROOT / ".atdd").exists():
|
|
108
|
+
pytest.skip("ATDD not initialized (no .atdd/ directory)")
|
|
109
|
+
|
|
110
|
+
assert MANIFEST_FILE.exists(), \
|
|
111
|
+
f"Manifest file not found at {MANIFEST_FILE}. Run 'atdd init' first."
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.coach
|
|
115
|
+
def test_manifest_entries_have_session_files(manifest: Optional[Dict], session_files: List[Path]):
|
|
116
|
+
"""
|
|
117
|
+
SPEC-COACH-MANIFEST-002: Every manifest entry has a corresponding session file
|
|
118
|
+
|
|
119
|
+
Given: Session manifest with entries
|
|
120
|
+
When: Checking for session files
|
|
121
|
+
Then: Each manifest entry has a matching file on disk
|
|
122
|
+
"""
|
|
123
|
+
if manifest is None:
|
|
124
|
+
pytest.skip("No manifest file found")
|
|
125
|
+
|
|
126
|
+
sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
|
|
127
|
+
missing = []
|
|
128
|
+
|
|
129
|
+
for entry in manifest.get("sessions", []):
|
|
130
|
+
file_path = entry.get("file", "")
|
|
131
|
+
session_id = entry.get("id", "")
|
|
132
|
+
|
|
133
|
+
# Resolve full path
|
|
134
|
+
full_path = REPO_ROOT / sessions_dir / file_path
|
|
135
|
+
|
|
136
|
+
if not full_path.exists():
|
|
137
|
+
missing.append(f"id={session_id}: {file_path}")
|
|
138
|
+
|
|
139
|
+
if missing:
|
|
140
|
+
pytest.fail(
|
|
141
|
+
f"Found {len(missing)} manifest entries without session files:\n" +
|
|
142
|
+
"\n".join(f" - {m}" for m in missing)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.coach
|
|
147
|
+
def test_session_files_have_manifest_entries(manifest: Optional[Dict], session_files: List[Path]):
|
|
148
|
+
"""
|
|
149
|
+
SPEC-COACH-MANIFEST-003: Every session file has a manifest entry
|
|
150
|
+
|
|
151
|
+
Given: Session files in atdd-sessions/
|
|
152
|
+
When: Checking manifest entries
|
|
153
|
+
Then: Each session file has a corresponding manifest entry
|
|
154
|
+
"""
|
|
155
|
+
if manifest is None:
|
|
156
|
+
pytest.skip("No manifest file found")
|
|
157
|
+
|
|
158
|
+
if not session_files:
|
|
159
|
+
pytest.skip("No session files found")
|
|
160
|
+
|
|
161
|
+
# Build set of manifest file paths
|
|
162
|
+
sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
|
|
163
|
+
manifest_files = set()
|
|
164
|
+
|
|
165
|
+
for entry in manifest.get("sessions", []):
|
|
166
|
+
file_path = entry.get("file", "")
|
|
167
|
+
manifest_files.add(file_path)
|
|
168
|
+
|
|
169
|
+
# Check each session file
|
|
170
|
+
orphaned = []
|
|
171
|
+
|
|
172
|
+
for session_path in session_files:
|
|
173
|
+
# Compute relative path from sessions_dir
|
|
174
|
+
try:
|
|
175
|
+
rel_path = session_path.relative_to(REPO_ROOT / sessions_dir)
|
|
176
|
+
rel_str = str(rel_path)
|
|
177
|
+
except ValueError:
|
|
178
|
+
rel_str = session_path.name
|
|
179
|
+
|
|
180
|
+
if rel_str not in manifest_files:
|
|
181
|
+
orphaned.append(session_path.name)
|
|
182
|
+
|
|
183
|
+
if orphaned:
|
|
184
|
+
pytest.fail(
|
|
185
|
+
f"Found {len(orphaned)} session files without manifest entries:\n" +
|
|
186
|
+
"\n".join(f" - {o}" for o in orphaned) +
|
|
187
|
+
"\n\nRun 'atdd session sync' to update manifest."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@pytest.mark.coach
|
|
192
|
+
def test_manifest_status_matches_session_frontmatter(manifest: Optional[Dict]):
|
|
193
|
+
"""
|
|
194
|
+
SPEC-COACH-MANIFEST-004: Manifest status matches session frontmatter status
|
|
195
|
+
|
|
196
|
+
Given: Session manifest with status fields
|
|
197
|
+
When: Comparing to session file frontmatter
|
|
198
|
+
Then: Status values match (or warning on mismatch)
|
|
199
|
+
"""
|
|
200
|
+
if manifest is None:
|
|
201
|
+
pytest.skip("No manifest file found")
|
|
202
|
+
|
|
203
|
+
sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
|
|
204
|
+
mismatches = []
|
|
205
|
+
|
|
206
|
+
for entry in manifest.get("sessions", []):
|
|
207
|
+
file_path = entry.get("file", "")
|
|
208
|
+
session_id = entry.get("id", "")
|
|
209
|
+
manifest_status = entry.get("status", "UNKNOWN").upper()
|
|
210
|
+
|
|
211
|
+
# Load session file
|
|
212
|
+
full_path = REPO_ROOT / sessions_dir / file_path
|
|
213
|
+
if not full_path.exists():
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
frontmatter = parse_session_frontmatter(full_path)
|
|
217
|
+
if frontmatter is None:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
# Compare status
|
|
221
|
+
file_status = str(frontmatter.get("status", "UNKNOWN")).upper()
|
|
222
|
+
# Handle status with extra info (e.g., "ACTIVE - working on X")
|
|
223
|
+
file_status_word = file_status.split()[0] if file_status else "UNKNOWN"
|
|
224
|
+
|
|
225
|
+
if file_status_word != manifest_status:
|
|
226
|
+
mismatches.append(
|
|
227
|
+
f"SESSION-{session_id}: manifest={manifest_status}, file={file_status_word}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if mismatches:
|
|
231
|
+
# Warn but don't fail - mismatches may be intentional during transitions
|
|
232
|
+
print(
|
|
233
|
+
f"\n⚠️ Found {len(mismatches)} status mismatches between manifest and files:\n" +
|
|
234
|
+
"\n".join(f" - {m}" for m in mismatches) +
|
|
235
|
+
"\n\nRun 'atdd session sync' to reconcile."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.mark.coach
|
|
240
|
+
def test_manifest_type_matches_session_frontmatter(manifest: Optional[Dict]):
|
|
241
|
+
"""
|
|
242
|
+
SPEC-COACH-MANIFEST-005: Manifest type matches session frontmatter type
|
|
243
|
+
|
|
244
|
+
Given: Session manifest with type fields
|
|
245
|
+
When: Comparing to session file frontmatter
|
|
246
|
+
Then: Type values match
|
|
247
|
+
"""
|
|
248
|
+
if manifest is None:
|
|
249
|
+
pytest.skip("No manifest file found")
|
|
250
|
+
|
|
251
|
+
sessions_dir = manifest.get("sessions_dir", "atdd-sessions")
|
|
252
|
+
mismatches = []
|
|
253
|
+
|
|
254
|
+
for entry in manifest.get("sessions", []):
|
|
255
|
+
file_path = entry.get("file", "")
|
|
256
|
+
session_id = entry.get("id", "")
|
|
257
|
+
manifest_type = entry.get("type", "unknown").lower()
|
|
258
|
+
|
|
259
|
+
# Load session file
|
|
260
|
+
full_path = REPO_ROOT / sessions_dir / file_path
|
|
261
|
+
if not full_path.exists():
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
frontmatter = parse_session_frontmatter(full_path)
|
|
265
|
+
if frontmatter is None:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Compare type
|
|
269
|
+
file_type = str(frontmatter.get("type", "unknown")).lower()
|
|
270
|
+
|
|
271
|
+
if file_type != manifest_type:
|
|
272
|
+
mismatches.append(
|
|
273
|
+
f"SESSION-{session_id}: manifest={manifest_type}, file={file_type}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if mismatches:
|
|
277
|
+
pytest.fail(
|
|
278
|
+
f"Found {len(mismatches)} type mismatches between manifest and files:\n" +
|
|
279
|
+
"\n".join(f" - {m}" for m in mismatches)
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.coach
|
|
284
|
+
def test_manifest_ids_are_unique(manifest: Optional[Dict]):
|
|
285
|
+
"""
|
|
286
|
+
SPEC-COACH-MANIFEST-006: Manifest session IDs are unique
|
|
287
|
+
|
|
288
|
+
Given: Session manifest
|
|
289
|
+
When: Checking session IDs
|
|
290
|
+
Then: No duplicate IDs exist
|
|
291
|
+
"""
|
|
292
|
+
if manifest is None:
|
|
293
|
+
pytest.skip("No manifest file found")
|
|
294
|
+
|
|
295
|
+
ids = {}
|
|
296
|
+
duplicates = []
|
|
297
|
+
|
|
298
|
+
for entry in manifest.get("sessions", []):
|
|
299
|
+
session_id = entry.get("id", "")
|
|
300
|
+
file_path = entry.get("file", "")
|
|
301
|
+
|
|
302
|
+
if session_id in ids:
|
|
303
|
+
duplicates.append(f"id={session_id}: {ids[session_id]} AND {file_path}")
|
|
304
|
+
else:
|
|
305
|
+
ids[session_id] = file_path
|
|
306
|
+
|
|
307
|
+
if duplicates:
|
|
308
|
+
pytest.fail(
|
|
309
|
+
f"Found duplicate session IDs in manifest:\n" +
|
|
310
|
+
"\n".join(f" - {d}" for d in duplicates)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@pytest.mark.coach
|
|
315
|
+
def test_manifest_file_ids_match_filename(manifest: Optional[Dict]):
|
|
316
|
+
"""
|
|
317
|
+
SPEC-COACH-MANIFEST-007: Manifest entry ID matches filename
|
|
318
|
+
|
|
319
|
+
Given: Session manifest entries
|
|
320
|
+
When: Comparing ID to filename pattern
|
|
321
|
+
Then: Entry ID matches SESSION-{ID}-* in filename
|
|
322
|
+
"""
|
|
323
|
+
if manifest is None:
|
|
324
|
+
pytest.skip("No manifest file found")
|
|
325
|
+
|
|
326
|
+
mismatches = []
|
|
327
|
+
|
|
328
|
+
for entry in manifest.get("sessions", []):
|
|
329
|
+
session_id = entry.get("id", "")
|
|
330
|
+
file_path = entry.get("file", "")
|
|
331
|
+
|
|
332
|
+
# Extract ID from filename
|
|
333
|
+
filename = Path(file_path).name
|
|
334
|
+
file_id = get_session_id_from_filename(filename)
|
|
335
|
+
|
|
336
|
+
if file_id and file_id != session_id:
|
|
337
|
+
mismatches.append(
|
|
338
|
+
f"id={session_id} but filename has id={file_id}: {file_path}"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if mismatches:
|
|
342
|
+
pytest.fail(
|
|
343
|
+
f"Found {len(mismatches)} ID mismatches between manifest and filenames:\n" +
|
|
344
|
+
"\n".join(f" - {m}" for m in mismatches)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ============================================================================
|
|
349
|
+
# Summary
|
|
350
|
+
# ============================================================================
|
|
351
|
+
|
|
352
|
+
@pytest.mark.coach
|
|
353
|
+
def test_manifest_alignment_summary(manifest: Optional[Dict], session_files: List[Path]):
|
|
354
|
+
"""
|
|
355
|
+
Generate a summary of manifest alignment.
|
|
356
|
+
|
|
357
|
+
This test always passes but prints a summary.
|
|
358
|
+
"""
|
|
359
|
+
if manifest is None:
|
|
360
|
+
print("\n⚠️ No manifest file found. Run 'atdd init' to create one.")
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
manifest_count = len(manifest.get("sessions", []))
|
|
364
|
+
file_count = len(session_files)
|
|
365
|
+
|
|
366
|
+
print("\n" + "=" * 60)
|
|
367
|
+
print("SESSION MANIFEST ALIGNMENT SUMMARY")
|
|
368
|
+
print("=" * 60)
|
|
369
|
+
print(f"\nManifest entries: {manifest_count}")
|
|
370
|
+
print(f"Session files: {file_count}")
|
|
371
|
+
|
|
372
|
+
if manifest_count == file_count:
|
|
373
|
+
print("\n✅ Manifest and files are aligned")
|
|
374
|
+
else:
|
|
375
|
+
print(f"\n⚠️ Difference: {abs(manifest_count - file_count)} entries")
|
|
376
|
+
|
|
377
|
+
# Status breakdown
|
|
378
|
+
statuses = {}
|
|
379
|
+
for entry in manifest.get("sessions", []):
|
|
380
|
+
status = entry.get("status", "UNKNOWN")
|
|
381
|
+
statuses[status] = statuses.get(status, 0) + 1
|
|
382
|
+
|
|
383
|
+
print("\nBy Status:")
|
|
384
|
+
for status, count in sorted(statuses.items()):
|
|
385
|
+
print(f" {status}: {count}")
|
|
386
|
+
|
|
387
|
+
print("\n" + "=" * 60)
|
|
@@ -21,6 +21,7 @@ from typing import List, Dict, Optional, Set, Tuple, Any
|
|
|
21
21
|
import yaml
|
|
22
22
|
|
|
23
23
|
from atdd.coach.utils.repo import find_repo_root
|
|
24
|
+
from atdd.coach.utils.config import load_atdd_config
|
|
24
25
|
|
|
25
26
|
# ============================================================================
|
|
26
27
|
# Configuration
|
|
@@ -498,6 +499,166 @@ def test_session_has_gate_commands(active_session_files: List[Path]):
|
|
|
498
499
|
pytest.fail(f"Missing gate commands:\n" + "\n".join(f" - {m}" for m in missing))
|
|
499
500
|
|
|
500
501
|
|
|
502
|
+
# ============================================================================
|
|
503
|
+
# Gate Command Prefix Validation
|
|
504
|
+
# ============================================================================
|
|
505
|
+
|
|
506
|
+
# Default allowed command prefixes
|
|
507
|
+
DEFAULT_GATE_COMMAND_PREFIXES = [
|
|
508
|
+
"atdd",
|
|
509
|
+
"pytest",
|
|
510
|
+
"python",
|
|
511
|
+
"npm",
|
|
512
|
+
"supabase",
|
|
513
|
+
"cd", # Allow cd for directory changes before commands
|
|
514
|
+
]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@pytest.fixture
|
|
518
|
+
def coach_config() -> Dict[str, Any]:
|
|
519
|
+
"""Load coach configuration from .atdd/config.yaml."""
|
|
520
|
+
config = load_atdd_config(REPO_ROOT)
|
|
521
|
+
return config.get("coach", {})
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@pytest.fixture
|
|
525
|
+
def gate_command_prefixes(coach_config: Dict[str, Any]) -> List[str]:
|
|
526
|
+
"""Get allowed gate command prefixes from config."""
|
|
527
|
+
return coach_config.get(
|
|
528
|
+
"session_gate_command_prefixes",
|
|
529
|
+
DEFAULT_GATE_COMMAND_PREFIXES
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@pytest.fixture
|
|
534
|
+
def gate_command_exceptions(coach_config: Dict[str, Any]) -> List[str]:
|
|
535
|
+
"""Get session files exempt from gate command validation."""
|
|
536
|
+
return coach_config.get("session_gate_command_exceptions", [])
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def extract_gate_commands(body: str) -> List[str]:
|
|
540
|
+
"""
|
|
541
|
+
Extract commands from code blocks in session body.
|
|
542
|
+
|
|
543
|
+
Returns list of command strings found in ```bash or ```shell blocks.
|
|
544
|
+
"""
|
|
545
|
+
import re
|
|
546
|
+
|
|
547
|
+
commands = []
|
|
548
|
+
|
|
549
|
+
# Match code blocks with bash/shell language hint
|
|
550
|
+
code_block_pattern = re.compile(
|
|
551
|
+
r"```(?:bash|shell|sh)?\n(.*?)```",
|
|
552
|
+
re.DOTALL
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
for match in code_block_pattern.finditer(body):
|
|
556
|
+
block_content = match.group(1)
|
|
557
|
+
# Extract individual commands (non-empty lines)
|
|
558
|
+
for line in block_content.strip().split('\n'):
|
|
559
|
+
line = line.strip()
|
|
560
|
+
# Skip comments and empty lines
|
|
561
|
+
if line and not line.startswith('#'):
|
|
562
|
+
commands.append(line)
|
|
563
|
+
|
|
564
|
+
return commands
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def command_matches_prefix(command: str, prefixes: List[str]) -> bool:
|
|
568
|
+
"""
|
|
569
|
+
Check if command starts with an allowed prefix.
|
|
570
|
+
|
|
571
|
+
Handles:
|
|
572
|
+
- Direct prefix match: "pytest tests/"
|
|
573
|
+
- Path prefix: "./atdd/atdd.py" matches "atdd"
|
|
574
|
+
- Chained commands: "cd foo && pytest" (each part checked)
|
|
575
|
+
"""
|
|
576
|
+
import re
|
|
577
|
+
|
|
578
|
+
# Split on && and || to check each command part
|
|
579
|
+
parts = re.split(r'\s*(?:&&|\|\|)\s*', command)
|
|
580
|
+
|
|
581
|
+
for part in parts:
|
|
582
|
+
part = part.strip()
|
|
583
|
+
if not part:
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
# Check direct prefix match
|
|
587
|
+
matched = False
|
|
588
|
+
for prefix in prefixes:
|
|
589
|
+
# Direct match
|
|
590
|
+
if part.startswith(prefix + " ") or part == prefix:
|
|
591
|
+
matched = True
|
|
592
|
+
break
|
|
593
|
+
# Path match (e.g., ./atdd/atdd.py matches "atdd")
|
|
594
|
+
if "/" in part:
|
|
595
|
+
cmd_name = part.split()[0].split("/")[-1]
|
|
596
|
+
# Remove .py extension
|
|
597
|
+
if cmd_name.endswith(".py"):
|
|
598
|
+
cmd_name = cmd_name[:-3]
|
|
599
|
+
if cmd_name == prefix:
|
|
600
|
+
matched = True
|
|
601
|
+
break
|
|
602
|
+
|
|
603
|
+
if not matched:
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_session_gate_commands_use_canonical_prefixes(
|
|
610
|
+
active_session_files: List[Path],
|
|
611
|
+
gate_command_prefixes: List[str],
|
|
612
|
+
gate_command_exceptions: List[str]
|
|
613
|
+
):
|
|
614
|
+
"""
|
|
615
|
+
Test that gate commands use canonical command prefixes.
|
|
616
|
+
|
|
617
|
+
Given: Active session files with gate commands
|
|
618
|
+
When: Checking command prefixes in code blocks
|
|
619
|
+
Then: All commands start with allowed prefixes from config
|
|
620
|
+
|
|
621
|
+
Configurable via .atdd/config.yaml:
|
|
622
|
+
coach:
|
|
623
|
+
session_gate_command_prefixes: ["atdd", "pytest", "npm"]
|
|
624
|
+
session_gate_command_exceptions: ["SESSION-00-*.md"]
|
|
625
|
+
"""
|
|
626
|
+
import fnmatch
|
|
627
|
+
|
|
628
|
+
violations = []
|
|
629
|
+
|
|
630
|
+
for f in active_session_files:
|
|
631
|
+
# Check if file is exempt
|
|
632
|
+
is_exempt = any(
|
|
633
|
+
fnmatch.fnmatch(f.name, pattern)
|
|
634
|
+
for pattern in gate_command_exceptions
|
|
635
|
+
)
|
|
636
|
+
if is_exempt:
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
parsed = parse_session_file(f)
|
|
640
|
+
body = parsed["body"]
|
|
641
|
+
|
|
642
|
+
commands = extract_gate_commands(body)
|
|
643
|
+
|
|
644
|
+
for cmd in commands:
|
|
645
|
+
if not command_matches_prefix(cmd, gate_command_prefixes):
|
|
646
|
+
# Extract first word for clearer error message
|
|
647
|
+
first_word = cmd.split()[0] if cmd.split() else cmd
|
|
648
|
+
violations.append(
|
|
649
|
+
f"{f.name}: '{first_word}...' not in allowed prefixes"
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
if violations:
|
|
653
|
+
pytest.fail(
|
|
654
|
+
f"Found {len(violations)} gate commands with non-canonical prefixes:\n" +
|
|
655
|
+
"\n".join(f" - {v}" for v in violations[:20]) +
|
|
656
|
+
(f"\n ... and {len(violations) - 20} more" if len(violations) > 20 else "") +
|
|
657
|
+
f"\n\nAllowed prefixes: {gate_command_prefixes}\n"
|
|
658
|
+
"Configure in .atdd/config.yaml: coach.session_gate_command_prefixes"
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
501
662
|
# ============================================================================
|
|
502
663
|
# Pre-Implementation Gate
|
|
503
664
|
# ============================================================================
|
|
@@ -26,7 +26,7 @@ atdd/coach/conventions/naming.convention.yaml,sha256=GHqymDducgMxcMmSUwoKE6xXGEv
|
|
|
26
26
|
atdd/coach/conventions/session.convention.yaml,sha256=Z4hCFNjOizgDXTRVqNZ4VaG40TIFF31zDkMZdA-xrvI,26084
|
|
27
27
|
atdd/coach/overlays/__init__.py,sha256=2lMiMSgfLJ3YHLpbzNI5B88AdQxiMEwjIfsWWb8t3To,123
|
|
28
28
|
atdd/coach/overlays/claude.md,sha256=33mhpqhmsRhCtdWlU7cMXAJDsaVra9uBBK8URV8OtQA,101
|
|
29
|
-
atdd/coach/schemas/config.schema.json,sha256=
|
|
29
|
+
atdd/coach/schemas/config.schema.json,sha256=OAxiJizlpmO-A_Mi-InSKpLr4AQ9t8u9lQBrPU81q2c,5605
|
|
30
30
|
atdd/coach/schemas/manifest.schema.json,sha256=WO13-YF_FgH1awh96khCtk-112b6XSC24anlY3B7GjY,2885
|
|
31
31
|
atdd/coach/templates/ATDD.md,sha256=JTSPm-J2Xr6XfPodDCxnVyrKoMf2eHF2rlFw8ft6RLQ,13277
|
|
32
32
|
atdd/coach/templates/SESSION-TEMPLATE.md,sha256=uuvSR-OBVb1QrmU5HcS0iO9ohng0o2nVg-qaAe76HY4,9894
|
|
@@ -43,7 +43,9 @@ atdd/coach/validators/shared_fixtures.py,sha256=Ia3B2fUW-aKibwVPF6RnRemtu3R_Dfb-
|
|
|
43
43
|
atdd/coach/validators/test_enrich_wagon_registry.py,sha256=WeTwYJqoNY6mEYc-QAvQo7YVagSOjaNKxB6Q6dpWqIM,6561
|
|
44
44
|
atdd/coach/validators/test_registry.py,sha256=ffN70yA_1xxL3R8gdpGbY2M8dQXyuajIZhBZ-ylNiNs,17845
|
|
45
45
|
atdd/coach/validators/test_release_versioning.py,sha256=B40DfbtrSGguPc537zXmjT75hhySfocWLzJWqOKZQcU,5678
|
|
46
|
-
atdd/coach/validators/
|
|
46
|
+
atdd/coach/validators/test_session_archive_status.py,sha256=w-LHy-1oB2Dvv-SRF8L9TOzjdxHI3d90SiWaHXV-s8Y,10676
|
|
47
|
+
atdd/coach/validators/test_session_manifest_alignment.py,sha256=5BHVIxY8ToU5A9gIg_jNqa3LyC2Ty6Br-Y5TlwzMDJg,11924
|
|
48
|
+
atdd/coach/validators/test_session_validation.py,sha256=8qvW5lnCNlawzhLdP9vrE89synEscVLGuJnpS7WqYC4,43981
|
|
47
49
|
atdd/coach/validators/test_traceability.py,sha256=qTyobt41VBiCr6xRN2C7BPtGYvk_2poVQIe814Blt8E,15977
|
|
48
50
|
atdd/coach/validators/test_train_registry.py,sha256=YDnmC1MP4TfAMZzyldh0hfHMo7LY27DZ2GXmhQx7vds,6021
|
|
49
51
|
atdd/coach/validators/test_update_feature_paths.py,sha256=zOKVDgEIpncSJwDh_shyyou5Pu-Ai7Z_XgF8zAbQVTA,4528
|
|
@@ -201,9 +203,9 @@ atdd/tester/validators/test_train_frontend_e2e.py,sha256=fpfUwTbAWzuqxbVKoaFw-ab
|
|
|
201
203
|
atdd/tester/validators/test_train_frontend_python.py,sha256=KK2U3oNFWLyBK7YHC0fU7shR05k93gVcO762AI8Q3pw,9018
|
|
202
204
|
atdd/tester/validators/test_typescript_test_naming.py,sha256=E-TyGv_GVlTfsbyuxrtv9sOWSZS_QcpH6rrJFbWoeeU,11280
|
|
203
205
|
atdd/tester/validators/test_typescript_test_structure.py,sha256=eV89SD1RaKtchBZupqhnJmaruoROosf3LwB4Fwe4UJI,2612
|
|
204
|
-
atdd-0.7.
|
|
205
|
-
atdd-0.7.
|
|
206
|
-
atdd-0.7.
|
|
207
|
-
atdd-0.7.
|
|
208
|
-
atdd-0.7.
|
|
209
|
-
atdd-0.7.
|
|
206
|
+
atdd-0.7.3.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
207
|
+
atdd-0.7.3.dist-info/METADATA,sha256=zGebjwsy2XRAV0Uh7ZzY8A0S-AtC5HvHtLzBrH0HTKM,8716
|
|
208
|
+
atdd-0.7.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
209
|
+
atdd-0.7.3.dist-info/entry_points.txt,sha256=-C3yrA1WQQfN3iuGmSzPapA5cKVBEYU5Q1HUffSJTbY,38
|
|
210
|
+
atdd-0.7.3.dist-info/top_level.txt,sha256=VKkf6Uiyrm4RS6ULCGM-v8AzYN8K2yg8SMqwJLoO-xs,5
|
|
211
|
+
atdd-0.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|