atdd 0.2.1__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.
Files changed (184) hide show
  1. atdd/__init__.py +6 -0
  2. atdd/__main__.py +4 -0
  3. atdd/cli.py +404 -0
  4. atdd/coach/__init__.py +0 -0
  5. atdd/coach/commands/__init__.py +0 -0
  6. atdd/coach/commands/add_persistence_metadata.py +215 -0
  7. atdd/coach/commands/analyze_migrations.py +188 -0
  8. atdd/coach/commands/consumers.py +720 -0
  9. atdd/coach/commands/infer_governance_status.py +149 -0
  10. atdd/coach/commands/initializer.py +177 -0
  11. atdd/coach/commands/interface.py +1078 -0
  12. atdd/coach/commands/inventory.py +565 -0
  13. atdd/coach/commands/migration.py +240 -0
  14. atdd/coach/commands/registry.py +1560 -0
  15. atdd/coach/commands/session.py +430 -0
  16. atdd/coach/commands/sync.py +405 -0
  17. atdd/coach/commands/test_interface.py +399 -0
  18. atdd/coach/commands/test_runner.py +141 -0
  19. atdd/coach/commands/tests/__init__.py +1 -0
  20. atdd/coach/commands/tests/test_telemetry_array_validation.py +235 -0
  21. atdd/coach/commands/traceability.py +4264 -0
  22. atdd/coach/conventions/session.convention.yaml +754 -0
  23. atdd/coach/overlays/__init__.py +2 -0
  24. atdd/coach/overlays/claude.md +2 -0
  25. atdd/coach/schemas/config.schema.json +34 -0
  26. atdd/coach/schemas/manifest.schema.json +101 -0
  27. atdd/coach/templates/ATDD.md +282 -0
  28. atdd/coach/templates/SESSION-TEMPLATE.md +327 -0
  29. atdd/coach/utils/__init__.py +0 -0
  30. atdd/coach/utils/graph/__init__.py +0 -0
  31. atdd/coach/utils/graph/urn.py +875 -0
  32. atdd/coach/validators/__init__.py +0 -0
  33. atdd/coach/validators/shared_fixtures.py +365 -0
  34. atdd/coach/validators/test_enrich_wagon_registry.py +167 -0
  35. atdd/coach/validators/test_registry.py +575 -0
  36. atdd/coach/validators/test_session_validation.py +1183 -0
  37. atdd/coach/validators/test_traceability.py +448 -0
  38. atdd/coach/validators/test_update_feature_paths.py +108 -0
  39. atdd/coach/validators/test_validate_contract_consumers.py +297 -0
  40. atdd/coder/__init__.py +1 -0
  41. atdd/coder/conventions/adapter.recipe.yaml +88 -0
  42. atdd/coder/conventions/backend.convention.yaml +460 -0
  43. atdd/coder/conventions/boundaries.convention.yaml +666 -0
  44. atdd/coder/conventions/commons.convention.yaml +460 -0
  45. atdd/coder/conventions/complexity.recipe.yaml +109 -0
  46. atdd/coder/conventions/component-naming.convention.yaml +178 -0
  47. atdd/coder/conventions/design.convention.yaml +327 -0
  48. atdd/coder/conventions/design.recipe.yaml +273 -0
  49. atdd/coder/conventions/dto.convention.yaml +660 -0
  50. atdd/coder/conventions/frontend.convention.yaml +542 -0
  51. atdd/coder/conventions/green.convention.yaml +1012 -0
  52. atdd/coder/conventions/presentation.convention.yaml +587 -0
  53. atdd/coder/conventions/refactor.convention.yaml +535 -0
  54. atdd/coder/conventions/technology.convention.yaml +206 -0
  55. atdd/coder/conventions/tests/__init__.py +0 -0
  56. atdd/coder/conventions/tests/test_adapter_recipe.py +302 -0
  57. atdd/coder/conventions/tests/test_complexity_recipe.py +289 -0
  58. atdd/coder/conventions/tests/test_component_taxonomy.py +278 -0
  59. atdd/coder/conventions/tests/test_component_urn_naming.py +165 -0
  60. atdd/coder/conventions/tests/test_thinness_recipe.py +286 -0
  61. atdd/coder/conventions/thinness.recipe.yaml +82 -0
  62. atdd/coder/conventions/train.convention.yaml +325 -0
  63. atdd/coder/conventions/verification.protocol.yaml +53 -0
  64. atdd/coder/schemas/design_system.schema.json +361 -0
  65. atdd/coder/validators/__init__.py +0 -0
  66. atdd/coder/validators/test_commons_structure.py +485 -0
  67. atdd/coder/validators/test_complexity.py +416 -0
  68. atdd/coder/validators/test_cross_language_consistency.py +431 -0
  69. atdd/coder/validators/test_design_system_compliance.py +413 -0
  70. atdd/coder/validators/test_dto_testing_patterns.py +268 -0
  71. atdd/coder/validators/test_green_cross_stack_layers.py +168 -0
  72. atdd/coder/validators/test_green_layer_dependencies.py +148 -0
  73. atdd/coder/validators/test_green_python_layer_structure.py +103 -0
  74. atdd/coder/validators/test_green_supabase_layer_structure.py +103 -0
  75. atdd/coder/validators/test_import_boundaries.py +396 -0
  76. atdd/coder/validators/test_init_file_urns.py +593 -0
  77. atdd/coder/validators/test_preact_layer_boundaries.py +221 -0
  78. atdd/coder/validators/test_presentation_convention.py +260 -0
  79. atdd/coder/validators/test_python_architecture.py +674 -0
  80. atdd/coder/validators/test_quality_metrics.py +420 -0
  81. atdd/coder/validators/test_station_master_pattern.py +244 -0
  82. atdd/coder/validators/test_train_infrastructure.py +454 -0
  83. atdd/coder/validators/test_train_urns.py +293 -0
  84. atdd/coder/validators/test_typescript_architecture.py +616 -0
  85. atdd/coder/validators/test_usecase_structure.py +421 -0
  86. atdd/coder/validators/test_wagon_boundaries.py +586 -0
  87. atdd/conftest.py +126 -0
  88. atdd/planner/__init__.py +1 -0
  89. atdd/planner/conventions/acceptance.convention.yaml +538 -0
  90. atdd/planner/conventions/appendix.convention.yaml +187 -0
  91. atdd/planner/conventions/artifact-naming.convention.yaml +852 -0
  92. atdd/planner/conventions/component.convention.yaml +670 -0
  93. atdd/planner/conventions/criteria.convention.yaml +141 -0
  94. atdd/planner/conventions/feature.convention.yaml +371 -0
  95. atdd/planner/conventions/interface.convention.yaml +382 -0
  96. atdd/planner/conventions/steps.convention.yaml +141 -0
  97. atdd/planner/conventions/train.convention.yaml +552 -0
  98. atdd/planner/conventions/wagon.convention.yaml +275 -0
  99. atdd/planner/conventions/wmbt.convention.yaml +258 -0
  100. atdd/planner/schemas/acceptance.schema.json +336 -0
  101. atdd/planner/schemas/appendix.schema.json +78 -0
  102. atdd/planner/schemas/component.schema.json +114 -0
  103. atdd/planner/schemas/feature.schema.json +197 -0
  104. atdd/planner/schemas/train.schema.json +192 -0
  105. atdd/planner/schemas/wagon.schema.json +281 -0
  106. atdd/planner/schemas/wmbt.schema.json +59 -0
  107. atdd/planner/validators/__init__.py +0 -0
  108. atdd/planner/validators/conftest.py +5 -0
  109. atdd/planner/validators/test_draft_wagon_registry.py +374 -0
  110. atdd/planner/validators/test_plan_cross_refs.py +240 -0
  111. atdd/planner/validators/test_plan_uniqueness.py +224 -0
  112. atdd/planner/validators/test_plan_urn_resolution.py +268 -0
  113. atdd/planner/validators/test_plan_wagons.py +174 -0
  114. atdd/planner/validators/test_train_validation.py +514 -0
  115. atdd/planner/validators/test_wagon_urn_chain.py +648 -0
  116. atdd/planner/validators/test_wmbt_consistency.py +327 -0
  117. atdd/planner/validators/test_wmbt_vocabulary.py +632 -0
  118. atdd/tester/__init__.py +1 -0
  119. atdd/tester/conventions/artifact.convention.yaml +257 -0
  120. atdd/tester/conventions/contract.convention.yaml +1009 -0
  121. atdd/tester/conventions/filename.convention.yaml +555 -0
  122. atdd/tester/conventions/migration.convention.yaml +509 -0
  123. atdd/tester/conventions/red.convention.yaml +797 -0
  124. atdd/tester/conventions/routing.convention.yaml +51 -0
  125. atdd/tester/conventions/telemetry.convention.yaml +458 -0
  126. atdd/tester/schemas/a11y.tmpl.json +17 -0
  127. atdd/tester/schemas/artifact.schema.json +189 -0
  128. atdd/tester/schemas/contract.schema.json +591 -0
  129. atdd/tester/schemas/contract.tmpl.json +95 -0
  130. atdd/tester/schemas/db.tmpl.json +20 -0
  131. atdd/tester/schemas/e2e.tmpl.json +17 -0
  132. atdd/tester/schemas/edge_function.tmpl.json +17 -0
  133. atdd/tester/schemas/event.tmpl.json +17 -0
  134. atdd/tester/schemas/http.tmpl.json +19 -0
  135. atdd/tester/schemas/job.tmpl.json +18 -0
  136. atdd/tester/schemas/load.tmpl.json +21 -0
  137. atdd/tester/schemas/metric.tmpl.json +19 -0
  138. atdd/tester/schemas/pack.schema.json +139 -0
  139. atdd/tester/schemas/realtime.tmpl.json +20 -0
  140. atdd/tester/schemas/rls.tmpl.json +18 -0
  141. atdd/tester/schemas/script.tmpl.json +16 -0
  142. atdd/tester/schemas/sec.tmpl.json +18 -0
  143. atdd/tester/schemas/storage.tmpl.json +18 -0
  144. atdd/tester/schemas/telemetry.schema.json +128 -0
  145. atdd/tester/schemas/telemetry_tracking_manifest.schema.json +143 -0
  146. atdd/tester/schemas/test_filename.schema.json +194 -0
  147. atdd/tester/schemas/test_intent.schema.json +179 -0
  148. atdd/tester/schemas/unit.tmpl.json +18 -0
  149. atdd/tester/schemas/visual.tmpl.json +18 -0
  150. atdd/tester/schemas/ws.tmpl.json +17 -0
  151. atdd/tester/utils/__init__.py +0 -0
  152. atdd/tester/utils/filename.py +300 -0
  153. atdd/tester/validators/__init__.py +0 -0
  154. atdd/tester/validators/cleanup_duplicate_headers.py +116 -0
  155. atdd/tester/validators/cleanup_duplicate_headers_v2.py +135 -0
  156. atdd/tester/validators/conftest.py +5 -0
  157. atdd/tester/validators/coverage_gap_report.py +321 -0
  158. atdd/tester/validators/fix_dual_ac_references.py +179 -0
  159. atdd/tester/validators/remove_duplicate_lines.py +93 -0
  160. atdd/tester/validators/test_acceptance_urn_filename_mapping.py +359 -0
  161. atdd/tester/validators/test_acceptance_urn_separator.py +166 -0
  162. atdd/tester/validators/test_artifact_naming_category.py +307 -0
  163. atdd/tester/validators/test_contract_schema_compliance.py +706 -0
  164. atdd/tester/validators/test_contracts_structure.py +200 -0
  165. atdd/tester/validators/test_coverage_adequacy.py +797 -0
  166. atdd/tester/validators/test_dual_ac_reference.py +225 -0
  167. atdd/tester/validators/test_fixture_validity.py +372 -0
  168. atdd/tester/validators/test_isolation.py +487 -0
  169. atdd/tester/validators/test_migration_coverage.py +204 -0
  170. atdd/tester/validators/test_migration_criteria.py +276 -0
  171. atdd/tester/validators/test_migration_generation.py +116 -0
  172. atdd/tester/validators/test_python_test_naming.py +410 -0
  173. atdd/tester/validators/test_red_layer_validation.py +95 -0
  174. atdd/tester/validators/test_red_python_layer_structure.py +87 -0
  175. atdd/tester/validators/test_red_supabase_layer_structure.py +90 -0
  176. atdd/tester/validators/test_telemetry_structure.py +634 -0
  177. atdd/tester/validators/test_typescript_test_naming.py +301 -0
  178. atdd/tester/validators/test_typescript_test_structure.py +84 -0
  179. atdd-0.2.1.dist-info/METADATA +221 -0
  180. atdd-0.2.1.dist-info/RECORD +184 -0
  181. atdd-0.2.1.dist-info/WHEEL +5 -0
  182. atdd-0.2.1.dist-info/entry_points.txt +2 -0
  183. atdd-0.2.1.dist-info/licenses/LICENSE +674 -0
  184. atdd-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,648 @@
