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.
- atdd/__init__.py +6 -0
- atdd/__main__.py +4 -0
- atdd/cli.py +404 -0
- atdd/coach/__init__.py +0 -0
- atdd/coach/commands/__init__.py +0 -0
- atdd/coach/commands/add_persistence_metadata.py +215 -0
- atdd/coach/commands/analyze_migrations.py +188 -0
- atdd/coach/commands/consumers.py +720 -0
- atdd/coach/commands/infer_governance_status.py +149 -0
- atdd/coach/commands/initializer.py +177 -0
- atdd/coach/commands/interface.py +1078 -0
- atdd/coach/commands/inventory.py +565 -0
- atdd/coach/commands/migration.py +240 -0
- atdd/coach/commands/registry.py +1560 -0
- atdd/coach/commands/session.py +430 -0
- atdd/coach/commands/sync.py +405 -0
- atdd/coach/commands/test_interface.py +399 -0
- atdd/coach/commands/test_runner.py +141 -0
- atdd/coach/commands/tests/__init__.py +1 -0
- atdd/coach/commands/tests/test_telemetry_array_validation.py +235 -0
- atdd/coach/commands/traceability.py +4264 -0
- atdd/coach/conventions/session.convention.yaml +754 -0
- atdd/coach/overlays/__init__.py +2 -0
- atdd/coach/overlays/claude.md +2 -0
- atdd/coach/schemas/config.schema.json +34 -0
- atdd/coach/schemas/manifest.schema.json +101 -0
- atdd/coach/templates/ATDD.md +282 -0
- atdd/coach/templates/SESSION-TEMPLATE.md +327 -0
- atdd/coach/utils/__init__.py +0 -0
- atdd/coach/utils/graph/__init__.py +0 -0
- atdd/coach/utils/graph/urn.py +875 -0
- atdd/coach/validators/__init__.py +0 -0
- atdd/coach/validators/shared_fixtures.py +365 -0
- atdd/coach/validators/test_enrich_wagon_registry.py +167 -0
- atdd/coach/validators/test_registry.py +575 -0
- atdd/coach/validators/test_session_validation.py +1183 -0
- atdd/coach/validators/test_traceability.py +448 -0
- atdd/coach/validators/test_update_feature_paths.py +108 -0
- atdd/coach/validators/test_validate_contract_consumers.py +297 -0
- atdd/coder/__init__.py +1 -0
- atdd/coder/conventions/adapter.recipe.yaml +88 -0
- atdd/coder/conventions/backend.convention.yaml +460 -0
- atdd/coder/conventions/boundaries.convention.yaml +666 -0
- atdd/coder/conventions/commons.convention.yaml +460 -0
- atdd/coder/conventions/complexity.recipe.yaml +109 -0
- atdd/coder/conventions/component-naming.convention.yaml +178 -0
- atdd/coder/conventions/design.convention.yaml +327 -0
- atdd/coder/conventions/design.recipe.yaml +273 -0
- atdd/coder/conventions/dto.convention.yaml +660 -0
- atdd/coder/conventions/frontend.convention.yaml +542 -0
- atdd/coder/conventions/green.convention.yaml +1012 -0
- atdd/coder/conventions/presentation.convention.yaml +587 -0
- atdd/coder/conventions/refactor.convention.yaml +535 -0
- atdd/coder/conventions/technology.convention.yaml +206 -0
- atdd/coder/conventions/tests/__init__.py +0 -0
- atdd/coder/conventions/tests/test_adapter_recipe.py +302 -0
- atdd/coder/conventions/tests/test_complexity_recipe.py +289 -0
- atdd/coder/conventions/tests/test_component_taxonomy.py +278 -0
- atdd/coder/conventions/tests/test_component_urn_naming.py +165 -0
- atdd/coder/conventions/tests/test_thinness_recipe.py +286 -0
- atdd/coder/conventions/thinness.recipe.yaml +82 -0
- atdd/coder/conventions/train.convention.yaml +325 -0
- atdd/coder/conventions/verification.protocol.yaml +53 -0
- atdd/coder/schemas/design_system.schema.json +361 -0
- atdd/coder/validators/__init__.py +0 -0
- atdd/coder/validators/test_commons_structure.py +485 -0
- atdd/coder/validators/test_complexity.py +416 -0
- atdd/coder/validators/test_cross_language_consistency.py +431 -0
- atdd/coder/validators/test_design_system_compliance.py +413 -0
- atdd/coder/validators/test_dto_testing_patterns.py +268 -0
- atdd/coder/validators/test_green_cross_stack_layers.py +168 -0
- atdd/coder/validators/test_green_layer_dependencies.py +148 -0
- atdd/coder/validators/test_green_python_layer_structure.py +103 -0
- atdd/coder/validators/test_green_supabase_layer_structure.py +103 -0
- atdd/coder/validators/test_import_boundaries.py +396 -0
- atdd/coder/validators/test_init_file_urns.py +593 -0
- atdd/coder/validators/test_preact_layer_boundaries.py +221 -0
- atdd/coder/validators/test_presentation_convention.py +260 -0
- atdd/coder/validators/test_python_architecture.py +674 -0
- atdd/coder/validators/test_quality_metrics.py +420 -0
- atdd/coder/validators/test_station_master_pattern.py +244 -0
- atdd/coder/validators/test_train_infrastructure.py +454 -0
- atdd/coder/validators/test_train_urns.py +293 -0
- atdd/coder/validators/test_typescript_architecture.py +616 -0
- atdd/coder/validators/test_usecase_structure.py +421 -0
- atdd/coder/validators/test_wagon_boundaries.py +586 -0
- atdd/conftest.py +126 -0
- atdd/planner/__init__.py +1 -0
- atdd/planner/conventions/acceptance.convention.yaml +538 -0
- atdd/planner/conventions/appendix.convention.yaml +187 -0
- atdd/planner/conventions/artifact-naming.convention.yaml +852 -0
- atdd/planner/conventions/component.convention.yaml +670 -0
- atdd/planner/conventions/criteria.convention.yaml +141 -0
- atdd/planner/conventions/feature.convention.yaml +371 -0
- atdd/planner/conventions/interface.convention.yaml +382 -0
- atdd/planner/conventions/steps.convention.yaml +141 -0
- atdd/planner/conventions/train.convention.yaml +552 -0
- atdd/planner/conventions/wagon.convention.yaml +275 -0
- atdd/planner/conventions/wmbt.convention.yaml +258 -0
- atdd/planner/schemas/acceptance.schema.json +336 -0
- atdd/planner/schemas/appendix.schema.json +78 -0
- atdd/planner/schemas/component.schema.json +114 -0
- atdd/planner/schemas/feature.schema.json +197 -0
- atdd/planner/schemas/train.schema.json +192 -0
- atdd/planner/schemas/wagon.schema.json +281 -0
- atdd/planner/schemas/wmbt.schema.json +59 -0
- atdd/planner/validators/__init__.py +0 -0
- atdd/planner/validators/conftest.py +5 -0
- atdd/planner/validators/test_draft_wagon_registry.py +374 -0
- atdd/planner/validators/test_plan_cross_refs.py +240 -0
- atdd/planner/validators/test_plan_uniqueness.py +224 -0
- atdd/planner/validators/test_plan_urn_resolution.py +268 -0
- atdd/planner/validators/test_plan_wagons.py +174 -0
- atdd/planner/validators/test_train_validation.py +514 -0
- atdd/planner/validators/test_wagon_urn_chain.py +648 -0
- atdd/planner/validators/test_wmbt_consistency.py +327 -0
- atdd/planner/validators/test_wmbt_vocabulary.py +632 -0
- atdd/tester/__init__.py +1 -0
- atdd/tester/conventions/artifact.convention.yaml +257 -0
- atdd/tester/conventions/contract.convention.yaml +1009 -0
- atdd/tester/conventions/filename.convention.yaml +555 -0
- atdd/tester/conventions/migration.convention.yaml +509 -0
- atdd/tester/conventions/red.convention.yaml +797 -0
- atdd/tester/conventions/routing.convention.yaml +51 -0
- atdd/tester/conventions/telemetry.convention.yaml +458 -0
- atdd/tester/schemas/a11y.tmpl.json +17 -0
- atdd/tester/schemas/artifact.schema.json +189 -0
- atdd/tester/schemas/contract.schema.json +591 -0
- atdd/tester/schemas/contract.tmpl.json +95 -0
- atdd/tester/schemas/db.tmpl.json +20 -0
- atdd/tester/schemas/e2e.tmpl.json +17 -0
- atdd/tester/schemas/edge_function.tmpl.json +17 -0
- atdd/tester/schemas/event.tmpl.json +17 -0
- atdd/tester/schemas/http.tmpl.json +19 -0
- atdd/tester/schemas/job.tmpl.json +18 -0
- atdd/tester/schemas/load.tmpl.json +21 -0
- atdd/tester/schemas/metric.tmpl.json +19 -0
- atdd/tester/schemas/pack.schema.json +139 -0
- atdd/tester/schemas/realtime.tmpl.json +20 -0
- atdd/tester/schemas/rls.tmpl.json +18 -0
- atdd/tester/schemas/script.tmpl.json +16 -0
- atdd/tester/schemas/sec.tmpl.json +18 -0
- atdd/tester/schemas/storage.tmpl.json +18 -0
- atdd/tester/schemas/telemetry.schema.json +128 -0
- atdd/tester/schemas/telemetry_tracking_manifest.schema.json +143 -0
- atdd/tester/schemas/test_filename.schema.json +194 -0
- atdd/tester/schemas/test_intent.schema.json +179 -0
- atdd/tester/schemas/unit.tmpl.json +18 -0
- atdd/tester/schemas/visual.tmpl.json +18 -0
- atdd/tester/schemas/ws.tmpl.json +17 -0
- atdd/tester/utils/__init__.py +0 -0
- atdd/tester/utils/filename.py +300 -0
- atdd/tester/validators/__init__.py +0 -0
- atdd/tester/validators/cleanup_duplicate_headers.py +116 -0
- atdd/tester/validators/cleanup_duplicate_headers_v2.py +135 -0
- atdd/tester/validators/conftest.py +5 -0
- atdd/tester/validators/coverage_gap_report.py +321 -0
- atdd/tester/validators/fix_dual_ac_references.py +179 -0
- atdd/tester/validators/remove_duplicate_lines.py +93 -0
- atdd/tester/validators/test_acceptance_urn_filename_mapping.py +359 -0
- atdd/tester/validators/test_acceptance_urn_separator.py +166 -0
- atdd/tester/validators/test_artifact_naming_category.py +307 -0
- atdd/tester/validators/test_contract_schema_compliance.py +706 -0
- atdd/tester/validators/test_contracts_structure.py +200 -0
- atdd/tester/validators/test_coverage_adequacy.py +797 -0
- atdd/tester/validators/test_dual_ac_reference.py +225 -0
- atdd/tester/validators/test_fixture_validity.py +372 -0
- atdd/tester/validators/test_isolation.py +487 -0
- atdd/tester/validators/test_migration_coverage.py +204 -0
- atdd/tester/validators/test_migration_criteria.py +276 -0
- atdd/tester/validators/test_migration_generation.py +116 -0
- atdd/tester/validators/test_python_test_naming.py +410 -0
- atdd/tester/validators/test_red_layer_validation.py +95 -0
- atdd/tester/validators/test_red_python_layer_structure.py +87 -0
- atdd/tester/validators/test_red_supabase_layer_structure.py +90 -0
- atdd/tester/validators/test_telemetry_structure.py +634 -0
- atdd/tester/validators/test_typescript_test_naming.py +301 -0
- atdd/tester/validators/test_typescript_test_structure.py +84 -0
- atdd-0.2.1.dist-info/METADATA +221 -0
- atdd-0.2.1.dist-info/RECORD +184 -0
- atdd-0.2.1.dist-info/WHEEL +5 -0
- atdd-0.2.1.dist-info/entry_points.txt +2 -0
- atdd-0.2.1.dist-info/licenses/LICENSE +674 -0
- 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)
|