atdd 0.7.2__py3-none-any.whl → 0.7.4__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/planner/conventions/wagon.convention.yaml +27 -19
- atdd/tester/conventions/artifact.convention.yaml +93 -74
- atdd/tester/validators/test_artifact_naming_category.py +132 -145
- atdd/tester/validators/test_contracts_structure.py +5 -2
- {atdd-0.7.2.dist-info → atdd-0.7.4.dist-info}/METADATA +1 -1
- {atdd-0.7.2.dist-info → atdd-0.7.4.dist-info}/RECORD +14 -12
- {atdd-0.7.2.dist-info → atdd-0.7.4.dist-info}/WHEEL +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.4.dist-info}/entry_points.txt +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.4.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.7.2.dist-info → atdd-0.7.4.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
|
# ============================================================================
|
|
@@ -177,30 +177,35 @@ artifact_contracts:
|
|
|
177
177
|
note: "Full artifact contract conventions defined in artifact.convention.yaml"
|
|
178
178
|
|
|
179
179
|
urn_format:
|
|
180
|
-
contract_urn: "contract:
|
|
181
|
-
telemetry_urn: "telemetry:
|
|
180
|
+
contract_urn: "contract:{theme}(:{category})*:{aspect}(.{variant})?"
|
|
181
|
+
telemetry_urn: "telemetry:{theme}(:{category})*:{aspect}(.{variant})?"
|
|
182
|
+
examples:
|
|
183
|
+
- "contract:commons:ux:foundations:color"
|
|
184
|
+
- "contract:mechanic:decision.choice"
|
|
185
|
+
- "contract:sensory:gesture.raw"
|
|
186
|
+
- "telemetry:commons:ux:foundations"
|
|
182
187
|
|
|
183
188
|
telemetry_filesystem:
|
|
184
189
|
description: "Telemetry URN maps to filesystem directory containing signal files"
|
|
185
|
-
pattern: "telemetry/{
|
|
186
|
-
urn_to_path: "telemetry:{
|
|
190
|
+
pattern: "telemetry/{segments}/{signal-type}.{plane}[.{measure}].json"
|
|
191
|
+
urn_to_path: "telemetry:{theme}:{segments} → telemetry/{theme}/{segments}/"
|
|
187
192
|
|
|
188
193
|
examples:
|
|
189
194
|
- urn: "telemetry:mechanic:decision.choice"
|
|
190
|
-
path: "telemetry/decision/choice/"
|
|
195
|
+
path: "telemetry/mechanic/decision/choice/"
|
|
191
196
|
files:
|
|
192
197
|
- "metric.db.count.json"
|
|
193
198
|
- "metric.be.duration.json"
|
|
194
199
|
- "event.be.json"
|
|
195
200
|
|
|
196
|
-
- urn: "telemetry:ux:foundations"
|
|
197
|
-
path: "telemetry/ux/foundations/"
|
|
201
|
+
- urn: "telemetry:commons:ux:foundations"
|
|
202
|
+
path: "telemetry/commons/ux/foundations/"
|
|
198
203
|
files:
|
|
199
204
|
- "metric.ui.render_latency.json"
|
|
200
205
|
- "event.ui.json"
|
|
201
206
|
|
|
202
|
-
- urn: "telemetry:ux:foundations
|
|
203
|
-
path: "telemetry/ux/foundations/
|
|
207
|
+
- urn: "telemetry:commons:ux:foundations:color"
|
|
208
|
+
path: "telemetry/commons/ux/foundations/color/"
|
|
204
209
|
files:
|
|
205
210
|
- "metric.ui.render_latency.json"
|
|
206
211
|
- "event.ui.json"
|
|
@@ -222,18 +227,21 @@ artifact_contracts:
|
|
|
222
227
|
|
|
223
228
|
produce_artifacts:
|
|
224
229
|
required_fields:
|
|
225
|
-
- name: "Artifact name (e.g., ux:foundations)"
|
|
226
|
-
- contract: "Contract URN or null (e.g., contract:ux:foundations)"
|
|
227
|
-
- telemetry: "Telemetry URN for observability and analytics or null (e.g., telemetry:ux:foundations)"
|
|
230
|
+
- name: "Artifact name (e.g., commons:ux:foundations)"
|
|
231
|
+
- contract: "Contract URN or null (e.g., contract:commons:ux:foundations)"
|
|
232
|
+
- telemetry: "Telemetry URN for observability and analytics or null (e.g., telemetry:commons:ux:foundations)"
|
|
228
233
|
optional_fields:
|
|
229
234
|
- to: "Visibility (internal|external), defaults to external"
|
|
230
235
|
- urn: "Legacy artifact URN"
|
|
231
236
|
- version: "Version string (e.g., v1)"
|
|
232
237
|
|
|
233
238
|
example:
|
|
234
|
-
- name: ux:foundations
|
|
235
|
-
contract: contract:ux:foundations
|
|
236
|
-
telemetry: telemetry:ux:foundations
|
|
239
|
+
- name: commons:ux:foundations
|
|
240
|
+
contract: contract:commons:ux:foundations
|
|
241
|
+
telemetry: telemetry:commons:ux:foundations
|
|
242
|
+
- name: commons:ux:foundations:color
|
|
243
|
+
contract: contract:commons:ux:foundations:color
|
|
244
|
+
telemetry: telemetry:commons:ux:foundations:color
|
|
237
245
|
- name: internal:cache
|
|
238
246
|
contract: null
|
|
239
247
|
telemetry: null
|
|
@@ -251,10 +259,10 @@ artifact_contracts:
|
|
|
251
259
|
|
|
252
260
|
example:
|
|
253
261
|
- name: appendix:mockup
|
|
254
|
-
- name:
|
|
255
|
-
from: wagon:
|
|
256
|
-
contract: contract:
|
|
257
|
-
telemetry: telemetry:
|
|
262
|
+
- name: commons:ux:foundations
|
|
263
|
+
from: wagon:maintain-ux
|
|
264
|
+
contract: contract:commons:ux:foundations
|
|
265
|
+
telemetry: telemetry:commons:ux:foundations
|
|
258
266
|
|
|
259
267
|
features_format:
|
|
260
268
|
legacy_format:
|