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.
@@ -75,11 +75,32 @@ class RepositoryInventory:
75
75
  }
76
76
 
77
77
  def scan_trains(self) -> Dict[str, Any]:
78
- """Scan plan/ for train manifests (aggregations of wagons)."""
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 {"total": 0, "trains": []}
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]:
@@ -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 2 digits)
1036
+ # Parse theme from train_id (first digit maps to theme name)
991
1037
  theme = ""
992
- if len(train_id) >= 2 and train_id[:2].isdigit():
993
- theme = train_id[:2]
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": manifest.get("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
  """