atdd 0.4.6__py3-none-any.whl → 0.4.7__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/inventory.py +91 -3
- atdd/coach/commands/registry.py +114 -5
- atdd/coach/utils/config.py +131 -0
- atdd/coach/utils/train_spec_phase.py +97 -0
- atdd/coach/validators/shared_fixtures.py +68 -1
- atdd/coach/validators/test_train_registry.py +189 -0
- atdd/coder/validators/test_train_infrastructure.py +236 -2
- atdd/planner/schemas/train.schema.json +125 -2
- atdd/planner/validators/test_train_validation.py +667 -2
- atdd/tester/validators/test_train_backend_e2e.py +371 -0
- atdd/tester/validators/test_train_frontend_e2e.py +292 -0
- atdd/tester/validators/test_train_frontend_python.py +282 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/METADATA +1 -1
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/RECORD +18 -12
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/WHEEL +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/entry_points.txt +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/top_level.txt +0 -0
atdd/coach/commands/inventory.py
CHANGED
|
@@ -75,11 +75,32 @@ class RepositoryInventory:
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
def scan_trains(self) -> Dict[str, Any]:
|
|
78
|
-
"""
|
|
78
|
+
"""
|
|
79
|
+
Scan plan/ for train manifests (aggregations of wagons).
|
|
80
|
+
|
|
81
|
+
Train First-Class Spec v0.6 Section 14: Gap Reporting
|
|
82
|
+
Reports missing test/code for each platform (backend/frontend/frontend_python).
|
|
83
|
+
"""
|
|
79
84
|
plan_dir = self.repo_root / "plan"
|
|
80
85
|
|
|
81
86
|
if not plan_dir.exists():
|
|
82
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
"total": 0,
|
|
89
|
+
"trains": [],
|
|
90
|
+
"by_theme": {},
|
|
91
|
+
"train_ids": [],
|
|
92
|
+
"detail_files": 0,
|
|
93
|
+
"missing_test_backend": [],
|
|
94
|
+
"missing_test_frontend": [],
|
|
95
|
+
"missing_test_frontend_python": [],
|
|
96
|
+
"missing_code_backend": [],
|
|
97
|
+
"missing_code_frontend": [],
|
|
98
|
+
"missing_code_frontend_python": [],
|
|
99
|
+
"gaps": {
|
|
100
|
+
"test": {"backend": 0, "frontend": 0, "frontend_python": 0},
|
|
101
|
+
"code": {"backend": 0, "frontend": 0, "frontend_python": 0}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
83
104
|
|
|
84
105
|
# Load trains registry
|
|
85
106
|
trains_file = plan_dir / "_trains.yaml"
|
|
@@ -103,6 +124,14 @@ class RepositoryInventory:
|
|
|
103
124
|
by_theme = defaultdict(int)
|
|
104
125
|
train_ids = []
|
|
105
126
|
|
|
127
|
+
# Gap tracking (Section 14)
|
|
128
|
+
missing_test_backend = []
|
|
129
|
+
missing_test_frontend = []
|
|
130
|
+
missing_test_frontend_python = []
|
|
131
|
+
missing_code_backend = []
|
|
132
|
+
missing_code_frontend = []
|
|
133
|
+
missing_code_frontend_python = []
|
|
134
|
+
|
|
106
135
|
for train in all_trains:
|
|
107
136
|
train_id = train.get("train_id", "unknown")
|
|
108
137
|
train_ids.append(train_id)
|
|
@@ -118,6 +147,46 @@ class RepositoryInventory:
|
|
|
118
147
|
theme = theme_map.get(theme_digit, "unknown")
|
|
119
148
|
by_theme[theme] += 1
|
|
120
149
|
|
|
150
|
+
# Gap analysis
|
|
151
|
+
expectations = train.get("expectations", {})
|
|
152
|
+
test_fields = train.get("test", {})
|
|
153
|
+
code_fields = train.get("code", {})
|
|
154
|
+
|
|
155
|
+
# Normalize test/code to dict form
|
|
156
|
+
if isinstance(test_fields, str):
|
|
157
|
+
test_fields = {"backend": [test_fields]}
|
|
158
|
+
elif isinstance(test_fields, list):
|
|
159
|
+
test_fields = {"backend": test_fields}
|
|
160
|
+
|
|
161
|
+
if isinstance(code_fields, str):
|
|
162
|
+
code_fields = {"backend": [code_fields]}
|
|
163
|
+
elif isinstance(code_fields, list):
|
|
164
|
+
code_fields = {"backend": code_fields}
|
|
165
|
+
|
|
166
|
+
# Check backend gaps (default expectation is True for backend)
|
|
167
|
+
expects_backend = expectations.get("backend", True)
|
|
168
|
+
if expects_backend:
|
|
169
|
+
if not test_fields.get("backend"):
|
|
170
|
+
missing_test_backend.append(train_id)
|
|
171
|
+
if not code_fields.get("backend"):
|
|
172
|
+
missing_code_backend.append(train_id)
|
|
173
|
+
|
|
174
|
+
# Check frontend gaps
|
|
175
|
+
expects_frontend = expectations.get("frontend", False)
|
|
176
|
+
if expects_frontend:
|
|
177
|
+
if not test_fields.get("frontend"):
|
|
178
|
+
missing_test_frontend.append(train_id)
|
|
179
|
+
if not code_fields.get("frontend"):
|
|
180
|
+
missing_code_frontend.append(train_id)
|
|
181
|
+
|
|
182
|
+
# Check frontend_python gaps
|
|
183
|
+
expects_frontend_python = expectations.get("frontend_python", False)
|
|
184
|
+
if expects_frontend_python:
|
|
185
|
+
if not test_fields.get("frontend_python"):
|
|
186
|
+
missing_test_frontend_python.append(train_id)
|
|
187
|
+
if not code_fields.get("frontend_python"):
|
|
188
|
+
missing_code_frontend_python.append(train_id)
|
|
189
|
+
|
|
121
190
|
# Find train detail files
|
|
122
191
|
train_detail_files = list((plan_dir / "_trains").glob("*.yaml")) if (plan_dir / "_trains").exists() else []
|
|
123
192
|
|
|
@@ -125,7 +194,26 @@ class RepositoryInventory:
|
|
|
125
194
|
"total": len(all_trains),
|
|
126
195
|
"by_theme": dict(by_theme),
|
|
127
196
|
"train_ids": train_ids,
|
|
128
|
-
"detail_files": len(train_detail_files)
|
|
197
|
+
"detail_files": len(train_detail_files),
|
|
198
|
+
# Gap reporting (Section 14)
|
|
199
|
+
"missing_test_backend": missing_test_backend,
|
|
200
|
+
"missing_test_frontend": missing_test_frontend,
|
|
201
|
+
"missing_test_frontend_python": missing_test_frontend_python,
|
|
202
|
+
"missing_code_backend": missing_code_backend,
|
|
203
|
+
"missing_code_frontend": missing_code_frontend,
|
|
204
|
+
"missing_code_frontend_python": missing_code_frontend_python,
|
|
205
|
+
"gaps": {
|
|
206
|
+
"test": {
|
|
207
|
+
"backend": len(missing_test_backend),
|
|
208
|
+
"frontend": len(missing_test_frontend),
|
|
209
|
+
"frontend_python": len(missing_test_frontend_python)
|
|
210
|
+
},
|
|
211
|
+
"code": {
|
|
212
|
+
"backend": len(missing_code_backend),
|
|
213
|
+
"frontend": len(missing_code_frontend),
|
|
214
|
+
"frontend_python": len(missing_code_frontend_python)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
129
217
|
}
|
|
130
218
|
|
|
131
219
|
def scan_wagons(self) -> Dict[str, Any]:
|
atdd/coach/commands/registry.py
CHANGED
|
@@ -910,6 +910,44 @@ class RegistryBuilder:
|
|
|
910
910
|
mode = "check" if preview_only else "interactive"
|
|
911
911
|
return self.update_telemetry_registry(mode)
|
|
912
912
|
|
|
913
|
+
def _normalize_test_code_field(self, field_value: Any) -> Dict[str, List[str]]:
|
|
914
|
+
"""
|
|
915
|
+
Normalize test/code field to canonical structure.
|
|
916
|
+
|
|
917
|
+
Train First-Class Spec v0.6 Section 5: Test/Code Field Typing Normalization
|
|
918
|
+
- string -> {"backend": [string]}
|
|
919
|
+
- list -> {"backend": list}
|
|
920
|
+
- dict -> normalize each sub-field to list
|
|
921
|
+
"""
|
|
922
|
+
if field_value is None:
|
|
923
|
+
return {}
|
|
924
|
+
|
|
925
|
+
if isinstance(field_value, str):
|
|
926
|
+
return {"backend": [field_value]}
|
|
927
|
+
elif isinstance(field_value, list):
|
|
928
|
+
return {"backend": field_value}
|
|
929
|
+
elif isinstance(field_value, dict):
|
|
930
|
+
result = {}
|
|
931
|
+
for key in ["backend", "frontend", "frontend_python"]:
|
|
932
|
+
if key in field_value:
|
|
933
|
+
val = field_value[key]
|
|
934
|
+
result[key] = [val] if isinstance(val, str) else (val or [])
|
|
935
|
+
return result
|
|
936
|
+
return {}
|
|
937
|
+
|
|
938
|
+
def _extract_wagons_from_participants(self, participants: List[str]) -> List[str]:
|
|
939
|
+
"""
|
|
940
|
+
Extract wagon names from participants list.
|
|
941
|
+
|
|
942
|
+
Train First-Class Spec v0.6 Section 4: Participants is Canonical Wagon Source
|
|
943
|
+
"""
|
|
944
|
+
wagons = []
|
|
945
|
+
for participant in participants:
|
|
946
|
+
if isinstance(participant, str) and participant.startswith("wagon:"):
|
|
947
|
+
wagon_name = participant.replace("wagon:", "")
|
|
948
|
+
wagons.append(wagon_name)
|
|
949
|
+
return wagons
|
|
950
|
+
|
|
913
951
|
def build_trains(self, mode: str = "interactive") -> Dict[str, Any]:
|
|
914
952
|
"""
|
|
915
953
|
Build trains registry from train manifest files.
|
|
@@ -920,12 +958,19 @@ class RegistryBuilder:
|
|
|
920
958
|
- XX = category within theme
|
|
921
959
|
- name = train slug
|
|
922
960
|
|
|
961
|
+
Train First-Class Spec v0.6 Normalization:
|
|
962
|
+
- Section 1: Normalize file→path (deprecation)
|
|
963
|
+
- Section 4: Extract wagons from participants
|
|
964
|
+
- Section 5: Normalize test/code fields to {backend/frontend/frontend_python: []}
|
|
965
|
+
|
|
923
966
|
Args:
|
|
924
967
|
mode: "interactive" (prompt), "apply" (no prompt), or "check" (verify only)
|
|
925
968
|
|
|
926
969
|
Returns:
|
|
927
970
|
Statistics about the update (includes has_changes flag for check mode)
|
|
928
971
|
"""
|
|
972
|
+
import warnings
|
|
973
|
+
|
|
929
974
|
print("\n📊 Analyzing trains registry from manifest files...")
|
|
930
975
|
|
|
931
976
|
# Set up paths
|
|
@@ -947,6 +992,7 @@ class RegistryBuilder:
|
|
|
947
992
|
"new": 0,
|
|
948
993
|
"errors": 0,
|
|
949
994
|
"preserved_drafts": 0,
|
|
995
|
+
"file_to_path_migrations": 0,
|
|
950
996
|
"changes": []
|
|
951
997
|
}
|
|
952
998
|
|
|
@@ -987,31 +1033,92 @@ class RegistryBuilder:
|
|
|
987
1033
|
# Try to infer from filename (e.g., 01-01-setup.yaml -> 01-01-setup)
|
|
988
1034
|
train_id = manifest_path.stem
|
|
989
1035
|
|
|
990
|
-
# Parse theme from train_id (first
|
|
1036
|
+
# Parse theme from train_id (first digit maps to theme name)
|
|
991
1037
|
theme = ""
|
|
992
|
-
|
|
993
|
-
|
|
1038
|
+
theme_map = {
|
|
1039
|
+
"0": "commons", "1": "mechanic", "2": "scenario", "3": "match",
|
|
1040
|
+
"4": "sensory", "5": "player", "6": "league", "7": "audience",
|
|
1041
|
+
"8": "monetization", "9": "partnership"
|
|
1042
|
+
}
|
|
1043
|
+
if train_id and train_id[0].isdigit():
|
|
1044
|
+
theme = theme_map.get(train_id[0], "")
|
|
994
1045
|
|
|
995
1046
|
# Build train entry
|
|
996
1047
|
rel_manifest = str(manifest_path.relative_to(self.repo_root))
|
|
997
1048
|
|
|
1049
|
+
# Section 1: Normalize file→path
|
|
1050
|
+
path_value = manifest.get("path")
|
|
1051
|
+
file_value = manifest.get("file")
|
|
1052
|
+
if file_value and not path_value:
|
|
1053
|
+
# Migrate file to path
|
|
1054
|
+
path_value = file_value
|
|
1055
|
+
stats["file_to_path_migrations"] += 1
|
|
1056
|
+
warnings.warn(
|
|
1057
|
+
f"Train {train_id}: 'file' field is deprecated, migrating to 'path'",
|
|
1058
|
+
DeprecationWarning,
|
|
1059
|
+
stacklevel=2
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Section 4: Extract wagons from participants
|
|
1063
|
+
participants = manifest.get("participants", [])
|
|
1064
|
+
wagons = self._extract_wagons_from_participants(participants)
|
|
1065
|
+
|
|
1066
|
+
# Also include explicitly listed wagons
|
|
1067
|
+
explicit_wagons = manifest.get("wagons", [])
|
|
1068
|
+
if explicit_wagons:
|
|
1069
|
+
# Validate subset relationship
|
|
1070
|
+
explicit_set = set(explicit_wagons)
|
|
1071
|
+
participant_set = set(wagons)
|
|
1072
|
+
if not explicit_set.issubset(participant_set) and participant_set:
|
|
1073
|
+
extra = explicit_set - participant_set
|
|
1074
|
+
warnings.warn(
|
|
1075
|
+
f"Train {train_id}: registry wagons {extra} not in YAML participants",
|
|
1076
|
+
UserWarning,
|
|
1077
|
+
stacklevel=2
|
|
1078
|
+
)
|
|
1079
|
+
wagons = explicit_wagons # Use explicit if provided
|
|
1080
|
+
|
|
1081
|
+
# Section 5: Normalize test/code fields
|
|
1082
|
+
test_normalized = self._normalize_test_code_field(manifest.get("test"))
|
|
1083
|
+
code_normalized = self._normalize_test_code_field(manifest.get("code"))
|
|
1084
|
+
|
|
998
1085
|
entry = {
|
|
999
1086
|
"train_id": train_id,
|
|
1000
1087
|
"theme": theme,
|
|
1001
1088
|
"title": manifest.get("title", manifest.get("description", "")),
|
|
1002
1089
|
"description": manifest.get("description", ""),
|
|
1003
|
-
"wagons":
|
|
1090
|
+
"wagons": wagons,
|
|
1004
1091
|
"status": manifest.get("status", "planned"),
|
|
1005
1092
|
"manifest": rel_manifest
|
|
1006
1093
|
}
|
|
1007
1094
|
|
|
1095
|
+
# Add path if present
|
|
1096
|
+
if path_value:
|
|
1097
|
+
entry["path"] = path_value
|
|
1098
|
+
|
|
1099
|
+
# Add primary_wagon if present
|
|
1100
|
+
primary_wagon = manifest.get("primary_wagon")
|
|
1101
|
+
if primary_wagon:
|
|
1102
|
+
entry["primary_wagon"] = primary_wagon
|
|
1103
|
+
|
|
1104
|
+
# Add normalized test/code if present
|
|
1105
|
+
if test_normalized:
|
|
1106
|
+
entry["test"] = test_normalized
|
|
1107
|
+
if code_normalized:
|
|
1108
|
+
entry["code"] = code_normalized
|
|
1109
|
+
|
|
1110
|
+
# Add expectations if present
|
|
1111
|
+
expectations = manifest.get("expectations")
|
|
1112
|
+
if expectations:
|
|
1113
|
+
entry["expectations"] = expectations
|
|
1114
|
+
|
|
1008
1115
|
# Check if updating or new
|
|
1009
1116
|
if train_id in existing_trains:
|
|
1010
1117
|
stats["updated"] += 1
|
|
1011
1118
|
# Check for field changes
|
|
1012
1119
|
old = existing_trains[train_id]
|
|
1013
1120
|
changed_fields = []
|
|
1014
|
-
for field in ["title", "description", "wagons", "status", "theme"]:
|
|
1121
|
+
for field in ["title", "description", "wagons", "status", "theme", "path", "test", "code", "expectations"]:
|
|
1015
1122
|
if old.get(field) != entry.get(field):
|
|
1016
1123
|
changed_fields.append(field)
|
|
1017
1124
|
if changed_fields:
|
|
@@ -1052,6 +1159,8 @@ class RegistryBuilder:
|
|
|
1052
1159
|
print(f" • {stats['updated']} trains will be updated")
|
|
1053
1160
|
print(f" • {stats['new']} new trains will be added")
|
|
1054
1161
|
print(f" • {stats['preserved_drafts']} draft trains will be preserved")
|
|
1162
|
+
if stats["file_to_path_migrations"] > 0:
|
|
1163
|
+
print(f" ⚠️ {stats['file_to_path_migrations']} file→path migrations (deprecation)")
|
|
1055
1164
|
if stats["errors"] > 0:
|
|
1056
1165
|
print(f" ⚠️ {stats['errors']} errors encountered")
|
|
1057
1166
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ATDD Configuration Loader.
|
|
3
|
+
|
|
4
|
+
Loads configuration from .atdd/config.yaml for train validation and enforcement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_atdd_config(repo_root: Path) -> Dict[str, Any]:
|
|
13
|
+
"""
|
|
14
|
+
Load .atdd/config.yaml configuration file.
|
|
15
|
+
|
|
16
|
+
The config file controls:
|
|
17
|
+
- FastAPI template enforcement (Section 11)
|
|
18
|
+
- Train validation behavior
|
|
19
|
+
- Custom path conventions
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
repo_root: Repository root path
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Parsed configuration dict, or empty dict if file doesn't exist
|
|
26
|
+
|
|
27
|
+
Example config:
|
|
28
|
+
trains:
|
|
29
|
+
enforce_fastapi_template: true
|
|
30
|
+
backend_runner_paths:
|
|
31
|
+
- python/trains/runner.py
|
|
32
|
+
- python/trains/{train_id}/runner.py
|
|
33
|
+
frontend_allowed_roots:
|
|
34
|
+
- web/src/
|
|
35
|
+
- web/components/
|
|
36
|
+
"""
|
|
37
|
+
config_path = repo_root / ".atdd" / "config.yaml"
|
|
38
|
+
|
|
39
|
+
if not config_path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with open(config_path) as f:
|
|
44
|
+
config = yaml.safe_load(f)
|
|
45
|
+
return config if config else {}
|
|
46
|
+
except Exception:
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_train_config(repo_root: Path) -> Dict[str, Any]:
|
|
51
|
+
"""
|
|
52
|
+
Get train-specific configuration.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
repo_root: Repository root path
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Train configuration dict with defaults applied
|
|
59
|
+
"""
|
|
60
|
+
config = load_atdd_config(repo_root)
|
|
61
|
+
train_config = config.get("trains", {})
|
|
62
|
+
|
|
63
|
+
# Apply defaults
|
|
64
|
+
defaults = {
|
|
65
|
+
"enforce_fastapi_template": False,
|
|
66
|
+
"backend_runner_paths": [
|
|
67
|
+
"python/trains/runner.py",
|
|
68
|
+
"python/trains/{train_id}/runner.py"
|
|
69
|
+
],
|
|
70
|
+
"frontend_allowed_roots": [
|
|
71
|
+
"web/src/",
|
|
72
|
+
"web/components/",
|
|
73
|
+
"web/pages/"
|
|
74
|
+
],
|
|
75
|
+
"frontend_python_paths": [
|
|
76
|
+
"python/streamlit/",
|
|
77
|
+
"python/apps/"
|
|
78
|
+
],
|
|
79
|
+
"e2e_backend_pattern": "e2e/{theme}/test_{train_id}*.py",
|
|
80
|
+
"e2e_frontend_pattern": "web/e2e/{train_id}/*.spec.ts"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Merge with defaults
|
|
84
|
+
for key, default_value in defaults.items():
|
|
85
|
+
if key not in train_config:
|
|
86
|
+
train_config[key] = default_value
|
|
87
|
+
|
|
88
|
+
return train_config
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_validation_config(repo_root: Path) -> Dict[str, Any]:
|
|
92
|
+
"""
|
|
93
|
+
Get validation-specific configuration.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
repo_root: Repository root path
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Validation configuration with defaults
|
|
100
|
+
"""
|
|
101
|
+
config = load_atdd_config(repo_root)
|
|
102
|
+
validation_config = config.get("validation", {})
|
|
103
|
+
|
|
104
|
+
defaults = {
|
|
105
|
+
"strict_mode": False,
|
|
106
|
+
"warn_on_missing_tests": True,
|
|
107
|
+
"warn_on_missing_code": True,
|
|
108
|
+
"require_primary_wagon": False
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for key, default_value in defaults.items():
|
|
112
|
+
if key not in validation_config:
|
|
113
|
+
validation_config[key] = default_value
|
|
114
|
+
|
|
115
|
+
return validation_config
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_feature_enabled(repo_root: Path, feature: str) -> bool:
|
|
119
|
+
"""
|
|
120
|
+
Check if a specific feature is enabled in config.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
repo_root: Repository root path
|
|
124
|
+
feature: Feature name to check
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if feature is enabled, False otherwise
|
|
128
|
+
"""
|
|
129
|
+
config = load_atdd_config(repo_root)
|
|
130
|
+
features = config.get("features", {})
|
|
131
|
+
return features.get(feature, False)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Train First-Class Spec v0.6 Rollout Phase Controller.
|
|
3
|
+
|
|
4
|
+
Manages the phased rollout of train validation rules:
|
|
5
|
+
- Phase 1 (WARNINGS_ONLY): All new validators emit warnings only
|
|
6
|
+
- Phase 2 (BACKEND_ENFORCEMENT): Backend validators become strict
|
|
7
|
+
- Phase 3 (FULL_ENFORCEMENT): All validators become strict
|
|
8
|
+
|
|
9
|
+
Usage in validators:
|
|
10
|
+
from atdd.coach.utils.train_spec_phase import TrainSpecPhase, should_enforce
|
|
11
|
+
|
|
12
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
13
|
+
assert condition, "Error message"
|
|
14
|
+
else:
|
|
15
|
+
if not condition:
|
|
16
|
+
warnings.warn("Warning message")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from enum import IntEnum
|
|
20
|
+
from typing import Optional
|
|
21
|
+
import warnings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TrainSpecPhase(IntEnum):
|
|
25
|
+
"""
|
|
26
|
+
Rollout phases for Train First-Class Spec v0.6.
|
|
27
|
+
|
|
28
|
+
Phases are ordered by strictness level:
|
|
29
|
+
- WARNINGS_ONLY (1): All new validators emit warnings, no assertions
|
|
30
|
+
- BACKEND_ENFORCEMENT (2): Backend validators (0022-0025, 0031-0033) strict
|
|
31
|
+
- FULL_ENFORCEMENT (3): All validators strict
|
|
32
|
+
"""
|
|
33
|
+
WARNINGS_ONLY = 1
|
|
34
|
+
BACKEND_ENFORCEMENT = 2
|
|
35
|
+
FULL_ENFORCEMENT = 3
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Current rollout phase - update this to advance through phases
|
|
39
|
+
CURRENT_PHASE = TrainSpecPhase.WARNINGS_ONLY
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def should_enforce(validator_phase: TrainSpecPhase) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Check if a validator should enforce strict mode.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
validator_phase: The phase at which this validator becomes strict
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if current phase >= validator_phase (should enforce)
|
|
51
|
+
False if current phase < validator_phase (should warn only)
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
# This validator becomes strict in Phase 2
|
|
55
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
56
|
+
assert backend_test_exists, "Backend test required"
|
|
57
|
+
else:
|
|
58
|
+
if not backend_test_exists:
|
|
59
|
+
warnings.warn("Backend test missing (warning only)")
|
|
60
|
+
"""
|
|
61
|
+
return CURRENT_PHASE >= validator_phase
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_current_phase() -> TrainSpecPhase:
|
|
65
|
+
"""Get the current rollout phase."""
|
|
66
|
+
return CURRENT_PHASE
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_phase_name(phase: Optional[TrainSpecPhase] = None) -> str:
|
|
70
|
+
"""Get human-readable name for a phase."""
|
|
71
|
+
phase = phase or CURRENT_PHASE
|
|
72
|
+
return {
|
|
73
|
+
TrainSpecPhase.WARNINGS_ONLY: "Phase 1: Warnings Only",
|
|
74
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT: "Phase 2: Backend Enforcement",
|
|
75
|
+
TrainSpecPhase.FULL_ENFORCEMENT: "Phase 3: Full Enforcement",
|
|
76
|
+
}.get(phase, "Unknown Phase")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit_phase_warning(
|
|
80
|
+
spec_id: str,
|
|
81
|
+
message: str,
|
|
82
|
+
validator_phase: TrainSpecPhase = TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Emit a deprecation/validation warning with phase context.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
spec_id: The SPEC ID (e.g., "SPEC-TRAIN-VAL-0022")
|
|
89
|
+
message: The warning message
|
|
90
|
+
validator_phase: Phase when this becomes an error
|
|
91
|
+
"""
|
|
92
|
+
phase_name = get_phase_name(validator_phase)
|
|
93
|
+
warnings.warn(
|
|
94
|
+
f"[{spec_id}] {message} (will become error in {phase_name})",
|
|
95
|
+
category=UserWarning,
|
|
96
|
+
stacklevel=3
|
|
97
|
+
)
|
|
@@ -14,11 +14,12 @@ Validators should use:
|
|
|
14
14
|
import json
|
|
15
15
|
import yaml
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Dict, Any, List, Tuple
|
|
17
|
+
from typing import Dict, Any, List, Tuple, Optional
|
|
18
18
|
import pytest
|
|
19
19
|
|
|
20
20
|
import atdd
|
|
21
21
|
from atdd.coach.utils.repo import find_repo_root
|
|
22
|
+
from atdd.coach.utils.config import load_atdd_config, get_train_config
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
# Path constants
|
|
@@ -192,6 +193,72 @@ def trains_registry() -> Dict[str, Any]:
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
|
|
196
|
+
@pytest.fixture(scope="module")
|
|
197
|
+
def trains_registry_with_groups() -> Dict[str, Dict[str, List[Dict]]]:
|
|
198
|
+
"""
|
|
199
|
+
Load trains registry preserving full group structure for theme validation.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Trains data with full nesting preserved:
|
|
203
|
+
{"0-commons": {"00-commons-nominal": [train1, train2], ...}, ...}
|
|
204
|
+
|
|
205
|
+
This fixture is used for validating theme derivation from group keys.
|
|
206
|
+
"""
|
|
207
|
+
trains_file = PLAN_DIR / "_trains.yaml"
|
|
208
|
+
if trains_file.exists():
|
|
209
|
+
with open(trains_file) as f:
|
|
210
|
+
data = yaml.safe_load(f)
|
|
211
|
+
return data.get("trains", {})
|
|
212
|
+
return {}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.fixture(scope="module")
|
|
216
|
+
def train_files() -> List[Tuple[Path, Dict]]:
|
|
217
|
+
"""
|
|
218
|
+
Load all train YAML files with their data.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of (path, train_data) tuples for all train files in plan/_trains/
|
|
222
|
+
"""
|
|
223
|
+
trains_dir = PLAN_DIR / "_trains"
|
|
224
|
+
train_files_data = []
|
|
225
|
+
|
|
226
|
+
if trains_dir.exists():
|
|
227
|
+
for train_file in sorted(trains_dir.glob("*.yaml")):
|
|
228
|
+
if not train_file.name.startswith("_"):
|
|
229
|
+
try:
|
|
230
|
+
with open(train_file) as f:
|
|
231
|
+
train_data = yaml.safe_load(f)
|
|
232
|
+
if train_data:
|
|
233
|
+
train_files_data.append((train_file, train_data))
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
return train_files_data
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@pytest.fixture(scope="module")
|
|
241
|
+
def atdd_config() -> Dict[str, Any]:
|
|
242
|
+
"""
|
|
243
|
+
Load .atdd/config.yaml configuration.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Configuration dict with train and validation settings
|
|
247
|
+
"""
|
|
248
|
+
return load_atdd_config(REPO_ROOT)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@pytest.fixture(scope="module")
|
|
252
|
+
def train_config() -> Dict[str, Any]:
|
|
253
|
+
"""
|
|
254
|
+
Load train-specific configuration with defaults.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Train configuration dict with defaults applied
|
|
258
|
+
"""
|
|
259
|
+
return get_train_config(REPO_ROOT)
|
|
260
|
+
|
|
261
|
+
|
|
195
262
|
@pytest.fixture(scope="module")
|
|
196
263
|
def wagons_registry() -> Dict[str, Any]:
|
|
197
264
|
"""
|