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,720 @@
1
+ """
2
+ Consumer Validation System - Validate and sync consumer declarations.
3
+
4
+ Architecture: 4-Layer Clean Architecture (single file)
5
+ - Domain: Pure business logic (mismatch detection, validation)
6
+ - Integration: File I/O adapters (YAML, JSON scanning)
7
+ - Application: Use cases (detect mismatches, apply updates)
8
+ - Presentation: CLI facade (ConsumerValidator)
9
+
10
+ Validates consumer declarations between:
11
+ - Wagon manifests (plan/*/_*.yaml)
12
+ - Feature manifests (plan/*/*/*.yaml)
13
+ - Contract schemas (contracts/**/*.schema.json)
14
+
15
+ This command helps maintain coherence between consumer declarations
16
+ in manifests and contract metadata.
17
+ """
18
+ import yaml
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Dict, List, Any, Optional, Tuple
22
+ from dataclasses import dataclass
23
+
24
+
25
+ # ============================================================================
26
+ # DOMAIN LAYER - Pure Business Logic
27
+ # ============================================================================
28
+ # No I/O, pure functions and entities.
29
+ # Handles mismatch detection and validation logic.
30
+ # ============================================================================
31
+
32
+ @dataclass
33
+ class ConsumerMismatch:
34
+ """Represents a consumer declaration mismatch."""
35
+ type: str # "manifest_to_contract" or "contract_to_manifest"
36
+ manifest_file: Optional[str] = None
37
+ contract_file: Optional[str] = None
38
+ contract_ref: Optional[str] = None # e.g., "contract:match:dilemma.current"
39
+ consumer_ref: Optional[str] = None # e.g., "wagon:test-wagon"
40
+
41
+
42
+ class ConsumerMismatchDetector:
43
+ """Domain logic for detecting consumer mismatches."""
44
+
45
+ @staticmethod
46
+ def detect_manifest_to_contract_mismatches(
47
+ manifest_consumers: Dict[str, List[str]],
48
+ contract_consumers: Dict[str, List[str]],
49
+ contract_id_map: Dict[str, str]
50
+ ) -> List[ConsumerMismatch]:
51
+ """
52
+ Detect manifests declaring contracts that don't list them as consumers.
53
+
54
+ Args:
55
+ manifest_consumers: {manifest_path: [contract_refs]}
56
+ contract_consumers: {contract_path: [consumer_refs]}
57
+ contract_id_map: {contract_ref: contract_path} mapping
58
+
59
+ Returns:
60
+ List of mismatches where manifest declares contract but contract doesn't list it
61
+ """
62
+ mismatches = []
63
+
64
+ for manifest_path, declared_contracts in manifest_consumers.items():
65
+ for contract_ref in declared_contracts:
66
+ # Extract wagon name from manifest path
67
+ # e.g., plan/test_wagon/features/choose_option.yaml -> wagon:test-wagon
68
+ parts = Path(manifest_path).parts
69
+ if len(parts) >= 2 and parts[0] == "plan":
70
+ wagon_name = parts[1].replace("_", "-")
71
+ consumer_ref = f"wagon:{wagon_name}"
72
+
73
+ # Find the contract file using $id mapping
74
+ contract_file = ConsumerMismatchDetector._find_contract_file(
75
+ contract_ref, contract_id_map
76
+ )
77
+
78
+ if contract_file:
79
+ consumers = contract_consumers.get(contract_file, [])
80
+ if consumer_ref not in consumers:
81
+ mismatches.append(ConsumerMismatch(
82
+ type="manifest_to_contract",
83
+ manifest_file=manifest_path,
84
+ contract_file=contract_file,
85
+ contract_ref=contract_ref,
86
+ consumer_ref=consumer_ref
87
+ ))
88
+
89
+ return mismatches
90
+
91
+ @staticmethod
92
+ def detect_contract_to_manifest_mismatches(
93
+ manifest_consumers: Dict[str, List[str]],
94
+ contract_consumers: Dict[str, List[str]]
95
+ ) -> List[ConsumerMismatch]:
96
+ """
97
+ Detect contracts listing consumers not declared in any manifest.
98
+
99
+ Args:
100
+ manifest_consumers: {manifest_path: [contract_refs]}
101
+ contract_consumers: {contract_path: [consumer_refs]}
102
+
103
+ Returns:
104
+ List of mismatches where contract lists consumer not in any manifest
105
+ """
106
+ mismatches = []
107
+
108
+ # Build set of all declared consumers from manifests
109
+ declared_consumers = set()
110
+ for manifest_path in manifest_consumers.keys():
111
+ parts = Path(manifest_path).parts
112
+ if len(parts) >= 2 and parts[0] == "plan":
113
+ wagon_name = parts[1].replace("_", "-")
114
+ declared_consumers.add(f"wagon:{wagon_name}")
115
+
116
+ # Check each contract's consumers
117
+ for contract_path, consumers in contract_consumers.items():
118
+ for consumer_ref in consumers:
119
+ if consumer_ref.startswith("wagon:") and consumer_ref not in declared_consumers:
120
+ mismatches.append(ConsumerMismatch(
121
+ type="contract_to_manifest",
122
+ contract_file=contract_path,
123
+ consumer_ref=consumer_ref
124
+ ))
125
+
126
+ return mismatches
127
+
128
+ @staticmethod
129
+ def _find_contract_file(contract_ref: str, contract_id_map: Dict[str, str]) -> Optional[str]:
130
+ """
131
+ Find contract file path from contract reference using $id mapping.
132
+
133
+ Args:
134
+ contract_ref: Contract reference like "contract:match:dilemma.current"
135
+ contract_id_map: Mapping of contract refs to file paths
136
+
137
+ Returns:
138
+ File path if found, None otherwise
139
+ """
140
+ return contract_id_map.get(contract_ref)
141
+
142
+
143
+ # ============================================================================
144
+ # INTEGRATION LAYER - File I/O Adapters
145
+ # ============================================================================
146
+ # Handles reading/writing YAML and JSON files.
147
+ # Scanning filesystem for manifests and contracts.
148
+ # ============================================================================
149
+
150
+ class ManifestScanner:
151
+ """Scans and parses wagon and feature manifests."""
152
+
153
+ @staticmethod
154
+ def scan_manifests(plan_dir: Path) -> Dict[str, List[str]]:
155
+ """
156
+ Scan all wagon and feature manifests for consumer declarations.
157
+
158
+ Returns:
159
+ Dict mapping manifest paths to list of contract references
160
+ """
161
+ manifest_consumers = {}
162
+
163
+ # Scan wagon manifests (plan/*/_*.yaml)
164
+ for wagon_manifest in plan_dir.glob("*/_*.yaml"):
165
+ consumers = ManifestScanner._extract_consumers(wagon_manifest)
166
+ if consumers:
167
+ rel_path = str(wagon_manifest.relative_to(plan_dir.parent))
168
+ manifest_consumers[rel_path] = consumers
169
+
170
+ # Scan feature manifests (plan/*/*/*.yaml)
171
+ for feature_manifest in plan_dir.glob("*/*/*.yaml"):
172
+ # Skip wagon manifests (those starting with _)
173
+ if not feature_manifest.name.startswith("_"):
174
+ consumers = ManifestScanner._extract_consumers(feature_manifest)
175
+ if consumers:
176
+ rel_path = str(feature_manifest.relative_to(plan_dir.parent))
177
+ manifest_consumers[rel_path] = consumers
178
+
179
+ return manifest_consumers
180
+
181
+ @staticmethod
182
+ def _extract_consumers(manifest_path: Path) -> List[str]:
183
+ """
184
+ Extract consumer contract references from manifest.
185
+
186
+ Recognizes two patterns:
187
+ 1. Pattern A (standalone): - name: contract:domain:resource
188
+ 2. Pattern B (annotation): - name: artifact
189
+ contract: contract:domain:resource
190
+ """
191
+ try:
192
+ with open(manifest_path) as f:
193
+ data = yaml.safe_load(f)
194
+
195
+ if not data:
196
+ return []
197
+
198
+ consumers = []
199
+ consume_list = data.get("consume", [])
200
+
201
+ for item in consume_list:
202
+ if isinstance(item, dict):
203
+ # Pattern A: name field starts with "contract:"
204
+ if "name" in item:
205
+ consumer_name = item["name"]
206
+ if consumer_name.startswith("contract:"):
207
+ consumers.append(consumer_name)
208
+
209
+ # Pattern B: contract field annotation
210
+ if "contract" in item:
211
+ contract_ref = item["contract"]
212
+ if contract_ref and contract_ref.startswith("contract:"):
213
+ consumers.append(contract_ref)
214
+
215
+ return consumers
216
+ except Exception:
217
+ return []
218
+
219
+
220
+ class ContractScanner:
221
+ """Scans and parses contract schemas."""
222
+
223
+ @staticmethod
224
+ def scan_contracts(contracts_dir: Path) -> Dict[str, List[str]]:
225
+ """
226
+ Scan all contract schemas for consumer declarations.
227
+
228
+ Returns:
229
+ Dict mapping contract paths to list of consumer references
230
+ """
231
+ contract_consumers = {}
232
+
233
+ for contract_file in contracts_dir.glob("**/*.schema.json"):
234
+ consumers = ContractScanner._extract_consumers(contract_file)
235
+ rel_path = str(contract_file.relative_to(contracts_dir.parent))
236
+ contract_consumers[rel_path] = consumers
237
+
238
+ return contract_consumers
239
+
240
+ @staticmethod
241
+ def scan_contract_ids(contracts_dir: Path) -> Dict[str, str]:
242
+ """
243
+ Scan all contract schemas and map $id to file path.
244
+
245
+ Returns:
246
+ Dict mapping contract $id to file path
247
+ """
248
+ contract_id_map = {}
249
+
250
+ for contract_file in contracts_dir.glob("**/*.schema.json"):
251
+ contract_id = ContractScanner._extract_contract_id(contract_file)
252
+ if contract_id:
253
+ rel_path = str(contract_file.relative_to(contracts_dir.parent))
254
+ contract_id_map[f"contract:{contract_id}"] = rel_path
255
+
256
+ return contract_id_map
257
+
258
+ @staticmethod
259
+ def _extract_consumers(contract_path: Path) -> List[str]:
260
+ """Extract consumer references from contract metadata."""
261
+ try:
262
+ with open(contract_path) as f:
263
+ data = json.load(f)
264
+
265
+ metadata = data.get("x-artifact-metadata", {})
266
+ return metadata.get("consumers", [])
267
+ except Exception:
268
+ return []
269
+
270
+ @staticmethod
271
+ def _extract_contract_id(contract_path: Path) -> Optional[str]:
272
+ """Extract $id from contract schema."""
273
+ try:
274
+ with open(contract_path) as f:
275
+ data = json.load(f)
276
+
277
+ return data.get("$id")
278
+ except Exception:
279
+ return None
280
+
281
+
282
+ class FileUpdater:
283
+ """Updates manifest and contract files."""
284
+
285
+ @staticmethod
286
+ def update_manifest(manifest_path: Path, contract_ref: str) -> bool:
287
+ """Add contract reference to manifest consume list."""
288
+ try:
289
+ with open(manifest_path) as f:
290
+ data = yaml.safe_load(f)
291
+
292
+ if not data:
293
+ data = {}
294
+
295
+ if "consume" not in data:
296
+ data["consume"] = []
297
+
298
+ # Check for duplicates
299
+ existing = {item.get("name") for item in data["consume"] if isinstance(item, dict)}
300
+ if contract_ref not in existing:
301
+ data["consume"].append({"name": contract_ref})
302
+
303
+ # Write back preserving format
304
+ with open(manifest_path, 'w') as f:
305
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
306
+
307
+ return True
308
+ except Exception as e:
309
+ print(f"Error updating manifest {manifest_path}: {e}")
310
+ return False
311
+
312
+ @staticmethod
313
+ def update_contract(contract_path: Path, consumer_ref: str) -> bool:
314
+ """Add consumer reference to contract metadata."""
315
+ try:
316
+ with open(contract_path) as f:
317
+ data = json.load(f)
318
+
319
+ metadata = data.get("x-artifact-metadata", {})
320
+ if "consumers" not in metadata:
321
+ metadata["consumers"] = []
322
+
323
+ # Check for duplicates
324
+ if consumer_ref not in metadata["consumers"]:
325
+ metadata["consumers"].append(consumer_ref)
326
+
327
+ data["x-artifact-metadata"] = metadata
328
+
329
+ # Write back preserving format
330
+ with open(contract_path, 'w') as f:
331
+ json.dump(data, f, indent=2)
332
+
333
+ return True
334
+ except Exception as e:
335
+ print(f"Error updating contract {contract_path}: {e}")
336
+ return False
337
+
338
+ @staticmethod
339
+ def remove_contract_consumer(contract_path: Path, consumer_ref: str) -> bool:
340
+ """Remove consumer reference from contract metadata."""
341
+ try:
342
+ with open(contract_path) as f:
343
+ data = json.load(f)
344
+
345
+ metadata = data.get("x-artifact-metadata", {})
346
+ if "consumers" in metadata and consumer_ref in metadata["consumers"]:
347
+ metadata["consumers"].remove(consumer_ref)
348
+
349
+ data["x-artifact-metadata"] = metadata
350
+
351
+ # Write back preserving format
352
+ with open(contract_path, 'w') as f:
353
+ json.dump(data, f, indent=2)
354
+
355
+ return True
356
+ except Exception as e:
357
+ print(f"Error removing consumer from contract {contract_path}: {e}")
358
+ return False
359
+
360
+
361
+ # ============================================================================
362
+ # APPLICATION LAYER - Use Cases & Orchestration
363
+ # ============================================================================
364
+ # Coordinates domain and integration layers.
365
+ # Contains validation and sync workflow orchestration.
366
+ # ============================================================================
367
+
368
+ class ConsumerValidationUseCase:
369
+ """Use case for validating consumer declarations."""
370
+
371
+ def __init__(self, repo_root: Path):
372
+ self.repo_root = repo_root
373
+ self.plan_dir = repo_root / "plan"
374
+ self.contracts_dir = repo_root / "contracts"
375
+
376
+ def detect_mismatches(self) -> Dict[str, Any]:
377
+ """
378
+ Detect all consumer mismatches between manifests and contracts.
379
+
380
+ Returns:
381
+ Report with manifest_to_contract and contract_to_manifest mismatches
382
+ """
383
+ # Scan files
384
+ manifest_consumers = ManifestScanner.scan_manifests(self.plan_dir)
385
+ contract_consumers = ContractScanner.scan_contracts(self.contracts_dir)
386
+ contract_id_map = ContractScanner.scan_contract_ids(self.contracts_dir)
387
+
388
+ # Detect mismatches
389
+ manifest_to_contract = ConsumerMismatchDetector.detect_manifest_to_contract_mismatches(
390
+ manifest_consumers, contract_consumers, contract_id_map
391
+ )
392
+ contract_to_manifest = ConsumerMismatchDetector.detect_contract_to_manifest_mismatches(
393
+ manifest_consumers, contract_consumers
394
+ )
395
+
396
+ # Convert to dict format
397
+ return {
398
+ "manifest_to_contract": [
399
+ {
400
+ "manifest": m.manifest_file,
401
+ "contract": m.contract_ref,
402
+ "contract_file": m.contract_file,
403
+ "consumer": m.consumer_ref
404
+ }
405
+ for m in manifest_to_contract
406
+ ],
407
+ "contract_to_manifest": [
408
+ {
409
+ "contract_file": m.contract_file,
410
+ "consumer": m.consumer_ref
411
+ }
412
+ for m in contract_to_manifest
413
+ ]
414
+ }
415
+
416
+
417
+ class ConsumerSyncUseCase:
418
+ """Use case for syncing consumer declarations."""
419
+
420
+ def __init__(self, repo_root: Path):
421
+ self.repo_root = repo_root
422
+
423
+ def apply_updates(self, updates: List[Dict], direction: str) -> Dict[str, Any]:
424
+ """
425
+ Apply consumer synchronization updates.
426
+
427
+ Args:
428
+ updates: List of update operations
429
+ direction: "manifests", "contracts", or "mutual"
430
+
431
+ Returns:
432
+ Summary report of applied changes
433
+ """
434
+ applied = 0
435
+ errors = []
436
+
437
+ for update in updates:
438
+ update_type = update.get("type", "manifest_to_contract")
439
+
440
+ # Direction 1: manifest_to_contract updates
441
+ if direction in ["manifests", "mutual"] and update_type == "manifest_to_contract":
442
+ # Update manifest
443
+ manifest_path = self.repo_root / update["manifest_file"]
444
+ contract_ref = update["contract_ref"]
445
+ if FileUpdater.update_manifest(manifest_path, contract_ref):
446
+ applied += 1
447
+ else:
448
+ errors.append(f"Failed to update {update['manifest_file']}")
449
+
450
+ if direction in ["contracts", "mutual"] and update_type == "manifest_to_contract":
451
+ # Update contract
452
+ contract_path = self.repo_root / update["contract_file"]
453
+ consumer_ref = update["consumer_ref"]
454
+ if FileUpdater.update_contract(contract_path, consumer_ref):
455
+ applied += 1
456
+ else:
457
+ errors.append(f"Failed to update {update['contract_file']}")
458
+
459
+ # Direction 2: contract_to_manifest updates
460
+ if direction in ["manifests", "mutual"] and update_type == "contract_to_manifest":
461
+ # Add consume declaration to manifest
462
+ manifest_path = self.repo_root / update["manifest_file"]
463
+ contract_ref = update["contract_ref"]
464
+ if FileUpdater.update_manifest(manifest_path, contract_ref):
465
+ applied += 1
466
+ else:
467
+ errors.append(f"Failed to update {update['manifest_file']}")
468
+
469
+ if direction in ["contracts", "mutual"] and update_type == "contract_to_manifest":
470
+ # Remove invalid consumer from contract
471
+ contract_path = self.repo_root / update["contract_file"]
472
+ consumer_ref = update["consumer_ref"]
473
+ if FileUpdater.remove_contract_consumer(contract_path, consumer_ref):
474
+ applied += 1
475
+ else:
476
+ errors.append(f"Failed to remove consumer from {update['contract_file']}")
477
+
478
+ return {
479
+ "applied": applied,
480
+ "errors": errors
481
+ }
482
+
483
+
484
+ # ============================================================================
485
+ # PRESENTATION LAYER - CLI Facade
486
+ # ============================================================================
487
+ # Public API for consumer validation and syncing.
488
+ # Delegates to application layer use cases.
489
+ # ============================================================================
490
+
491
+ class ConsumerValidator:
492
+ """
493
+ Validates and syncs consumer declarations between manifests and contracts.
494
+
495
+ Usage:
496
+ validator = ConsumerValidator(repo_root)
497
+ report = validator.detect_mismatches()
498
+ summary = validator.apply_updates(updates, direction="mutual")
499
+ """
500
+
501
+ def __init__(self, repo_root: Path):
502
+ self.repo_root = Path(repo_root)
503
+ self.validation_use_case = ConsumerValidationUseCase(self.repo_root)
504
+ self.sync_use_case = ConsumerSyncUseCase(self.repo_root)
505
+
506
+ def detect_mismatches(self) -> Dict[str, Any]:
507
+ """
508
+ Detect consumer mismatches between manifests and contracts.
509
+
510
+ Returns:
511
+ Report dict with:
512
+ - manifest_to_contract: List of manifests declaring contracts not listing them
513
+ - contract_to_manifest: List of contracts listing undeclared consumers
514
+ """
515
+ return self.validation_use_case.detect_mismatches()
516
+
517
+ def apply_updates(self, updates: List[Dict], direction: str = "mutual") -> Dict[str, Any]:
518
+ """
519
+ Apply consumer synchronization updates.
520
+
521
+ Args:
522
+ updates: List of update operations from detect_mismatches
523
+ direction: "manifests", "contracts", or "mutual" (default)
524
+
525
+ Returns:
526
+ Summary dict with applied count and errors
527
+ """
528
+ return self.sync_use_case.apply_updates(updates, direction)
529
+
530
+
531
+ # ============================================================================
532
+ # CLI Entry Point
533
+ # ============================================================================
534
+
535
+ if __name__ == "__main__":
536
+ import sys
537
+
538
+ # Check for --fix flag
539
+ fix_mode = "--fix" in sys.argv
540
+
541
+ # Run consumer validation
542
+ repo_root = Path.cwd()
543
+ validator = ConsumerValidator(repo_root)
544
+ report = validator.detect_mismatches()
545
+
546
+ # Display results
547
+ print('=' * 80)
548
+ print('CONSUMER VALIDATION REPORT')
549
+ print('=' * 80)
550
+
551
+ manifest_to_contract = report['manifest_to_contract']
552
+ contract_to_manifest = report['contract_to_manifest']
553
+
554
+ print(f'\n📋 DIRECTION 1: Manifest→Contract Mismatches')
555
+ print(f' Manifests declaring contracts that don\'t list them as consumers')
556
+ print(f' Found: {len(manifest_to_contract)} mismatches\n')
557
+
558
+ if manifest_to_contract:
559
+ for i, mismatch in enumerate(manifest_to_contract, 1):
560
+ print(f' {i}. Manifest: {mismatch["manifest"]}')
561
+ print(f' Declares: {mismatch["contract"]}')
562
+ print(f' Contract: {mismatch["contract_file"]}')
563
+ print(f' Missing consumer: {mismatch["consumer"]}')
564
+ print()
565
+ else:
566
+ print(' ✓ No mismatches found\n')
567
+
568
+ print(f'📋 DIRECTION 2: Contract→Manifest Mismatches')
569
+ print(f' Contracts listing consumers not declared in any manifest')
570
+ print(f' Found: {len(contract_to_manifest)} mismatches\n')
571
+
572
+ if contract_to_manifest:
573
+ for i, mismatch in enumerate(contract_to_manifest, 1):
574
+ print(f' {i}. Contract: {mismatch["contract_file"]}')
575
+ print(f' Lists consumer: {mismatch["consumer"]}')
576
+ print(f' Not found in any manifest')
577
+ print()
578
+ else:
579
+ print(' ✓ No mismatches found\n')
580
+
581
+ # Exit if no mismatches found
582
+ if len(manifest_to_contract) == 0 and len(contract_to_manifest) == 0:
583
+ print('✓ All consumer declarations are in sync!')
584
+ sys.exit(0)
585
+
586
+ # Fix mode - ask for direction and approval
587
+ if fix_mode:
588
+ print('=' * 80)
589
+ print('FIX MODE - SELECT DIRECTION')
590
+ print('=' * 80)
591
+ print('1. Update manifests only - Add contract refs to wagon/feature consume lists')
592
+ print('2. Update contracts only - Add wagon refs to contract x-artifact-metadata.consumers')
593
+ print('3. Mutual sync (both) - Sync both directions [RECOMMENDED]')
594
+ print('=' * 80)
595
+
596
+ direction_choice = input('\nSelect fix direction (1/2/3) or cancel (c): ').strip()
597
+
598
+ if direction_choice == 'c':
599
+ print('❌ Cancelled by user')
600
+ sys.exit(0)
601
+
602
+ direction_map = {
603
+ '1': 'manifests',
604
+ '2': 'contracts',
605
+ '3': 'mutual'
606
+ }
607
+
608
+ direction = direction_map.get(direction_choice)
609
+ if not direction:
610
+ print('❌ Invalid choice')
611
+ sys.exit(1)
612
+
613
+ # Show preview of changes
614
+ print('\n' + '=' * 80)
615
+ print('PREVIEW OF CHANGES')
616
+ print('=' * 80)
617
+
618
+ changes_count = 0
619
+ all_updates = []
620
+
621
+ # Direction 1: Manifest→Contract mismatches
622
+ if direction in ['manifests', 'mutual'] and manifest_to_contract:
623
+ print('\n📝 MANIFESTS TO UPDATE (Direction 1):')
624
+ for mismatch in manifest_to_contract:
625
+ print(f'\n File: {mismatch["manifest"]}')
626
+ print(f' Will add: consume:')
627
+ print(f' - name: {mismatch["contract"]}')
628
+ changes_count += 1
629
+ all_updates.append(mismatch)
630
+
631
+ if direction in ['contracts', 'mutual'] and manifest_to_contract:
632
+ print('\n📝 CONTRACTS TO UPDATE (Direction 1):')
633
+ for mismatch in manifest_to_contract:
634
+ print(f'\n File: {mismatch["contract_file"]}')
635
+ print(f' Will add to x-artifact-metadata.consumers:')
636
+ print(f' - {mismatch["consumer"]}')
637
+ changes_count += 1
638
+
639
+ # Direction 2: Contract→Manifest mismatches
640
+ if direction in ['manifests', 'mutual'] and contract_to_manifest:
641
+ print('\n📝 MANIFESTS TO UPDATE (Direction 2):')
642
+ print(' Adding consume declarations to wagon manifests\n')
643
+ for mismatch in contract_to_manifest:
644
+ # Extract wagon name and construct manifest path
645
+ consumer_ref = mismatch["consumer"]
646
+ if consumer_ref.startswith("wagon:"):
647
+ wagon_name = consumer_ref.replace("wagon:", "").replace("-", "_")
648
+ manifest_path = f"plan/{wagon_name}/_{wagon_name}.yaml"
649
+
650
+ # Extract contract ref from contract file
651
+ contract_file = mismatch["contract_file"]
652
+ # e.g., contracts/commons/identifiers/username.schema.json -> contract:system:identifiers
653
+ parts = Path(contract_file).parts
654
+ if len(parts) >= 4 and parts[0] == "contracts":
655
+ domain = parts[1]
656
+ resource = parts[2]
657
+ contract_ref = f"contract:{domain}:{resource}"
658
+
659
+ print(f' File: {manifest_path}')
660
+ print(f' Will add: consume:')
661
+ print(f' - name: {contract_ref}')
662
+ print()
663
+
664
+ changes_count += 1
665
+ all_updates.append({
666
+ "type": "contract_to_manifest",
667
+ "manifest_file": manifest_path,
668
+ "contract_file": contract_file,
669
+ "contract_ref": contract_ref,
670
+ "consumer_ref": consumer_ref
671
+ })
672
+
673
+ if direction in ['contracts', 'mutual'] and contract_to_manifest:
674
+ print('\n📝 CONTRACTS TO UPDATE (Direction 2):')
675
+ print(' Removing invalid consumer references\n')
676
+ for mismatch in contract_to_manifest:
677
+ print(f' File: {mismatch["contract_file"]}')
678
+ print(f' Will remove from x-artifact-metadata.consumers:')
679
+ print(f' - {mismatch["consumer"]}')
680
+ print()
681
+ changes_count += 1
682
+
683
+ print(f'\n Total changes: {changes_count}')
684
+ print('=' * 80)
685
+
686
+ # Ask for final approval
687
+ approval = input('\nApply these changes? (yes/no): ').strip().lower()
688
+
689
+ if approval not in ['yes', 'y']:
690
+ print('❌ Changes not applied')
691
+ sys.exit(0)
692
+
693
+ # Apply updates
694
+ print('\n🔧 Applying updates...\n')
695
+ summary = validator.apply_updates(all_updates, direction=direction)
696
+
697
+ print('=' * 80)
698
+ print('SUMMARY')
699
+ print('=' * 80)
700
+ print(f'✓ Applied: {summary["applied"]} updates')
701
+
702
+ if summary.get("errors"):
703
+ print(f'\n❌ Errors: {len(summary["errors"])}')
704
+ for error in summary["errors"]:
705
+ print(f' - {error}')
706
+ else:
707
+ print('✓ No errors')
708
+
709
+ print('=' * 80)
710
+ print('\n✓ Consumer synchronization complete!')
711
+
712
+ else:
713
+ # Not in fix mode - show instructions
714
+ print('=' * 80)
715
+ print('NEXT STEPS')
716
+ print('=' * 80)
717
+ print('Run with --fix to apply updates:')
718
+ print(' python3 atdd/coach/commands/consumers.py --fix')
719
+ print('=' * 80)
720
+ sys.exit(1)