atdd 0.7.1__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/commands/registry.py +6 -6
- atdd/coach/commands/traceability.py +5 -5
- atdd/coach/conventions/naming.convention.yaml +296 -0
- atdd/coach/conventions/session.convention.yaml +1 -0
- atdd/coach/schemas/config.schema.json +26 -0
- atdd/coach/templates/ATDD.md +1 -0
- atdd/coach/templates/SESSION-TEMPLATE.md +6 -1
- 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/planner/conventions/train.convention.yaml +17 -0
- atdd/planner/validators/test_plan_wagons.py +70 -3
- atdd/tester/conventions/telemetry.convention.yaml +25 -20
- atdd/tester/schemas/telemetry_tracking_manifest.schema.json +1 -1
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/METADATA +1 -1
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/RECORD +20 -17
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/WHEEL +0 -0
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/entry_points.txt +0 -0
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.7.1.dist-info → atdd-0.7.3.dist-info}/top_level.txt +0 -0
|
@@ -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
|
# ============================================================================
|
|
@@ -257,6 +257,23 @@ registry:
|
|
|
257
257
|
structure: "theme-category-grouped"
|
|
258
258
|
description: "Trains grouped by theme, then by category (nominal/error/alternate/exception) in _trains.yaml manifest"
|
|
259
259
|
|
|
260
|
+
# Two-file pattern: Registry + Per-Train Spec Files
|
|
261
|
+
two_file_pattern:
|
|
262
|
+
description: "Each train requires two files: a registry entry and a spec file"
|
|
263
|
+
files:
|
|
264
|
+
registry:
|
|
265
|
+
path: "plan/_trains.yaml"
|
|
266
|
+
purpose: "Central manifest listing all trains with summary metadata"
|
|
267
|
+
contains: "train_id, description, path, wagons (summary list)"
|
|
268
|
+
spec:
|
|
269
|
+
path: "plan/_trains/{train_id}.yaml"
|
|
270
|
+
purpose: "Full train specification with complete workflow definition"
|
|
271
|
+
contains: "train_id, title, themes, participants, sequence, dependencies, test, code, expectations"
|
|
272
|
+
validation:
|
|
273
|
+
rule: "Every registry entry MUST have a corresponding spec file"
|
|
274
|
+
validator: "SPEC-TRAIN-VAL-0003 (test_train_files_exist_for_registry_entries)"
|
|
275
|
+
enforcement: "Planner phase gate fails if spec file missing"
|
|
276
|
+
|
|
260
277
|
format: |
|
|
261
278
|
trains:
|
|
262
279
|
0-commons:
|
|
@@ -95,6 +95,72 @@ def test_wagon_slugs_match_directory_names(wagon_manifests):
|
|
|
95
95
|
f"Wagon slug '{wagon_slug}' doesn't match expected slug '{expected_slug}' (from directory '{directory_name}') for {path}"
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
@pytest.mark.platform
|
|
99
|
+
def test_wagon_names_follow_verb_object_pattern(wagon_manifests):
|
|
100
|
+
"""
|
|
101
|
+
SPEC-PLATFORM-WAGONS-0008: Wagon names follow verb-object semantic pattern
|
|
102
|
+
|
|
103
|
+
Given: All wagon manifests
|
|
104
|
+
When: Checking wagon name format
|
|
105
|
+
Then: Wagon names follow verb-object pattern (e.g., resolve-dilemmas, commit-state)
|
|
106
|
+
- Format: verb-object (kebab-case, at least 2 hyphen-separated words)
|
|
107
|
+
- First segment should be a verb (action)
|
|
108
|
+
- Subsequent segments describe the object/domain
|
|
109
|
+
Per naming.convention.yaml wagon section
|
|
110
|
+
"""
|
|
111
|
+
import re
|
|
112
|
+
|
|
113
|
+
# Pattern: verb-object with optional additional words (e.g., burn-timebank, juggle-domains)
|
|
114
|
+
# Requires at least 2 segments separated by hyphens
|
|
115
|
+
verb_object_pattern = re.compile(r"^[a-z][a-z0-9]*-[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$")
|
|
116
|
+
|
|
117
|
+
for path, manifest in wagon_manifests:
|
|
118
|
+
wagon_name = manifest.get("wagon", "")
|
|
119
|
+
if wagon_name:
|
|
120
|
+
assert verb_object_pattern.match(wagon_name), \
|
|
121
|
+
f"Wagon {path}: name '{wagon_name}' doesn't follow verb-object pattern (e.g., resolve-dilemmas)"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.platform
|
|
125
|
+
def test_feature_names_follow_verb_object_pattern(wagon_manifests):
|
|
126
|
+
"""
|
|
127
|
+
SPEC-PLATFORM-WAGONS-0009: Feature names follow verb-object semantic pattern
|
|
128
|
+
|
|
129
|
+
Given: All wagon manifests with features[] entries
|
|
130
|
+
When: Checking feature name format from URNs
|
|
131
|
+
Then: Feature names follow verb-object pattern (e.g., capture-choice, pair-fragments)
|
|
132
|
+
- URN format: feature:{wagon}:{feature-name}
|
|
133
|
+
- Feature name requires at least 2 hyphen-separated words
|
|
134
|
+
Per naming.convention.yaml feature section
|
|
135
|
+
"""
|
|
136
|
+
import re
|
|
137
|
+
|
|
138
|
+
# Pattern: verb-object with optional additional words
|
|
139
|
+
verb_object_pattern = re.compile(r"^[a-z][a-z0-9]*-[a-z][a-z0-9]*(-[a-z][a-z0-9]*)*$")
|
|
140
|
+
|
|
141
|
+
for path, manifest in wagon_manifests:
|
|
142
|
+
features_list = manifest.get("features", [])
|
|
143
|
+
wagon_name = manifest.get("wagon", "")
|
|
144
|
+
|
|
145
|
+
for feature in features_list:
|
|
146
|
+
feature_name = None
|
|
147
|
+
|
|
148
|
+
# Extract feature name from URN
|
|
149
|
+
if isinstance(feature, dict) and "urn" in feature:
|
|
150
|
+
urn = feature["urn"]
|
|
151
|
+
parts = urn.split(":")
|
|
152
|
+
if len(parts) >= 3:
|
|
153
|
+
feature_name = parts[2]
|
|
154
|
+
elif isinstance(feature, str) and feature.startswith("feature:"):
|
|
155
|
+
parts = feature.split(":")
|
|
156
|
+
if len(parts) >= 3:
|
|
157
|
+
feature_name = parts[2]
|
|
158
|
+
|
|
159
|
+
if feature_name:
|
|
160
|
+
assert verb_object_pattern.match(feature_name), \
|
|
161
|
+
f"Wagon {wagon_name}: feature '{feature_name}' doesn't follow verb-object pattern (e.g., capture-choice)"
|
|
162
|
+
|
|
163
|
+
|
|
98
164
|
@pytest.mark.platform
|
|
99
165
|
def test_produce_artifact_names_follow_convention(wagon_manifests):
|
|
100
166
|
"""
|
|
@@ -153,9 +219,10 @@ def test_telemetry_urns_match_pattern(wagon_manifests):
|
|
|
153
219
|
|
|
154
220
|
Given: Wagon produce items with non-null telemetry URNs
|
|
155
221
|
When: Validating URN format
|
|
156
|
-
Then: Telemetry URNs match pattern telemetry:{path}:{aspect}
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
Then: Telemetry URNs match pattern telemetry:{path}:{aspect}(.{variant})?
|
|
223
|
+
- Colons (:) for hierarchical descent (e.g., telemetry:commons:ux:foundations)
|
|
224
|
+
- Dots (.) for lateral variants (e.g., telemetry:mechanic:decision.choice)
|
|
225
|
+
Per telemetry.convention.yaml id_vs_urn section
|
|
159
226
|
"""
|
|
160
227
|
import re
|
|
161
228
|
|