atdd 0.6.1__py3-none-any.whl → 0.7.0__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.
@@ -121,6 +121,17 @@
121
121
  }
122
122
  },
123
123
  "additionalProperties": false
124
+ },
125
+ "localization": {
126
+ "type": "object",
127
+ "description": "Localization validation settings (Localization Manifest Spec v1)",
128
+ "properties": {
129
+ "manifest": {
130
+ "type": "string",
131
+ "description": "Path to localization manifest.json relative to repo root (e.g., web/locales/manifest.json)"
132
+ }
133
+ },
134
+ "additionalProperties": false
124
135
  }
125
136
  },
126
137
  "required": ["version", "release"],
@@ -0,0 +1,97 @@
1
+ """
2
+ Localization Manifest Spec v1 Rollout Phase Controller.
3
+
4
+ Manages the phased rollout of localization validation rules:
5
+ - Phase 1 (WARNINGS_ONLY): All validators emit warnings only
6
+ - Phase 2 (TESTER_ENFORCEMENT): Tester phase validators (LOCALE-TEST-*) strict
7
+ - Phase 3 (FULL_ENFORCEMENT): All validators including Coder (LOCALE-CODE-*) strict
8
+
9
+ Usage in validators:
10
+ from atdd.coach.utils.locale_phase import LocalePhase, should_enforce_locale
11
+
12
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
13
+ assert condition, "Error message"
14
+ else:
15
+ if not condition:
16
+ emit_locale_warning("LOCALE-TEST-1.1", "Warning message", LocalePhase.TESTER_ENFORCEMENT)
17
+ """
18
+
19
+ from enum import IntEnum
20
+ from typing import Optional
21
+ import warnings
22
+
23
+
24
+ class LocalePhase(IntEnum):
25
+ """
26
+ Rollout phases for Localization Manifest Spec v1.
27
+
28
+ Phases are ordered by strictness level:
29
+ - WARNINGS_ONLY (1): All new validators emit warnings, no assertions
30
+ - TESTER_ENFORCEMENT (2): Tester phase validators (LOCALE-TEST-*) strict
31
+ - FULL_ENFORCEMENT (3): All validators including Coder (LOCALE-CODE-*) strict
32
+ """
33
+ WARNINGS_ONLY = 1
34
+ TESTER_ENFORCEMENT = 2
35
+ FULL_ENFORCEMENT = 3
36
+
37
+
38
+ # Current rollout phase - update this to advance through phases
39
+ CURRENT_LOCALE_PHASE = LocalePhase.WARNINGS_ONLY
40
+
41
+
42
+ def should_enforce_locale(validator_phase: LocalePhase) -> bool:
43
+ """
44
+ Check if a locale 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_locale(LocalePhase.TESTER_ENFORCEMENT):
56
+ assert all_keys_match, "Keys must match reference locale"
57
+ else:
58
+ if not all_keys_match:
59
+ emit_locale_warning("LOCALE-TEST-1.4", "Keys don't match", LocalePhase.TESTER_ENFORCEMENT)
60
+ """
61
+ return CURRENT_LOCALE_PHASE >= validator_phase
62
+
63
+
64
+ def get_current_locale_phase() -> LocalePhase:
65
+ """Get the current locale rollout phase."""
66
+ return CURRENT_LOCALE_PHASE
67
+
68
+
69
+ def get_locale_phase_name(phase: Optional[LocalePhase] = None) -> str:
70
+ """Get human-readable name for a locale phase."""
71
+ phase = phase or CURRENT_LOCALE_PHASE
72
+ return {
73
+ LocalePhase.WARNINGS_ONLY: "Phase 1: Warnings Only",
74
+ LocalePhase.TESTER_ENFORCEMENT: "Phase 2: Tester Enforcement",
75
+ LocalePhase.FULL_ENFORCEMENT: "Phase 3: Full Enforcement",
76
+ }.get(phase, "Unknown Phase")
77
+
78
+
79
+ def emit_locale_warning(
80
+ spec_id: str,
81
+ message: str,
82
+ validator_phase: LocalePhase = LocalePhase.TESTER_ENFORCEMENT
83
+ ) -> None:
84
+ """
85
+ Emit a locale validation warning with phase context.
86
+
87
+ Args:
88
+ spec_id: The SPEC ID (e.g., "LOCALE-TEST-1.1")
89
+ message: The warning message
90
+ validator_phase: Phase when this becomes an error
91
+ """
92
+ phase_name = get_locale_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
+ )
@@ -599,3 +599,52 @@ def wagon_to_train_mapping(train_files: List[Tuple[Path, Dict]]) -> Dict[str, Li
599
599
  mapping[wagon_slug].append(train_id)
600
600
 
601
601
  return mapping
602
+
603
+
604
+ # ============================================================================
605
+ # LOCALIZATION FIXTURES (Localization Manifest Spec v1)
606
+ # ============================================================================
607
+
608
+
609
+ @pytest.fixture(scope="module")
610
+ def locale_manifest_path(atdd_config: Dict[str, Any]) -> Optional[Path]:
611
+ """
612
+ Get path to localization manifest file from config.
613
+
614
+ Returns:
615
+ Path to manifest file, or None if localization not configured
616
+ """
617
+ manifest_rel = atdd_config.get("localization", {}).get("manifest")
618
+ if not manifest_rel:
619
+ return None
620
+ return REPO_ROOT / manifest_rel
621
+
622
+
623
+ @pytest.fixture(scope="module")
624
+ def locale_manifest(locale_manifest_path: Optional[Path]) -> Optional[Dict[str, Any]]:
625
+ """
626
+ Load localization manifest from configured path.
627
+
628
+ Returns:
629
+ Manifest dict with reference, locales, namespaces, or None if not configured
630
+ """
631
+ if locale_manifest_path is None:
632
+ return None
633
+ if not locale_manifest_path.exists():
634
+ return None
635
+
636
+ with open(locale_manifest_path) as f:
637
+ return json.load(f)
638
+
639
+
640
+ @pytest.fixture(scope="module")
641
+ def locales_dir(locale_manifest_path: Optional[Path]) -> Optional[Path]:
642
+ """
643
+ Get locales directory (parent of manifest file).
644
+
645
+ Returns:
646
+ Path to locales directory, or None if not configured
647
+ """
648
+ if locale_manifest_path is None:
649
+ return None
650
+ return locale_manifest_path.parent
@@ -0,0 +1,171 @@
1
+ """
2
+ i18n runtime validation (Localization Manifest Spec v1).
3
+
4
+ Validates that runtime code uses the centralized locale manifest:
5
+ - LOCALE-CODE-2.1: i18nConfig.ts imports from manifest (not hardcoded arrays)
6
+ - LOCALE-CODE-2.2: LanguageSwitcher uses shared SUPPORTED_LOCALES
7
+ """
8
+
9
+ import re
10
+ import pytest
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from atdd.coach.utils.locale_phase import (
15
+ LocalePhase,
16
+ should_enforce_locale,
17
+ emit_locale_warning,
18
+ )
19
+ from atdd.coach.utils.repo import find_repo_root
20
+
21
+ # Path constants
22
+ REPO_ROOT = find_repo_root()
23
+ WEB_DIR = REPO_ROOT / "web"
24
+
25
+
26
+ def _find_file(base_dir: Path, *possible_paths: str) -> Optional[Path]:
27
+ """Find first existing file from list of possible paths."""
28
+ for rel_path in possible_paths:
29
+ full_path = base_dir / rel_path
30
+ if full_path.exists():
31
+ return full_path
32
+ return None
33
+
34
+
35
+ def _read_file_content(path: Path) -> Optional[str]:
36
+ """Read file content, return None on error."""
37
+ try:
38
+ return path.read_text()
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ @pytest.mark.locale
44
+ @pytest.mark.coder
45
+ def test_i18n_config_uses_manifest(locale_manifest, locale_manifest_path):
46
+ """
47
+ LOCALE-CODE-2.1: i18nConfig.ts imports from manifest (not hardcoded arrays)
48
+
49
+ Given: Web application with i18n configuration
50
+ When: Checking i18nConfig.ts or i18n.ts
51
+ Then: Configuration imports locales from manifest or shared constant
52
+ NOT hardcoded locale arrays like ['en', 'es', 'fr']
53
+ """
54
+ if locale_manifest is None:
55
+ pytest.skip("Localization not configured")
56
+
57
+ i18n_config = _find_file(
58
+ WEB_DIR,
59
+ "src/i18nConfig.ts",
60
+ "src/i18n/config.ts",
61
+ "src/i18n.ts",
62
+ "src/lib/i18n.ts",
63
+ "src/config/i18n.ts",
64
+ )
65
+
66
+ if i18n_config is None:
67
+ pytest.skip("No i18n config file found in web/src/")
68
+
69
+ content = _read_file_content(i18n_config)
70
+ if content is None:
71
+ pytest.skip(f"Cannot read {i18n_config}")
72
+
73
+ hardcoded_array_pattern = re.compile(
74
+ r"(?:locales|supportedLocales|SUPPORTED_LOCALES|languages)\s*[=:]\s*\[\s*['\"][a-z]{2}",
75
+ re.IGNORECASE
76
+ )
77
+
78
+ if hardcoded_array_pattern.search(content):
79
+ manifest_import_patterns = [
80
+ r"from\s+['\"].*manifest",
81
+ r"import.*manifest",
82
+ r"require\s*\(\s*['\"].*manifest",
83
+ r"SUPPORTED_LOCALES",
84
+ r"getSupportedLocales",
85
+ ]
86
+
87
+ has_manifest_usage = any(
88
+ re.search(pattern, content, re.IGNORECASE)
89
+ for pattern in manifest_import_patterns
90
+ )
91
+
92
+ if not has_manifest_usage:
93
+ msg = (
94
+ f"i18n config has hardcoded locale array: {i18n_config.relative_to(REPO_ROOT)}\n"
95
+ f" Should import from manifest.json or use shared SUPPORTED_LOCALES constant"
96
+ )
97
+ if should_enforce_locale(LocalePhase.FULL_ENFORCEMENT):
98
+ pytest.fail(msg)
99
+ else:
100
+ emit_locale_warning("LOCALE-CODE-2.1", msg, LocalePhase.FULL_ENFORCEMENT)
101
+ pytest.skip(msg)
102
+
103
+
104
+ @pytest.mark.locale
105
+ @pytest.mark.coder
106
+ def test_language_switcher_uses_shared_locales(locale_manifest, locale_manifest_path):
107
+ """
108
+ LOCALE-CODE-2.2: LanguageSwitcher uses shared SUPPORTED_LOCALES
109
+
110
+ Given: Web application with language switcher component
111
+ When: Checking LanguageSwitcher component
112
+ Then: Component imports locales from shared constant or manifest
113
+ NOT hardcoded locale arrays
114
+ """
115
+ if locale_manifest is None:
116
+ pytest.skip("Localization not configured")
117
+
118
+ switcher_patterns = [
119
+ "src/components/LanguageSwitcher.tsx",
120
+ "src/components/LocaleSwitcher.tsx",
121
+ "src/components/ui/LanguageSwitcher.tsx",
122
+ "src/components/common/LanguageSwitcher.tsx",
123
+ "src/features/i18n/LanguageSwitcher.tsx",
124
+ ]
125
+
126
+ switcher_file = _find_file(WEB_DIR, *switcher_patterns)
127
+
128
+ if switcher_file is None:
129
+ switcher_files = list(WEB_DIR.rglob("*[Ll]anguage*[Ss]witcher*.tsx"))
130
+ if not switcher_files:
131
+ switcher_files = list(WEB_DIR.rglob("*[Ll]ocale*[Ss]witcher*.tsx"))
132
+ if switcher_files:
133
+ switcher_file = switcher_files[0]
134
+
135
+ if switcher_file is None:
136
+ pytest.skip("No LanguageSwitcher component found")
137
+
138
+ content = _read_file_content(switcher_file)
139
+ if content is None:
140
+ pytest.skip(f"Cannot read {switcher_file}")
141
+
142
+ hardcoded_array_pattern = re.compile(
143
+ r"(?:locales|languages|options)\s*[=:]\s*\[\s*(?:\{[^}]*locale[^}]*['\"][a-z]{2}|['\"][a-z]{2})",
144
+ re.IGNORECASE
145
+ )
146
+
147
+ if hardcoded_array_pattern.search(content):
148
+ shared_patterns = [
149
+ r"SUPPORTED_LOCALES",
150
+ r"getSupportedLocales",
151
+ r"from\s+['\"].*manifest",
152
+ r"from\s+['\"].*i18n",
153
+ r"from\s+['\"].*config",
154
+ r"useLocales",
155
+ ]
156
+
157
+ has_shared_usage = any(
158
+ re.search(pattern, content, re.IGNORECASE)
159
+ for pattern in shared_patterns
160
+ )
161
+
162
+ if not has_shared_usage:
163
+ msg = (
164
+ f"LanguageSwitcher has hardcoded locale array: {switcher_file.relative_to(REPO_ROOT)}\n"
165
+ f" Should import from shared SUPPORTED_LOCALES or manifest"
166
+ )
167
+ if should_enforce_locale(LocalePhase.FULL_ENFORCEMENT):
168
+ pytest.fail(msg)
169
+ else:
170
+ emit_locale_warning("LOCALE-CODE-2.2", msg, LocalePhase.FULL_ENFORCEMENT)
171
+ pytest.skip(msg)
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://atdd.dev/schemas/tester/locale_manifest.schema.json",
4
+ "title": "Locale Manifest",
5
+ "description": "Schema for localization manifest.json - single source of truth for supported locales and namespaces (Localization Manifest Spec v1)",
6
+ "type": "object",
7
+ "required": ["reference", "locales", "namespaces"],
8
+ "properties": {
9
+ "reference": {
10
+ "type": "string",
11
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$",
12
+ "description": "Reference locale code (e.g., 'en', 'en-US'). All other locales must match this locale's keys."
13
+ },
14
+ "locales": {
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "string",
19
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
20
+ },
21
+ "uniqueItems": true,
22
+ "description": "List of supported locale codes. Must include the reference locale."
23
+ },
24
+ "namespaces": {
25
+ "type": "array",
26
+ "minItems": 1,
27
+ "items": {
28
+ "type": "string",
29
+ "pattern": "^[a-z][a-z0-9_-]*$"
30
+ },
31
+ "uniqueItems": true,
32
+ "description": "Required namespaces that must exist for all locales (e.g., 'common', 'ui', 'errors')"
33
+ },
34
+ "optional_namespaces": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "string",
38
+ "pattern": "^[a-z][a-z0-9_-]*$"
39
+ },
40
+ "uniqueItems": true,
41
+ "description": "Optional namespaces that may not exist for all locales. If present, must match reference keys."
42
+ }
43
+ },
44
+ "additionalProperties": false,
45
+ "examples": [
46
+ {
47
+ "reference": "en",
48
+ "locales": ["en", "es", "fr", "de", "ja"],
49
+ "namespaces": ["common", "ui", "errors"],
50
+ "optional_namespaces": ["landing", "marketing"]
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,451 @@
1
+ """
2
+ Localization coverage validation (Localization Manifest Spec v1).
3
+
4
+ Validates locale files against manifest.json as single source of truth:
5
+ - LOCALE-TEST-1.1: Manifest schema compliance
6
+ - LOCALE-TEST-1.2: All locale/namespace files exist
7
+ - LOCALE-TEST-1.3: All files are valid JSON
8
+ - LOCALE-TEST-1.4: Keys match reference locale (deep comparison)
9
+ - LOCALE-TEST-1.5: Types match reference (object/array/primitive)
10
+ - LOCALE-TEST-1.6: Optional namespaces may be missing
11
+ - LOCALE-TEST-1.7: languageNames.<locale> exists in reference ui.json
12
+ """
13
+
14
+ import json
15
+ import pytest
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Set, Tuple
18
+
19
+ import atdd
20
+ from atdd.coach.utils.locale_phase import (
21
+ LocalePhase,
22
+ should_enforce_locale,
23
+ emit_locale_warning,
24
+ )
25
+ from atdd.coach.utils.repo import find_repo_root
26
+
27
+ # Path constants
28
+ REPO_ROOT = find_repo_root()
29
+ ATDD_PKG_DIR = Path(atdd.__file__).resolve().parent
30
+
31
+
32
+ def _load_json_file(path: Path) -> Tuple[Optional[Dict], Optional[str]]:
33
+ """Load JSON file, returning (data, error_message)."""
34
+ try:
35
+ with open(path) as f:
36
+ return json.load(f), None
37
+ except json.JSONDecodeError as e:
38
+ return None, f"Invalid JSON: {e}"
39
+ except Exception as e:
40
+ return None, str(e)
41
+
42
+
43
+ def _get_all_keys(obj: Any, prefix: str = "") -> Set[str]:
44
+ """
45
+ Extract all keys from nested object using dot notation.
46
+ Ignores keys starting with underscore (private/metadata keys).
47
+ """
48
+ keys = set()
49
+ if isinstance(obj, dict):
50
+ for key, value in obj.items():
51
+ if key.startswith("_"):
52
+ continue
53
+ full_key = f"{prefix}.{key}" if prefix else key
54
+ keys.add(full_key)
55
+ keys.update(_get_all_keys(value, full_key))
56
+ return keys
57
+
58
+
59
+ def _get_type_signature(obj: Any) -> str:
60
+ """Get type signature for comparison (object/array/primitive)."""
61
+ if isinstance(obj, dict):
62
+ return "object"
63
+ elif isinstance(obj, list):
64
+ return f"array[{len(obj)}]"
65
+ elif isinstance(obj, bool):
66
+ return "boolean"
67
+ elif isinstance(obj, int):
68
+ return "number"
69
+ elif isinstance(obj, float):
70
+ return "number"
71
+ elif isinstance(obj, str):
72
+ return "string"
73
+ elif obj is None:
74
+ return "null"
75
+ return "unknown"
76
+
77
+
78
+ def _compare_types(ref_obj: Any, target_obj: Any, path: str = "") -> List[str]:
79
+ """
80
+ Compare types between reference and target objects recursively.
81
+ Returns list of type mismatch descriptions.
82
+ """
83
+ mismatches = []
84
+ ref_type = _get_type_signature(ref_obj)
85
+ target_type = _get_type_signature(target_obj)
86
+
87
+ if ref_type.startswith("array") and target_type.startswith("array"):
88
+ pass
89
+ elif ref_type != target_type:
90
+ key_display = path or "(root)"
91
+ mismatches.append(f"{key_display}: expected {ref_type}, got {target_type}")
92
+
93
+ if isinstance(ref_obj, dict) and isinstance(target_obj, dict):
94
+ for key in ref_obj:
95
+ if key.startswith("_"):
96
+ continue
97
+ if key in target_obj:
98
+ child_path = f"{path}.{key}" if path else key
99
+ mismatches.extend(_compare_types(ref_obj[key], target_obj[key], child_path))
100
+
101
+ return mismatches
102
+
103
+
104
+ @pytest.mark.locale
105
+ def test_locale_manifest_schema_compliance(locale_manifest, locale_manifest_path, load_schema):
106
+ """
107
+ LOCALE-TEST-1.1: Manifest schema compliance
108
+
109
+ Given: localization.manifest configured in .atdd/config.yaml
110
+ When: Loading the manifest file
111
+ Then: File exists and validates against locale_manifest.schema.json
112
+ """
113
+ if locale_manifest_path is None:
114
+ pytest.skip("Localization not configured (localization.manifest not in config)")
115
+
116
+ if not locale_manifest_path.exists():
117
+ msg = f"Manifest file not found: {locale_manifest_path.relative_to(REPO_ROOT)}"
118
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
119
+ pytest.fail(msg)
120
+ else:
121
+ emit_locale_warning("LOCALE-TEST-1.1", msg)
122
+ pytest.skip(msg)
123
+
124
+ schema = load_schema("tester", "locale_manifest.schema.json")
125
+
126
+ try:
127
+ import jsonschema
128
+ jsonschema.validate(locale_manifest, schema)
129
+ except jsonschema.ValidationError as e:
130
+ msg = f"Manifest schema validation failed: {e.message}"
131
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
132
+ pytest.fail(msg)
133
+ else:
134
+ emit_locale_warning("LOCALE-TEST-1.1", msg)
135
+ pytest.skip(msg)
136
+
137
+ if locale_manifest["reference"] not in locale_manifest["locales"]:
138
+ msg = f"Reference locale '{locale_manifest['reference']}' not in locales list"
139
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
140
+ pytest.fail(msg)
141
+ else:
142
+ emit_locale_warning("LOCALE-TEST-1.1", msg)
143
+
144
+
145
+ @pytest.mark.locale
146
+ def test_locale_files_exist(locale_manifest, locales_dir):
147
+ """
148
+ LOCALE-TEST-1.2: All <locale>/<namespace>.json files exist
149
+
150
+ Given: Manifest with locales and namespaces
151
+ When: Checking file system
152
+ Then: All required namespace files exist for all locales
153
+ """
154
+ if locale_manifest is None:
155
+ pytest.skip("Localization not configured")
156
+
157
+ missing_files = []
158
+ locales = locale_manifest.get("locales", [])
159
+ namespaces = locale_manifest.get("namespaces", [])
160
+
161
+ for locale in locales:
162
+ locale_dir = locales_dir / locale
163
+ for namespace in namespaces:
164
+ file_path = locale_dir / f"{namespace}.json"
165
+ if not file_path.exists():
166
+ missing_files.append(f"{locale}/{namespace}.json")
167
+
168
+ if missing_files:
169
+ msg = f"Missing locale files ({len(missing_files)}):\n" + "\n".join(f" - {f}" for f in missing_files[:20])
170
+ if len(missing_files) > 20:
171
+ msg += f"\n ... and {len(missing_files) - 20} more"
172
+
173
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
174
+ pytest.fail(msg)
175
+ else:
176
+ emit_locale_warning("LOCALE-TEST-1.2", msg)
177
+ pytest.skip(msg)
178
+
179
+
180
+ @pytest.mark.locale
181
+ def test_locale_files_valid_json(locale_manifest, locales_dir):
182
+ """
183
+ LOCALE-TEST-1.3: All locale files are valid JSON
184
+
185
+ Given: Locale namespace files
186
+ When: Parsing as JSON
187
+ Then: All files parse without errors
188
+ """
189
+ if locale_manifest is None:
190
+ pytest.skip("Localization not configured")
191
+
192
+ invalid_files = []
193
+ locales = locale_manifest.get("locales", [])
194
+ namespaces = locale_manifest.get("namespaces", [])
195
+ optional_namespaces = locale_manifest.get("optional_namespaces", [])
196
+
197
+ all_namespaces = namespaces + optional_namespaces
198
+
199
+ for locale in locales:
200
+ locale_dir = locales_dir / locale
201
+ for namespace in all_namespaces:
202
+ file_path = locale_dir / f"{namespace}.json"
203
+ if file_path.exists():
204
+ _, error = _load_json_file(file_path)
205
+ if error:
206
+ invalid_files.append(f"{locale}/{namespace}.json: {error}")
207
+
208
+ if invalid_files:
209
+ msg = f"Invalid JSON files ({len(invalid_files)}):\n" + "\n".join(f" - {f}" for f in invalid_files[:10])
210
+ if len(invalid_files) > 10:
211
+ msg += f"\n ... and {len(invalid_files) - 10} more"
212
+
213
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
214
+ pytest.fail(msg)
215
+ else:
216
+ emit_locale_warning("LOCALE-TEST-1.3", msg)
217
+ pytest.skip(msg)
218
+
219
+
220
+ @pytest.mark.locale
221
+ def test_locale_keys_match_reference(locale_manifest, locales_dir):
222
+ """
223
+ LOCALE-TEST-1.4: Keys match reference locale (deep comparison, ignore _ prefix)
224
+
225
+ Given: Reference locale and other locales
226
+ When: Comparing keys at all nesting levels
227
+ Then: All locales have same keys as reference (ignoring _ prefixed keys)
228
+ """
229
+ if locale_manifest is None:
230
+ pytest.skip("Localization not configured")
231
+
232
+ reference = locale_manifest.get("reference")
233
+ locales = locale_manifest.get("locales", [])
234
+ namespaces = locale_manifest.get("namespaces", [])
235
+
236
+ key_mismatches = []
237
+
238
+ for namespace in namespaces:
239
+ ref_path = locales_dir / reference / f"{namespace}.json"
240
+ ref_data, ref_error = _load_json_file(ref_path)
241
+
242
+ if ref_error:
243
+ continue
244
+
245
+ ref_keys = _get_all_keys(ref_data)
246
+
247
+ for locale in locales:
248
+ if locale == reference:
249
+ continue
250
+
251
+ locale_path = locales_dir / locale / f"{namespace}.json"
252
+ locale_data, locale_error = _load_json_file(locale_path)
253
+
254
+ if locale_error:
255
+ continue
256
+
257
+ locale_keys = _get_all_keys(locale_data)
258
+
259
+ missing_keys = ref_keys - locale_keys
260
+ extra_keys = locale_keys - ref_keys
261
+
262
+ if missing_keys:
263
+ key_mismatches.append(
264
+ f"{locale}/{namespace}.json missing keys: {', '.join(sorted(missing_keys)[:5])}"
265
+ + (f" (+{len(missing_keys) - 5} more)" if len(missing_keys) > 5 else "")
266
+ )
267
+
268
+ if extra_keys:
269
+ key_mismatches.append(
270
+ f"{locale}/{namespace}.json extra keys: {', '.join(sorted(extra_keys)[:5])}"
271
+ + (f" (+{len(extra_keys) - 5} more)" if len(extra_keys) > 5 else "")
272
+ )
273
+
274
+ if key_mismatches:
275
+ msg = f"Key mismatches ({len(key_mismatches)}):\n" + "\n".join(f" - {m}" for m in key_mismatches[:15])
276
+ if len(key_mismatches) > 15:
277
+ msg += f"\n ... and {len(key_mismatches) - 15} more"
278
+
279
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
280
+ pytest.fail(msg)
281
+ else:
282
+ emit_locale_warning("LOCALE-TEST-1.4", msg)
283
+ pytest.skip(msg)
284
+
285
+
286
+ @pytest.mark.locale
287
+ def test_locale_types_match_reference(locale_manifest, locales_dir):
288
+ """
289
+ LOCALE-TEST-1.5: Types match reference (object->object, array->array, primitive->same type)
290
+
291
+ Given: Reference locale and other locales
292
+ When: Comparing value types at each key
293
+ Then: Types match between reference and all locales
294
+ """
295
+ if locale_manifest is None:
296
+ pytest.skip("Localization not configured")
297
+
298
+ reference = locale_manifest.get("reference")
299
+ locales = locale_manifest.get("locales", [])
300
+ namespaces = locale_manifest.get("namespaces", [])
301
+
302
+ type_mismatches = []
303
+
304
+ for namespace in namespaces:
305
+ ref_path = locales_dir / reference / f"{namespace}.json"
306
+ ref_data, ref_error = _load_json_file(ref_path)
307
+
308
+ if ref_error:
309
+ continue
310
+
311
+ for locale in locales:
312
+ if locale == reference:
313
+ continue
314
+
315
+ locale_path = locales_dir / locale / f"{namespace}.json"
316
+ locale_data, locale_error = _load_json_file(locale_path)
317
+
318
+ if locale_error:
319
+ continue
320
+
321
+ mismatches = _compare_types(ref_data, locale_data)
322
+ for mismatch in mismatches:
323
+ type_mismatches.append(f"{locale}/{namespace}.json: {mismatch}")
324
+
325
+ if type_mismatches:
326
+ msg = f"Type mismatches ({len(type_mismatches)}):\n" + "\n".join(f" - {m}" for m in type_mismatches[:10])
327
+ if len(type_mismatches) > 10:
328
+ msg += f"\n ... and {len(type_mismatches) - 10} more"
329
+
330
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
331
+ pytest.fail(msg)
332
+ else:
333
+ emit_locale_warning("LOCALE-TEST-1.5", msg)
334
+ pytest.skip(msg)
335
+
336
+
337
+ @pytest.mark.locale
338
+ def test_optional_namespaces_match_reference(locale_manifest, locales_dir):
339
+ """
340
+ LOCALE-TEST-1.6: Optional namespaces may be missing; if present, must match reference
341
+
342
+ Given: Optional namespaces in manifest
343
+ When: Checking locale files
344
+ Then: Optional namespace files may not exist, but if they do, keys must match reference
345
+ """
346
+ if locale_manifest is None:
347
+ pytest.skip("Localization not configured")
348
+
349
+ optional_namespaces = locale_manifest.get("optional_namespaces", [])
350
+ if not optional_namespaces:
351
+ pytest.skip("No optional namespaces defined")
352
+
353
+ reference = locale_manifest.get("reference")
354
+ locales = locale_manifest.get("locales", [])
355
+
356
+ key_mismatches = []
357
+
358
+ for namespace in optional_namespaces:
359
+ ref_path = locales_dir / reference / f"{namespace}.json"
360
+ ref_data, ref_error = _load_json_file(ref_path)
361
+
362
+ if ref_error or ref_data is None:
363
+ continue
364
+
365
+ ref_keys = _get_all_keys(ref_data)
366
+
367
+ for locale in locales:
368
+ if locale == reference:
369
+ continue
370
+
371
+ locale_path = locales_dir / locale / f"{namespace}.json"
372
+ if not locale_path.exists():
373
+ continue
374
+
375
+ locale_data, locale_error = _load_json_file(locale_path)
376
+ if locale_error:
377
+ continue
378
+
379
+ locale_keys = _get_all_keys(locale_data)
380
+
381
+ missing_keys = ref_keys - locale_keys
382
+ extra_keys = locale_keys - ref_keys
383
+
384
+ if missing_keys:
385
+ key_mismatches.append(
386
+ f"{locale}/{namespace}.json (optional) missing keys: {', '.join(sorted(missing_keys)[:5])}"
387
+ )
388
+
389
+ if extra_keys:
390
+ key_mismatches.append(
391
+ f"{locale}/{namespace}.json (optional) extra keys: {', '.join(sorted(extra_keys)[:5])}"
392
+ )
393
+
394
+ if key_mismatches:
395
+ msg = f"Optional namespace key mismatches ({len(key_mismatches)}):\n" + "\n".join(f" - {m}" for m in key_mismatches[:10])
396
+ if len(key_mismatches) > 10:
397
+ msg += f"\n ... and {len(key_mismatches) - 10} more"
398
+
399
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
400
+ pytest.fail(msg)
401
+ else:
402
+ emit_locale_warning("LOCALE-TEST-1.6", msg)
403
+ pytest.skip(msg)
404
+
405
+
406
+ @pytest.mark.locale
407
+ def test_language_names_complete(locale_manifest, locales_dir):
408
+ """
409
+ LOCALE-TEST-1.7: languageNames.<locale> exists in reference ui.json
410
+
411
+ Given: Manifest with locales list
412
+ When: Checking reference ui.json for languageNames
413
+ Then: Each locale in manifest has a corresponding languageNames entry
414
+ """
415
+ if locale_manifest is None:
416
+ pytest.skip("Localization not configured")
417
+
418
+ reference = locale_manifest.get("reference")
419
+ locales = locale_manifest.get("locales", [])
420
+ namespaces = locale_manifest.get("namespaces", [])
421
+
422
+ if "ui" not in namespaces:
423
+ pytest.skip("No 'ui' namespace configured - skipping languageNames check")
424
+
425
+ ui_path = locales_dir / reference / "ui.json"
426
+ ui_data, ui_error = _load_json_file(ui_path)
427
+
428
+ if ui_error:
429
+ pytest.skip(f"Cannot read reference ui.json: {ui_error}")
430
+
431
+ language_names = ui_data.get("languageNames", {})
432
+ if not language_names:
433
+ msg = "Reference ui.json missing 'languageNames' object"
434
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
435
+ pytest.fail(msg)
436
+ else:
437
+ emit_locale_warning("LOCALE-TEST-1.7", msg)
438
+ pytest.skip(msg)
439
+
440
+ missing_names = []
441
+ for locale in locales:
442
+ if locale not in language_names:
443
+ missing_names.append(locale)
444
+
445
+ if missing_names:
446
+ msg = f"Missing languageNames entries for locales: {', '.join(missing_names)}"
447
+ if should_enforce_locale(LocalePhase.TESTER_ENFORCEMENT):
448
+ pytest.fail(msg)
449
+ else:
450
+ emit_locale_warning("LOCALE-TEST-1.7", msg)
451
+ pytest.skip(msg)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atdd
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: ATDD Platform - Acceptance Test Driven Development toolkit
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -25,19 +25,20 @@ atdd/coach/commands/tests/test_telemetry_array_validation.py,sha256=WK5ZXvR1avlz
25
25
  atdd/coach/conventions/session.convention.yaml,sha256=1wCxQ_Y2Wb2080Xt2JZs0_WsV8_4SC0Tq87G_BCGdiE,26049
26
26
  atdd/coach/overlays/__init__.py,sha256=2lMiMSgfLJ3YHLpbzNI5B88AdQxiMEwjIfsWWb8t3To,123
27
27
  atdd/coach/overlays/claude.md,sha256=33mhpqhmsRhCtdWlU7cMXAJDsaVra9uBBK8URV8OtQA,101
28
- atdd/coach/schemas/config.schema.json,sha256=XcqpISpV-57Kk7HdBXz_4K0jqj_SdKgBHy9VyVpNRD8,4191
28
+ atdd/coach/schemas/config.schema.json,sha256=47cFGE5juBv9ewhtgrNir4b6I9imIIo8VjoD9yvASf4,4578
29
29
  atdd/coach/schemas/manifest.schema.json,sha256=WO13-YF_FgH1awh96khCtk-112b6XSC24anlY3B7GjY,2885
30
30
  atdd/coach/templates/ATDD.md,sha256=h_oPpKLX7nuafC0VAoKCnkM2-kGQeksR34QWlGyfMwU,13236
31
31
  atdd/coach/templates/SESSION-TEMPLATE.md,sha256=cGT_0x5KLbPHOCiuM8evLGpWKIlR-aggqxiBtbjSJoo,9478
32
32
  atdd/coach/utils/__init__.py,sha256=7Jbo-heJEKSAn6I0s35z_2S4R8qGZ48PL6a2IntcNYg,148
33
33
  atdd/coach/utils/config.py,sha256=6XXaaeVfjTrJwdaR0IZ6Kf1-1ZHhaCVLO5pNx_A2el4,3320
34
34
  atdd/coach/utils/coverage_phase.py,sha256=14CzGiTEeb-Z-CMYnJjx1-4dn3LbQVJUlFr_-1bKVMc,3250
35
+ atdd/coach/utils/locale_phase.py,sha256=S6eORhvj2N412BOY4QFQGcLA_twsr_D4vviy4X-KDNo,3236
35
36
  atdd/coach/utils/repo.py,sha256=0kiF5WpVTen0nO14u5T0RflznZhgGco2i9CwKobOh38,3757
36
37
  atdd/coach/utils/train_spec_phase.py,sha256=Mk8CiMoO6jb-VGttHgI20KIG26r9cjSz4gDfk01q1M0,3025
37
38
  atdd/coach/utils/graph/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
39
  atdd/coach/utils/graph/urn.py,sha256=O2AHIB_CmmMUvXzyejc_oFReNW_rOcw7m4qaqSYcnNQ,33558
39
40
  atdd/coach/validators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
- atdd/coach/validators/shared_fixtures.py,sha256=q-J39x0pxdEWJmRWe45i6VyYy8-0MHsi9ElMAvDx3MA,19474
41
+ atdd/coach/validators/shared_fixtures.py,sha256=Ia3B2fUW-aKibwVPF6RnRemtu3R_Dfb-2MvPVNitgxg,20931
41
42
  atdd/coach/validators/test_enrich_wagon_registry.py,sha256=WeTwYJqoNY6mEYc-QAvQo7YVagSOjaNKxB6Q6dpWqIM,6561
42
43
  atdd/coach/validators/test_registry.py,sha256=ffN70yA_1xxL3R8gdpGbY2M8dQXyuajIZhBZ-ylNiNs,17845
43
44
  atdd/coach/validators/test_release_versioning.py,sha256=B40DfbtrSGguPc537zXmjT75hhySfocWLzJWqOKZQcU,5678
@@ -84,6 +85,7 @@ atdd/coder/validators/test_green_layer_dependencies.py,sha256=8TWuTD3RrDBqp9LUCS
84
85
  atdd/coder/validators/test_green_python_layer_structure.py,sha256=WXo1VA5-WprgtAQgC8ekhuIRJR47b_qgTYTjPn7svms,4389
85
86
  atdd/coder/validators/test_green_supabase_layer_structure.py,sha256=cLj85acLX6Knewk9AWbiJDwEzoaE-NBNollyJCvRUD0,4371
86
87
  atdd/coder/validators/test_hierarchy_coverage.py,sha256=1JuaiO4k-v3_rL5x4-MPsKajAv-K5cTzevW5eS4cC-U,12240
88
+ atdd/coder/validators/test_i18n_runtime.py,sha256=pmx1nhvOS1nk2xa1E5Lm8yPgpylNhV6AjDECeIzN4Pg,5575
87
89
  atdd/coder/validators/test_import_boundaries.py,sha256=3kzVMKIwZU9FcS0YLU8gsuHSDRlRr2rU3UPgoraV2FU,11811
88
90
  atdd/coder/validators/test_init_file_urns.py,sha256=uDJ2MgfJNFcjzoIKItn73n9V08m3ZBkt1SAZgHWdXPs,17914
89
91
  atdd/coder/validators/test_preact_layer_boundaries.py,sha256=rabXY9gif3b8QH9_Kz5Pu6FZSvDpfQKDm_SKjdpLD44,7766
@@ -149,6 +151,7 @@ atdd/tester/schemas/event.tmpl.json,sha256=3-7cSaesuPcVZP5NENrg-GnDRQPbwkWB_1ISI
149
151
  atdd/tester/schemas/http.tmpl.json,sha256=zrynEO18HmEXanQDVk3XZNHNOp1lUyVKXTR-f7_KAGI,818
150
152
  atdd/tester/schemas/job.tmpl.json,sha256=7HtX-esAb9asB-9uNyuWDRKE-HWovlNnuYYllSzjJDg,926
151
153
  atdd/tester/schemas/load.tmpl.json,sha256=wSPfGXTEVQrgsqVXihwiDIKWS_QErkXlh2WdVJwLqoc,971
154
+ atdd/tester/schemas/locale_manifest.schema.json,sha256=4-A25522C4Bdy7Y3I9USqsk4K97hkv3rTr60-dcVZAk,1736
152
155
  atdd/tester/schemas/metric.tmpl.json,sha256=jHTQgAsnVXWPlQipjnu79eqZwvHcDV5giaoN9w1HZdY,949
153
156
  atdd/tester/schemas/pack.schema.json,sha256=pQfbvaB5Kl1SFCewz60zaTRviuZm-gM_9QCYipJWa3w,4575
154
157
  atdd/tester/schemas/realtime.tmpl.json,sha256=c9Sbkqk6vFUhzTWI2slfyTfEwJ0ltFhQrtjPabRLtmo,1141
@@ -183,6 +186,7 @@ atdd/tester/validators/test_dual_ac_reference.py,sha256=LDhIqXyVxgWVCgj7FneDTLt6
183
186
  atdd/tester/validators/test_fixture_validity.py,sha256=Fp4AWwhvZlos1ik_d7NbP030Qq-klZLnCmc12ylptqs,12101
184
187
  atdd/tester/validators/test_hierarchy_coverage.py,sha256=o2jd3dsqvqtqQ3I3RQa4FGoYIJE0vArUIISurDxYTgw,21060
185
188
  atdd/tester/validators/test_isolation.py,sha256=NYrqJcVDZH0SDRWHlPdazG6THT4w3XEvz_xn4PBxU4E,16489
189
+ atdd/tester/validators/test_locale_coverage.py,sha256=amnZAx5wTvF6mGbclvu40n0XhYBKLl97SnBVkbLMv1E,15487
186
190
  atdd/tester/validators/test_migration_coverage.py,sha256=LOx0L9KLH4gVisNHXhxKrzHLgCgj4PVZxeZ-2gg-SQk,7344
187
191
  atdd/tester/validators/test_migration_criteria.py,sha256=YDGvWjkVSjUVVNv4RJWLdy4iLoG1EXzmm_ficD0Gt3Q,7896
188
192
  atdd/tester/validators/test_migration_generation.py,sha256=wpTmuxvM13OfSgC-3SJBdtB2XaPjBYyD-jXVYWi7Z9Q,4064
@@ -196,9 +200,9 @@ atdd/tester/validators/test_train_frontend_e2e.py,sha256=fpfUwTbAWzuqxbVKoaFw-ab
196
200
  atdd/tester/validators/test_train_frontend_python.py,sha256=KK2U3oNFWLyBK7YHC0fU7shR05k93gVcO762AI8Q3pw,9018
197
201
  atdd/tester/validators/test_typescript_test_naming.py,sha256=E-TyGv_GVlTfsbyuxrtv9sOWSZS_QcpH6rrJFbWoeeU,11280
198
202
  atdd/tester/validators/test_typescript_test_structure.py,sha256=eV89SD1RaKtchBZupqhnJmaruoROosf3LwB4Fwe4UJI,2612
199
- atdd-0.6.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
200
- atdd-0.6.1.dist-info/METADATA,sha256=tQJjLbry8VRfv7IXzkaChFTwEM_yoV9QZjEa17TEb5A,8716
201
- atdd-0.6.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
202
- atdd-0.6.1.dist-info/entry_points.txt,sha256=-C3yrA1WQQfN3iuGmSzPapA5cKVBEYU5Q1HUffSJTbY,38
203
- atdd-0.6.1.dist-info/top_level.txt,sha256=VKkf6Uiyrm4RS6ULCGM-v8AzYN8K2yg8SMqwJLoO-xs,5
204
- atdd-0.6.1.dist-info/RECORD,,
203
+ atdd-0.7.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
204
+ atdd-0.7.0.dist-info/METADATA,sha256=EEjcJY5c8XfUKF8ebYBc1o3mjFrJqqJldicW2coK-iQ,8716
205
+ atdd-0.7.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
206
+ atdd-0.7.0.dist-info/entry_points.txt,sha256=-C3yrA1WQQfN3iuGmSzPapA5cKVBEYU5Q1HUffSJTbY,38
207
+ atdd-0.7.0.dist-info/top_level.txt,sha256=VKkf6Uiyrm4RS6ULCGM-v8AzYN8K2yg8SMqwJLoO-xs,5
208
+ atdd-0.7.0.dist-info/RECORD,,
File without changes