1
+ """
2
+ Platform test: Complete wagon URN chain reconciliation.
3
+
4
+ Given a wagon URN, recursively validates the entire chain purely via URNs:
5
+ wagon โ†’ produce โ†’ contract/telemetry โ†’ features โ†’ code โ†’ wmbts โ†’ acceptances โ†’ tests
6
+
7
+ This is a fast, parametrized test that validates 100% URN traceability with no inference.
8
+ Validates:
9
+ - Specification layer: wagon, features, wmbts, acceptances
10
+ - Interface layer: contracts, telemetry with signal files
11
+ - Implementation layer: code files with component: URNs
12
+ - Test layer: test files with acc: URNs
13
+
14
+ Acceptance URN Format Support (SPEC-COACH-UTILS-0282):
15
+ - NEW format: acc:{wagon}:{wmbt_id}-{harness}-{NNN}[-{slug}]
16
+ Example: acc:pace-dilemmas:L001-UNIT-001
17
+ - OLD format: acc:{wagon}:{wmbt_id}:{id} or acc:{wagon}.{wmbt_id}.{id}
18
+ Example: acc:maintain-ux:L001:AC-HTTP-001 or acc:maintain-ux.L001.AC-HTTP-001
19
+ """
20
+ import pytest
21
+ import yaml
22
+ import json
23
+ from pathlib import Path
24
+ from typing import Dict, Any, List, Set
25
+
26
+ # Path constants
27
+ REPO_ROOT = Path(__file__).resolve().parents[4]
28
+ PLAN_DIR = REPO_ROOT / "plan"
29
+ CONTRACTS_DIR = REPO_ROOT / "contracts"
30
+ TELEMETRY_DIR = REPO_ROOT / "telemetry"
31
+
32
+
33
+ class WagonChainValidator:
34
+ """Validates complete URN chain for a wagon."""
35
+
36
+ def __init__(self, wagon_slug: str):
37
+ self.wagon_slug = wagon_slug
38
+ self.wagon_urn = f"wagon:{wagon_slug}"
39
+ self.errors: List[str] = []
40
+ self.stats = {
41
+ "produce_count": 0,
42
+ "contract_urns": 0,
43
+ "contract_schemas": 0,
44
+ "telemetry_urns": 0,
45
+ "telemetry_signals": 0,
46
+ "feature_urns": 0,
47
+ "feature_yaml_urns": 0,
48
+ "wmbt_urns": 0,
49
+ "wmbt_yaml_urns": 0,
50
+ "acceptance_urns": 0,
51
+ "test_files": 0,
52
+ "code_files": 0
53
+ }
54
+
55
+ def validate(self) -> bool:
56
+ """Run complete chain validation. Returns True if valid."""
57
+ # 1. Load wagon manifest via URN
58
+ wagon_path = self._resolve_wagon_urn(self.wagon_urn)
59
+ if not wagon_path:
60
+ return False
61
+
62
+ with open(wagon_path) as f:
63
+ wagon_manifest = yaml.safe_load(f)
64
+
65
+ # 2. Validate produce artifacts
66
+ self._validate_produce_artifacts(wagon_manifest)
67
+
68
+ # 3. Validate features โ†’ code files
69
+ self._validate_features(wagon_manifest)
70
+
71
+ # 4. Validate WMBTs โ†’ acceptances โ†’ test files
72
+ self._validate_wmbts(wagon_manifest)
73
+
74
+ return len(self.errors) == 0
75
+
76
+ def _resolve_wagon_urn(self, wagon_urn: str) -> Path:
77
+ """Resolve wagon:slug to plan/{dirname}/_{dirname}.yaml."""
78
+ if not wagon_urn.startswith("wagon:"):
79
+ self.errors.append(f"Invalid wagon URN: {wagon_urn}")
80
+ return None
81
+
82
+ slug = wagon_urn.split(":")[1]
83
+ dirname = slug.replace("-", "_")
84
+ manifest_path = PLAN_DIR / dirname / f"_{dirname}.yaml"
85
+
86
+ if not manifest_path.exists():
87
+ self.errors.append(
88
+ f"Wagon URN {wagon_urn} does not resolve to filesystem:\n"
89
+ f" Expected: {manifest_path}\n"
90
+ f" Path does not exist"
91
+ )
92
+ return None
93
+
94
+ return manifest_path
95
+
96
+ def _validate_produce_artifacts(self, wagon_manifest: Dict[str, Any]):
97
+ """Validate produce โ†’ contract + telemetry URNs."""
98
+ produce_items = wagon_manifest.get("produce", [])
99
+ self.stats["produce_count"] = len(produce_items)
100
+
101
+ for idx, item in enumerate(produce_items):
102
+ artifact_name = item.get("name", "")
103
+ contract_urn = item.get("contract")
104
+ telemetry_urn = item.get("telemetry")
105
+
106
+ # Validate contract URN
107
+ if contract_urn:
108
+ if not self._validate_contract_urn(contract_urn):
109
+ self.errors.append(
110
+ f"produce[{idx}] contract URN {contract_urn} resolution failed"
111
+ )
112
+ else:
113
+ self.stats["contract_urns"] += 1
114
+
115
+ # Validate telemetry URN (handle both string and list)
116
+ if telemetry_urn:
117
+ telemetry_urns = telemetry_urn if isinstance(telemetry_urn, list) else [telemetry_urn]
118
+ for urn in telemetry_urns:
119
+ if not self._validate_telemetry_urn(urn):
120
+ self.errors.append(
121
+ f"produce[{idx}] telemetry URN {urn} resolution failed"
122
+ )
123
+ else:
124
+ self.stats["telemetry_urns"] += 1
125
+
126
+ def _validate_contract_urn(self, contract_urn: str) -> bool:
127
+ """Validate contract URN resolves to file or directory per convention.
128
+
129
+ Supports patterns per artifact-naming.convention.yaml:
130
+ - FLAT: contracts/{domain}/{resource}.schema.json (singular resource)
131
+ - FACETED: contracts/{domain}/{aspect}/{variant}.schema.json (dot notation)
132
+ - COLLECTION: contracts/{domain}/{resource}/ (plural resource with multiple schemas)
133
+
134
+ Per convention: Split artifact name by colons (:) and dots (.) - each segment creates a directory level.
135
+ """
136
+ # Validate URN format
137
+ if not contract_urn.startswith("contract:"):
138
+ return False
139
+
140
+ # Remove "contract:" prefix and split by both : and .
141
+ artifact_part = contract_urn[9:] # Remove "contract:"
142
+
143
+ # Split by both : and . per artifact-naming convention
144
+ import re
145
+ segments = re.split(r'[:\.]', artifact_part)
146
+
147
+ if len(segments) < 2:
148
+ return False
149
+
150
+ # Reconstruct domain:resource for $id validation (preserves original separators)
151
+ domain_resource = artifact_part
152
+
153
+ # Build file path: all segments become directories except the last one becomes filename
154
+ # contracts/{seg1}/{seg2}/{...}/{segN}.schema.json
155
+ path_parts = segments[:-1]
156
+ filename = f"{segments[-1]}.schema.json"
157
+
158
+ contract_file = CONTRACTS_DIR / Path(*path_parts) / filename
159
+
160
+ # Try as a file first (FLAT or FACETED pattern)
161
+ if contract_file.exists() and contract_file.is_file():
162
+ try:
163
+ with open(contract_file) as f:
164
+ schema = json.load(f)
165
+
166
+ schema_id = schema.get("$id", "")
167
+ if not schema_id:
168
+ self.stats["contract_schemas"] += 1
169
+ return True # File exists, skip $id validation
170
+
171
+ # Validate $id contains the artifact name (with original separators)
172
+ if domain_resource not in schema_id:
173
+ self.errors.append(
174
+ f"Contract schema $id mismatch:\n"
175
+ f" URN: {contract_urn}\n"
176
+ f" File: {contract_file.relative_to(REPO_ROOT)}\n"
177
+ f" Found $id: '{schema_id}'\n"
178
+ f" Must contain: '{domain_resource}'"
179
+ )
180
+ return False
181
+
182
+ self.stats["contract_schemas"] += 1
183
+ return True
184
+
185
+ except Exception as e:
186
+ self.errors.append(
187
+ f"Error reading contract schema {contract_file.relative_to(REPO_ROOT)}: {str(e)}"
188
+ )
189
+ return False
190
+
191
+ # Try as a directory (COLLECTION pattern)
192
+ # For collections, the directory is at the parent level
193
+ contract_dir = CONTRACTS_DIR / Path(*segments)
194
+ if contract_dir.exists() and contract_dir.is_dir():
195
+ # COLLECTION pattern - validate as directory
196
+ contract_path = contract_dir
197
+ else:
198
+ # Neither FLAT/FACETED nor COLLECTION pattern found
199
+ return False
200
+
201
+ contract_path = contract_dir
202
+
203
+ # Validate schema files have $id fields
204
+ schema_files = list(contract_path.glob("*.schema.json"))
205
+ for schema_file in schema_files:
206
+ try:
207
+ with open(schema_file) as f:
208
+ schema = json.load(f)
209
+
210
+ schema_id = schema.get("$id", "")
211
+
212
+ # Skip validation if $id is not present (optional for now)
213
+ if not schema_id:
214
+ continue
215
+
216
+ # Schema $id can have multiple formats:
217
+ # 1. ux:foundations:color:v1.1
218
+ # 2. urn:contract:ux:foundations:layout
219
+ # Validate it contains the artifact name pattern (with original separators)
220
+ if domain_resource not in schema_id:
221
+ self.errors.append(
222
+ f"Contract schema $id mismatch:\n"
223
+ f" URN: {contract_urn}\n"
224
+ f" File: {schema_file.relative_to(REPO_ROOT)}\n"
225
+ f" Found $id: '{schema_id}'\n"
226
+ f" Must contain: '{domain_resource}'"
227
+ )
228
+ continue
229
+
230
+ self.stats["contract_schemas"] += 1
231
+
232
+ except Exception as e:
233
+ self.errors.append(
234
+ f"Error reading contract schema {schema_file.relative_to(REPO_ROOT)}: {str(e)}"
235
+ )
236
+
237
+ return True
238
+
239
+ def _validate_telemetry_urn(self, telemetry_urn: str) -> bool:
240
+ """Validate telemetry URN resolves to directory with signal files.
241
+
242
+ Telemetry structure mirrors contracts - subdirectories with signal files:
243
+ - URN: telemetry:commons:ux:foundations
244
+ - Path: telemetry/commons/ux/foundations/ (subdirectory matching URN)
245
+ - URN: telemetry:match:dilemma.paired
246
+ - Path: telemetry/match/dilemma/paired/ (both : and . create directory levels)
247
+ - Files:
248
+ - {resource}.{type}.{plane}[.{measure}].json (e.g., color.metric.be.count.json)
249
+ - {domain}.{type}.{plane}[.{measure}].json (e.g., foundations.metric.be.error-rate.json)
250
+
251
+ Supports multi-level paths:
252
+ - telemetry:ux:foundations โ†’ telemetry/ux/foundations/
253
+ - telemetry:commons:ux:foundations โ†’ telemetry/commons/ux/foundations/
254
+ - telemetry:match:dilemma.paired โ†’ telemetry/match/dilemma/paired/
255
+ """
256
+ parts = telemetry_urn.split(":")
257
+ if len(parts) < 3 or parts[0] != "telemetry":
258
+ return False
259
+
260
+ # Use all parts after 'telemetry:' to construct the path (mirrors contract structure)
261
+ # Split by both : and . per artifact-naming convention (same as contracts)
262
+ artifact_part = ':'.join(parts[1:])
263
+ import re
264
+ segments = re.split(r'[:\.]', artifact_part)
265
+ path_parts = segments
266
+ telemetry_path = TELEMETRY_DIR / Path(*path_parts)
267
+
268
+ if not (telemetry_path.exists() and telemetry_path.is_dir()):
269
+ return False
270
+
271
+ # Must contain signal files
272
+ signal_files = list(telemetry_path.glob("*.json"))
273
+ if not signal_files:
274
+ self.errors.append(
275
+ f"Telemetry directory {telemetry_path} exists but contains no signal files"
276
+ )
277
+ return False
278
+
279
+ # Validate each signal file's $id URN, artifact_ref, and acceptance_criteria
280
+ for signal_file in signal_files:
281
+ try:
282
+ with open(signal_file) as f:
283
+ signal = json.load(f)
284
+
285
+ signal_id = signal.get("$id", "")
286
+
287
+ # Signal $id must match path (NO "telemetry:" prefix)
288
+ # telemetry URN: telemetry:commons:ux:foundations
289
+ # signal $id should start with: commons:ux:foundations
290
+ expected_id_prefix = telemetry_urn.replace("telemetry:", "", 1)
291
+
292
+ if not signal_id.startswith(expected_id_prefix):
293
+ self.errors.append(
294
+ f"Signal $id mismatch:\n"
295
+ f" File: {signal_file.relative_to(REPO_ROOT)}\n"
296
+ f" Found $id: '{signal_id}'\n"
297
+ f" Expected prefix: '{expected_id_prefix}' (NO 'telemetry:' prefix)"
298
+ )
299
+ continue
300
+
301
+ # Validate artifact_ref is a valid contract URN (if present)
302
+ # Note: artifact_ref may not match telemetry path exactly
303
+ # Example: telemetry:commons:ux:foundations may reference contract:ux:foundations
304
+ artifact_ref = signal.get("artifact_ref", "")
305
+ if artifact_ref and not artifact_ref.startswith("contract:"):
306
+ self.errors.append(
307
+ f"Signal artifact_ref must start with 'contract:':\n"
308
+ f" File: {signal_file.relative_to(REPO_ROOT)}\n"
309
+ f" Found artifact_ref: '{artifact_ref}'"
310
+ )
311
+
312
+ # Validate acceptance_criteria are acc: URNs
313
+ acceptance_criteria = signal.get("acceptance_criteria", [])
314
+ for acc_urn in acceptance_criteria:
315
+ if not acc_urn.startswith("acc:"):
316
+ self.errors.append(
317
+ f"Signal has invalid acceptance URN:\n"
318
+ f" File: {signal_file.relative_to(REPO_ROOT)}\n"
319
+ f" Invalid URN: '{acc_urn}'\n"
320
+ f" Must start with 'acc:'"
321
+ )
322
+
323
+ self.stats["telemetry_signals"] += 1
324
+
325
+ except Exception as e:
326
+ self.errors.append(
327
+ f"Error reading telemetry signal {signal_file.relative_to(REPO_ROOT)}: {str(e)}"
328
+ )
329
+ return False
330
+
331
+ return True
332
+
333
+ def _validate_features(self, wagon_manifest: Dict[str, Any]):
334
+ """Validate feature URNs resolve to files and have code implementations."""
335
+ feature_refs = wagon_manifest.get("features", [])
336
+
337
+ for feature_ref in feature_refs:
338
+ feature_urn = feature_ref.get("urn")
339
+ if not feature_urn:
340
+ continue
341
+
342
+ # feature:maintain-ux:provide-foundations โ†’ plan/maintain_ux/features/provide-foundations.yaml
343
+ if not feature_urn.startswith("feature:"):
344
+ self.errors.append(f"Invalid feature URN format: {feature_urn}")
345
+ continue
346
+
347
+ parts = feature_urn.split(":")
348
+
349
+ # Support both formats per convention evolution:
350
+ # NEW: feature:wagon:feature-slug (3 parts with colons)
351
+ # OLD: feature:wagon.feature-slug (2 parts with dot separator)
352
+ if len(parts) == 3:
353
+ # NEW format: feature:wagon:feature-slug
354
+ _, wagon_slug, feature_slug = parts
355
+ elif len(parts) == 2:
356
+ # OLD format: feature:wagon.feature-slug
357
+ feature_full = parts[1]
358
+ if "." in feature_full:
359
+ wagon_slug, feature_slug = feature_full.split(".", 1)
360
+ else:
361
+ self.errors.append(f"Feature URN missing wagon separator: {feature_urn}")
362
+ continue
363
+ else:
364
+ self.errors.append(f"Invalid feature URN format: {feature_urn}")
365
+ continue
366
+ wagon_dirname = wagon_slug.replace("-", "_")
367
+ feature_filename = feature_slug.replace("-", "_")
368
+
369
+ feature_path = PLAN_DIR / wagon_dirname / "features" / f"{feature_filename}.yaml"
370
+
371
+ if not feature_path.exists():
372
+ self.errors.append(
373
+ f"Feature URN {feature_urn} does not resolve to filesystem:\n"
374
+ f" Expected: {feature_path}\n"
375
+ f" Path does not exist"
376
+ )
377
+ else:
378
+ self.stats["feature_urns"] += 1
379
+
380
+ # Validate feature YAML file has urn field
381
+ try:
382
+ with open(feature_path) as f:
383
+ feature_data = yaml.safe_load(f)
384
+
385
+ yaml_urn = feature_data.get("urn", "")
386
+ if yaml_urn != feature_urn:
387
+ self.errors.append(
388
+ f"Feature YAML urn field mismatch:\n"
389
+ f" File: {feature_path.relative_to(REPO_ROOT)}\n"
390
+ f" Found urn: '{yaml_urn}'\n"
391
+ f" Expected: '{feature_urn}'"
392
+ )
393
+ else:
394
+ self.stats["feature_yaml_urns"] += 1
395
+
396
+ except Exception as e:
397
+ self.errors.append(
398
+ f"Error reading feature YAML {feature_path.relative_to(REPO_ROOT)}: {str(e)}"
399
+ )
400
+
401
+ def _validate_wmbts(self, wagon_manifest: Dict[str, Any]):
402
+ """Validate WMBT URNs resolve to files and acceptances."""
403
+ wmbt_dict = wagon_manifest.get("wmbt", {})
404
+
405
+ for wmbt_id, wmbt_desc in wmbt_dict.items():
406
+ # Skip metadata fields
407
+ if wmbt_id in ("total", "coverage"):
408
+ continue
409
+
410
+ # wmbt:maintain-ux:L001 โ†’ plan/maintain_ux/L001.yaml
411
+ wagon_slug = wagon_manifest.get("wagon", "")
412
+ wagon_dirname = wagon_slug.replace("-", "_")
413
+ wmbt_path = PLAN_DIR / wagon_dirname / f"{wmbt_id}.yaml"
414
+
415
+ if not wmbt_path.exists():
416
+ self.errors.append(
417
+ f"WMBT ID {wmbt_id} does not resolve to file:\n"
418
+ f" Expected: {wmbt_path}\n"
419
+ f" Path does not exist"
420
+ )
421
+ continue
422
+
423
+ self.stats["wmbt_urns"] += 1
424
+
425
+ # Validate WMBT file structure
426
+ try:
427
+ with open(wmbt_path) as f:
428
+ wmbt_data = yaml.safe_load(f)
429
+
430
+ wmbt_urn = wmbt_data.get("urn", "")
431
+ expected_urn = f"wmbt:{wagon_slug}:{wmbt_id}"
432
+
433
+ if wmbt_urn != expected_urn:
434
+ self.errors.append(
435
+ f"WMBT YAML urn field mismatch:\n"
436
+ f" File: {wmbt_path.relative_to(REPO_ROOT)}\n"
437
+ f" Found urn: '{wmbt_urn}'\n"
438
+ f" Expected: '{expected_urn}'"
439
+ )
440
+ else:
441
+ self.stats["wmbt_yaml_urns"] += 1
442
+
443
+ # Validate acceptances
444
+ self._validate_acceptances(wmbt_data, wagon_slug, wmbt_id)
445
+
446
+ except Exception as e:
447
+ self.errors.append(
448
+ f"Error reading WMBT YAML {wmbt_path.relative_to(REPO_ROOT)}: {str(e)}"
449
+ )
450
+
451
+ def _validate_acceptances(self, wmbt_data: Dict[str, Any], wagon_slug: str, wmbt_id: str):
452
+ """Validate acceptance URNs and test files.
453
+
454
+ Supports both formats per SPEC-COACH-UTILS-0282:
455
+ - NEW: acc:{wagon}:{wmbt_id}-{harness}-{NNN}[-{slug}]
456
+ Example: acc:pace-dilemmas:L001-UNIT-001
457
+ - OLD: acc:{wagon}:{wmbt_id}:{id} or acc:{wagon}.{wmbt_id}.{id}
458
+ Example: acc:maintain-ux:L001:AC-HTTP-001 or acc:maintain-ux.L001.AC-HTTP-001
459
+ """
460
+ acceptances = wmbt_data.get("acceptances", [])
461
+
462
+ for acceptance in acceptances:
463
+ acc_urn = acceptance.get("identity", {}).get("urn", "")
464
+ acc_id = acceptance.get("identity", {}).get("id", "")
465
+
466
+ # Validate URN starts with acc:{wagon}:
467
+ expected_urn_start = f"acc:{wagon_slug}:"
468
+
469
+ # Also check for old dot-separated format
470
+ expected_urn_start_dots = f"acc:{wagon_slug}."
471
+
472
+ if not (acc_urn.startswith(expected_urn_start) or acc_urn.startswith(expected_urn_start_dots)):
473
+ self.errors.append(
474
+ f"Acceptance URN '{acc_urn}' does not match expected wagon prefix '{expected_urn_start}'"
475
+ )
476
+ continue
477
+
478
+ # Extract the part after wagon slug
479
+ if acc_urn.startswith(expected_urn_start):
480
+ remainder = acc_urn[len(expected_urn_start):]
481
+ separator = ":"
482
+ else:
483
+ remainder = acc_urn[len(expected_urn_start_dots):]
484
+ separator = "."
485
+
486
+ # Validate that remainder contains wmbt_id
487
+ # NEW format: L001-UNIT-001 (dash-separated)
488
+ # OLD format: L001:AC-HTTP-001 or L001.AC-HTTP-001 (colon/dot-separated)
489
+ if "-" in remainder:
490
+ # NEW format: wmbt_id-harness-NNN
491
+ wmbt_part = remainder.split("-")[0]
492
+ elif separator in remainder:
493
+ # OLD format: wmbt_id:id or wmbt_id.id
494
+ wmbt_part = remainder.split(separator)[0]
495
+ else:
496
+ # Just wmbt_id, no separator
497
+ wmbt_part = remainder
498
+
499
+ if wmbt_part != wmbt_id:
500
+ self.errors.append(
501
+ f"Acceptance URN '{acc_urn}' does not contain expected WMBT ID '{wmbt_id}' (found: '{wmbt_part}')"
502
+ )
503
+ continue
504
+
505
+ self.stats["acceptance_urns"] += 1
506
+
507
+ def get_report(self) -> str:
508
+ """Generate validation report."""
509
+ if len(self.errors) == 0:
510
+ return (
511
+ f"โœ… Wagon {self.wagon_urn} - FULL CHAIN VALIDATED\n"
512
+ f" ๐Ÿ“‹ Specification Layer:\n"
513
+ f" โ€ข Produce: {self.stats['produce_count']} artifacts\n"
514
+ f" โ€ข Features: {self.stats['feature_urns']} specs ({self.stats['feature_yaml_urns']} YAML URNs)\n"
515
+ f" โ€ข WMBTs: {self.stats['wmbt_urns']} specs ({self.stats['wmbt_yaml_urns']} YAML URNs)\n"
516
+ f" โ€ข Acceptances: {self.stats['acceptance_urns']} criteria\n"
517
+ f" ๐Ÿ”Œ Interface Layer:\n"
518
+ f" โ€ข Contracts: {self.stats['contract_urns']} URNs ({self.stats['contract_schemas']} schemas with $id)\n"
519
+ f" โ€ข Telemetry: {self.stats['telemetry_urns']} URNs ({self.stats['telemetry_signals']} signals with $id)\n"
520
+ f" ๐Ÿ’ป Implementation Layer:\n"
521
+ f" โ€ข Code files: {self.stats['code_files']} with component: URNs\n"
522
+ f" ๐Ÿงช Test Layer:\n"
523
+ f" โ€ข Test files: {self.stats['test_files']} with acc: URNs"
524
+ )
525
+ else:
526
+ return (
527
+ f"โŒ Wagon {self.wagon_urn} - CHAIN VALIDATION FAILED\n"
528
+ f" Errors ({len(self.errors)}):\n" +
529
+ "\n".join(f" โ€ข {err}" for err in self.errors[:5]) +
530
+ (f"\n ... and {len(self.errors) - 5} more errors" if len(self.errors) > 5 else "")
531
+ )
532
+
533
+
534
+ def get_active_wagon_slugs() -> List[str]:
535
+ """
536
+ Extract wagon slugs from all wagon manifests.
537
+
538
+ Returns:
539
+ List of wagon slugs (e.g., ["maintain-ux", "resolve-dilemmas"])
540
+ """
541
+ slugs = []
542
+ wagons_file = PLAN_DIR / "_wagons.yaml"
543
+
544
+ if wagons_file.exists():
545
+ with open(wagons_file) as f:
546
+ wagons_data = yaml.safe_load(f)
547
+ for wagon_entry in wagons_data.get("wagons", []):
548
+ if "slug" in wagon_entry:
549
+ slugs.append(wagon_entry["slug"])
550
+
551
+ # Also discover from directories
552
+ for wagon_dir in PLAN_DIR.iterdir():
553
+ if wagon_dir.is_dir() and not wagon_dir.name.startswith("_"):
554
+ # Convert dirname back to slug: maintain_ux โ†’ maintain-ux
555
+ slug = wagon_dir.name.replace("_", "-")
556
+ if slug not in slugs:
557
+ slugs.append(slug)
558
+
559
+ return sorted(slugs)
560
+
561
+
562
+ @pytest.mark.platform
563
+ @pytest.mark.e2e
564
+ @pytest.mark.parametrize("wagon_slug", get_active_wagon_slugs())
565
+ def test_wagon_complete_urn_chain(wagon_slug: str):
566
+ """
567
+ SPEC-PLATFORM-CHAIN-0001: Complete wagon URN chain reconciliation
568
+
569
+ Given: A wagon URN (wagon:slug)
570
+ When: Recursively validating the entire chain via URNs only
571
+ Then:
572
+ 1. Wagon URN โ†’ manifest file exists
573
+ 2. Produce artifacts โ†’ contract URNs โ†’ contracts/{domain}/{resource}/
574
+ 3. Contract schemas have $id field matching domain:resource:*
575
+ 4. Produce artifacts โ†’ telemetry URNs โ†’ telemetry/{domain}/{resource}/
576
+ 5. Telemetry signals have $id, artifact_ref, acceptance_criteria URNs
577
+ 6. Feature URNs โ†’ feature YAML files exist with urn: field
578
+ 7. Feature code files exist with component: URN markers (first line)
579
+ 8. WMBT IDs โ†’ WMBT YAML files exist with urn: field
580
+ 9. Acceptance URNs follow expected pattern
581
+ 10. Acceptance test files exist with acc: URN markers (first line)
582
+
583
+ This test validates 100% URN traceability across ALL layers:
584
+ - Specification Layer: YAML files with urn: fields
585
+ - Interface Layer: JSON schemas with $id fields and artifact_ref
586
+ - Implementation Layer: Code files with component: URN comments
587
+ - Test Layer: Test files with acc: URN comments
588
+ """
589
+ validator = WagonChainValidator(wagon_slug)
590
+ is_valid = validator.validate()
591
+
592
+ report = validator.get_report()
593
+ print(f"\n{report}")
594
+
595
+ if not is_valid:
596
+ pytest.fail(f"\n\nWagon {wagon_slug} URN chain validation failed:\n{report}")
597
+
598
+
599
+ @pytest.mark.platform
600
+ def test_all_wagons_have_complete_chains():
601
+ """
602
+ SPEC-PLATFORM-CHAIN-0002: All wagons have complete URN chains
603
+
604
+ Given: All wagon slugs in repository
605
+ When: Validating each wagon's URN chain
606
+ Then: All wagons pass complete chain validation
607
+ Summary report shows total statistics
608
+ """
609
+ active_wagon_slugs = get_active_wagon_slugs()
610
+ results = {}
611
+ total_errors = 0
612
+
613
+ for wagon_slug in active_wagon_slugs:
614
+ validator = WagonChainValidator(wagon_slug)
615
+ is_valid = validator.validate()
616
+ results[wagon_slug] = {
617
+ "valid": is_valid,
618
+ "errors": len(validator.errors),
619
+ "stats": validator.stats
620
+ }
621
+ total_errors += len(validator.errors)
622
+
623
+ # Generate summary report
624
+ valid_count = sum(1 for r in results.values() if r["valid"])
625
+ total_count = len(results)
626
+
627
+ summary = [
628
+ f"\n{'=' * 80}",
629
+ f"COMPLETE URN CHAIN VALIDATION SUMMARY",
630
+ f"{'=' * 80}",
631
+ f"Total Wagons: {total_count}",
632
+ f"Valid Chains: {valid_count}/{total_count}",
633
+ f"Total Errors: {total_errors}",
634
+ f"{'=' * 80}"
635
+ ]
636
+
637
+ for wagon_slug, result in sorted(results.items()):
638
+ status = "โœ…" if result["valid"] else "โŒ"
639
+ summary.append(
640
+ f"{status} {wagon_slug}: "
641
+ f"{result['stats']['produce_count']} artifacts, "
642
+ f"{result['stats']['contract_urns']} contracts, "
643
+ f"{result['stats']['telemetry_urns']} telemetry"
644
+ )
645
+
646
+ print("\n".join(summary))
647
+
648
+ assert total_errors == 0, f"\n\n{total_errors} URN chain errors found across {total_count} wagons"