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.
@@ -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
  # ============================================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atdd
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: ATDD Platform - Acceptance Test Driven Development toolkit
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -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=47cFGE5juBv9ewhtgrNir4b6I9imIIo8VjoD9yvASf4,4578
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/test_session_validation.py,sha256=0VszXtFwRTO04b5CxDPO3klk0VfiqlpdbNpshjMn-qU,39079
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.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
205
- atdd-0.7.2.dist-info/METADATA,sha256=_Rvqn06owPOazCQlN34cO_5tPLWZipNsRU0yfUABKrU,8716
206
- atdd-0.7.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
207
- atdd-0.7.2.dist-info/entry_points.txt,sha256=-C3yrA1WQQfN3iuGmSzPapA5cKVBEYU5Q1HUffSJTbY,38
208
- atdd-0.7.2.dist-info/top_level.txt,sha256=VKkf6Uiyrm4RS6ULCGM-v8AzYN8K2yg8SMqwJLoO-xs,5
209
- atdd-0.7.2.dist-info/RECORD,,
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