atdd 0.4.5__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.
@@ -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
  """
@@ -0,0 +1,189 @@
1
+ """
2
+ Coach validators: Train registry gap validation.
3
+
4
+ Train First-Class Spec v0.6 Section 14: Inventory Reporting
5
+
6
+ Validates:
7
+ - SPEC-TRAIN-VAL-0037: Inventory reports train gaps
8
+ - SPEC-TRAIN-VAL-0038: Gap report format validation
9
+
10
+ Ensures the inventory command properly reports gaps in train implementation
11
+ across backend, frontend, and frontend_python platforms.
12
+ """
13
+
14
+ import pytest
15
+ from pathlib import Path
16
+ from typing import Dict, Any, List
17
+
18
+ from atdd.coach.utils.repo import find_repo_root
19
+ from atdd.coach.commands.inventory import RepositoryInventory
20
+ from atdd.coach.utils.train_spec_phase import (
21
+ TrainSpecPhase,
22
+ should_enforce,
23
+ emit_phase_warning
24
+ )
25
+
26
+
27
+ # Path constants
28
+ REPO_ROOT = find_repo_root()
29
+
30
+
31
+ @pytest.fixture(scope="module")
32
+ def train_inventory() -> Dict[str, Any]:
33
+ """Generate train inventory with gap reporting."""
34
+ inventory = RepositoryInventory(REPO_ROOT)
35
+ return inventory.scan_trains()
36
+
37
+
38
+ @pytest.mark.platform
39
+ def test_inventory_reports_train_gaps(train_inventory):
40
+ """
41
+ SPEC-TRAIN-VAL-0037: Inventory reports train gaps
42
+
43
+ Given: Inventory scan of trains
44
+ When: Checking gap reporting fields
45
+ Then: Inventory includes missing_test_* and missing_code_* arrays
46
+
47
+ Section 14: Gap Reporting
48
+ """
49
+ required_gap_fields = [
50
+ "missing_test_backend",
51
+ "missing_test_frontend",
52
+ "missing_test_frontend_python",
53
+ "missing_code_backend",
54
+ "missing_code_frontend",
55
+ "missing_code_frontend_python"
56
+ ]
57
+
58
+ missing_fields = []
59
+ for field in required_gap_fields:
60
+ if field not in train_inventory:
61
+ missing_fields.append(field)
62
+
63
+ if missing_fields:
64
+ pytest.fail(
65
+ f"Inventory missing gap reporting fields:\n " +
66
+ "\n ".join(missing_fields) +
67
+ "\n\nExpected fields for Train First-Class Spec v0.6 Section 14"
68
+ )
69
+
70
+ # Verify all gap fields are lists
71
+ for field in required_gap_fields:
72
+ value = train_inventory.get(field)
73
+ assert isinstance(value, list), \
74
+ f"Gap field '{field}' should be a list, got {type(value).__name__}"
75
+
76
+
77
+ @pytest.mark.platform
78
+ def test_gap_report_format_validation(train_inventory):
79
+ """
80
+ SPEC-TRAIN-VAL-0038: Gap report format validation
81
+
82
+ Given: Inventory with gap reporting
83
+ When: Checking gap report structure
84
+ Then: Gaps summary exists with correct format
85
+
86
+ Section 14: Gap Report Format
87
+ """
88
+ # Check for gaps summary
89
+ assert "gaps" in train_inventory, \
90
+ "Inventory should include 'gaps' summary object"
91
+
92
+ gaps = train_inventory["gaps"]
93
+
94
+ # Validate structure
95
+ assert isinstance(gaps, dict), \
96
+ "gaps should be a dictionary"
97
+
98
+ assert "test" in gaps, \
99
+ "gaps should include 'test' category"
100
+
101
+ assert "code" in gaps, \
102
+ "gaps should include 'code' category"
103
+
104
+ # Validate test gaps structure
105
+ test_gaps = gaps["test"]
106
+ assert isinstance(test_gaps, dict), \
107
+ "gaps.test should be a dictionary"
108
+
109
+ required_platforms = ["backend", "frontend", "frontend_python"]
110
+ for platform in required_platforms:
111
+ assert platform in test_gaps, \
112
+ f"gaps.test should include '{platform}'"
113
+ assert isinstance(test_gaps[platform], int), \
114
+ f"gaps.test.{platform} should be an integer count"
115
+
116
+ # Validate code gaps structure
117
+ code_gaps = gaps["code"]
118
+ assert isinstance(code_gaps, dict), \
119
+ "gaps.code should be a dictionary"
120
+
121
+ for platform in required_platforms:
122
+ assert platform in code_gaps, \
123
+ f"gaps.code should include '{platform}'"
124
+ assert isinstance(code_gaps[platform], int), \
125
+ f"gaps.code.{platform} should be an integer count"
126
+
127
+
128
+ @pytest.mark.platform
129
+ def test_gap_counts_match_arrays(train_inventory):
130
+ """
131
+ Verify gap counts match corresponding array lengths.
132
+
133
+ Given: Inventory with gap reporting
134
+ When: Comparing counts to arrays
135
+ Then: Counts equal array lengths
136
+ """
137
+ gaps = train_inventory.get("gaps", {})
138
+
139
+ # Test gaps
140
+ test_gaps = gaps.get("test", {})
141
+ assert test_gaps.get("backend", 0) == len(train_inventory.get("missing_test_backend", [])), \
142
+ "gaps.test.backend count mismatch"
143
+ assert test_gaps.get("frontend", 0) == len(train_inventory.get("missing_test_frontend", [])), \
144
+ "gaps.test.frontend count mismatch"
145
+ assert test_gaps.get("frontend_python", 0) == len(train_inventory.get("missing_test_frontend_python", [])), \
146
+ "gaps.test.frontend_python count mismatch"
147
+
148
+ # Code gaps
149
+ code_gaps = gaps.get("code", {})
150
+ assert code_gaps.get("backend", 0) == len(train_inventory.get("missing_code_backend", [])), \
151
+ "gaps.code.backend count mismatch"
152
+ assert code_gaps.get("frontend", 0) == len(train_inventory.get("missing_code_frontend", [])), \
153
+ "gaps.code.frontend count mismatch"
154
+ assert code_gaps.get("frontend_python", 0) == len(train_inventory.get("missing_code_frontend_python", [])), \
155
+ "gaps.code.frontend_python count mismatch"
156
+
157
+
158
+ @pytest.mark.platform
159
+ def test_gap_train_ids_are_valid(train_inventory):
160
+ """
161
+ Verify train IDs in gap arrays are valid.
162
+
163
+ Given: Inventory with gap arrays
164
+ When: Checking train IDs
165
+ Then: All train IDs in gaps are in train_ids list
166
+ """
167
+ all_train_ids = set(train_inventory.get("train_ids", []))
168
+
169
+ if not all_train_ids:
170
+ pytest.skip("No trains found in inventory")
171
+
172
+ gap_fields = [
173
+ "missing_test_backend",
174
+ "missing_test_frontend",
175
+ "missing_test_frontend_python",
176
+ "missing_code_backend",
177
+ "missing_code_frontend",
178
+ "missing_code_frontend_python"
179
+ ]
180
+
181
+ invalid_ids = []
182
+ for field in gap_fields:
183
+ gap_ids = train_inventory.get(field, [])
184
+ for train_id in gap_ids:
185
+ if train_id not in all_train_ids:
186
+ invalid_ids.append(f"{field}: {train_id}")
187
+
188
+ assert not invalid_ids, \
189
+ f"Gap arrays contain invalid train IDs:\n " + "\n ".join(invalid_ids)