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,674 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test Python code follows clean architecture (4-layer).
|
|
3
|
+
|
|
4
|
+
Validates:
|
|
5
|
+
- Domain layer is pure (no imports from other layers)
|
|
6
|
+
- Application layer only imports from domain
|
|
7
|
+
- Presentation layer imports from application/domain
|
|
8
|
+
- Integration layer only imports from domain
|
|
9
|
+
- Component naming follows backend conventions
|
|
10
|
+
- Files are in correct layers based on their suffixes
|
|
11
|
+
|
|
12
|
+
Conventions from:
|
|
13
|
+
- atdd/coder/conventions/backend.convention.yaml
|
|
14
|
+
|
|
15
|
+
Inspired by: .claude/utils/coder/architecture.py
|
|
16
|
+
But: Self-contained, no utility dependencies
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
import re
|
|
21
|
+
import yaml
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Path constants
|
|
27
|
+
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
28
|
+
PYTHON_DIR = REPO_ROOT / "python"
|
|
29
|
+
BACKEND_CONVENTION = REPO_ROOT / "atdd" / "coder" / "conventions" / "backend.convention.yaml"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def determine_layer_from_path(file_path: Path) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Determine layer from file path.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
file_path: Path to Python file
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Layer name: 'domain', 'application', 'presentation', 'integration', 'unknown'
|
|
41
|
+
"""
|
|
42
|
+
path_str = str(file_path).lower()
|
|
43
|
+
|
|
44
|
+
# Check explicit layer directories
|
|
45
|
+
if '/domain/' in path_str or path_str.endswith('/domain.py'):
|
|
46
|
+
return 'domain'
|
|
47
|
+
elif '/application/' in path_str or path_str.endswith('/application.py'):
|
|
48
|
+
return 'application'
|
|
49
|
+
elif '/presentation/' in path_str or path_str.endswith('/presentation.py'):
|
|
50
|
+
return 'presentation'
|
|
51
|
+
elif '/integration/' in path_str or '/infrastructure/' in path_str:
|
|
52
|
+
return 'integration'
|
|
53
|
+
|
|
54
|
+
# Check alternative patterns
|
|
55
|
+
if '/entities/' in path_str or '/models/' in path_str or '/value_objects/' in path_str:
|
|
56
|
+
return 'domain'
|
|
57
|
+
elif '/use_cases/' in path_str or '/usecases/' in path_str or '/services/' in path_str:
|
|
58
|
+
return 'application'
|
|
59
|
+
elif '/controllers/' in path_str or '/handlers/' in path_str or '/views/' in path_str:
|
|
60
|
+
return 'presentation'
|
|
61
|
+
elif '/adapters/' in path_str or '/repositories/' in path_str or '/gateways/' in path_str:
|
|
62
|
+
return 'integration'
|
|
63
|
+
|
|
64
|
+
return 'unknown'
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_python_imports(file_path: Path) -> list:
|
|
68
|
+
"""
|
|
69
|
+
Extract import statements from Python file.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
file_path: Path to Python file
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
List of imported module paths
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
79
|
+
content = f.read()
|
|
80
|
+
except Exception:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
imports = []
|
|
84
|
+
|
|
85
|
+
# Match: from X import Y
|
|
86
|
+
from_imports = re.findall(r'from\s+([^\s]+)\s+import', content)
|
|
87
|
+
imports.extend(from_imports)
|
|
88
|
+
|
|
89
|
+
# Match: import X
|
|
90
|
+
direct_imports = re.findall(r'^\s*import\s+([^\s;,]+)', content, re.MULTILINE)
|
|
91
|
+
imports.extend(direct_imports)
|
|
92
|
+
|
|
93
|
+
return imports
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def infer_layer_from_import(import_path: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Infer layer from import path.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
import_path: Import statement (e.g., "src.domain.entities")
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Layer name or 'external' for third-party imports
|
|
105
|
+
"""
|
|
106
|
+
import_lower = import_path.lower()
|
|
107
|
+
|
|
108
|
+
# Check if it's a relative import - can't determine layer reliably
|
|
109
|
+
if import_path.startswith('.'):
|
|
110
|
+
# Exception: relative imports to 'ports' are application layer
|
|
111
|
+
if 'ports' in import_lower or '_port' in import_lower:
|
|
112
|
+
return 'application'
|
|
113
|
+
return 'unknown'
|
|
114
|
+
|
|
115
|
+
# Check for layer indicators in import path (order matters - more specific first)
|
|
116
|
+
|
|
117
|
+
# Ports are in application layer (interfaces), not integration
|
|
118
|
+
if 'ports' in import_lower or '_port' in import_lower:
|
|
119
|
+
return 'application'
|
|
120
|
+
|
|
121
|
+
# Domain layer
|
|
122
|
+
if '.domain.' in import_lower or '/domain/' in import_lower:
|
|
123
|
+
return 'domain'
|
|
124
|
+
if 'entities' in import_lower or 'models' in import_lower or 'value_objects' in import_lower:
|
|
125
|
+
return 'domain'
|
|
126
|
+
|
|
127
|
+
# Application layer
|
|
128
|
+
if '.application.' in import_lower or '/application/' in import_lower:
|
|
129
|
+
return 'application'
|
|
130
|
+
if 'use_case' in import_lower or 'usecase' in import_lower or 'use_cases' in import_lower:
|
|
131
|
+
return 'application'
|
|
132
|
+
|
|
133
|
+
# Presentation layer
|
|
134
|
+
if '.presentation.' in import_lower or '/presentation/' in import_lower:
|
|
135
|
+
return 'presentation'
|
|
136
|
+
if 'controller' in import_lower or 'handler' in import_lower:
|
|
137
|
+
return 'presentation'
|
|
138
|
+
|
|
139
|
+
# Integration layer (check after ports to avoid false positives)
|
|
140
|
+
if '.integration.' in import_lower or '/integration/' in import_lower:
|
|
141
|
+
return 'integration'
|
|
142
|
+
if 'infrastructure' in import_lower or 'adapter' in import_lower:
|
|
143
|
+
return 'integration'
|
|
144
|
+
# Only mark as integration if it has 'repository' but NOT 'port'
|
|
145
|
+
if 'repository' in import_lower and 'port' not in import_lower:
|
|
146
|
+
return 'integration'
|
|
147
|
+
|
|
148
|
+
# Third-party or standard library
|
|
149
|
+
return 'external'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_backend_convention() -> Dict:
|
|
153
|
+
"""
|
|
154
|
+
Load backend convention from YAML file.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Backend convention dict
|
|
158
|
+
"""
|
|
159
|
+
if not BACKEND_CONVENTION.exists():
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
with open(BACKEND_CONVENTION, 'r', encoding='utf-8') as f:
|
|
163
|
+
data = yaml.safe_load(f)
|
|
164
|
+
return data.get('backend', {})
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_layer_component_suffixes(convention: Dict) -> Dict[str, Dict[str, List[str]]]:
|
|
168
|
+
"""
|
|
169
|
+
Extract layer -> component_type -> suffixes mapping from convention.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
convention: Backend convention dict
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Dict like {
|
|
176
|
+
'domain': {
|
|
177
|
+
'entities': ['*.py'],
|
|
178
|
+
'value_objects': ['*_vo.py', '*.py']
|
|
179
|
+
},
|
|
180
|
+
'application': {...},
|
|
181
|
+
...
|
|
182
|
+
}
|
|
183
|
+
"""
|
|
184
|
+
result = {}
|
|
185
|
+
|
|
186
|
+
layers = convention.get('layers', {})
|
|
187
|
+
for layer_name, layer_config in layers.items():
|
|
188
|
+
result[layer_name] = {}
|
|
189
|
+
|
|
190
|
+
component_types = layer_config.get('component_types', [])
|
|
191
|
+
for component_type in component_types:
|
|
192
|
+
name = component_type.get('name', '')
|
|
193
|
+
suffix_config = component_type.get('suffix', {})
|
|
194
|
+
|
|
195
|
+
# Get Python suffixes
|
|
196
|
+
py_suffixes = suffix_config.get('python', '')
|
|
197
|
+
if py_suffixes:
|
|
198
|
+
# Parse comma-separated suffixes
|
|
199
|
+
suffixes = [s.strip() for s in py_suffixes.split(',')]
|
|
200
|
+
result[layer_name][name] = suffixes
|
|
201
|
+
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def matches_suffix_pattern(filename: str, pattern: str) -> bool:
|
|
206
|
+
"""
|
|
207
|
+
Check if filename matches a suffix pattern.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
filename: File name (e.g., "user_service.py")
|
|
211
|
+
pattern: Pattern (e.g., "*_service.py")
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
True if matches
|
|
215
|
+
"""
|
|
216
|
+
# Convert glob pattern to regex
|
|
217
|
+
# *_service.py -> .*_service\.py$
|
|
218
|
+
# *.py -> .*\.py$
|
|
219
|
+
regex_pattern = pattern.replace('.', r'\.')
|
|
220
|
+
regex_pattern = regex_pattern.replace('*', '.*')
|
|
221
|
+
regex_pattern = f'^{regex_pattern}$'
|
|
222
|
+
|
|
223
|
+
return bool(re.match(regex_pattern, filename))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def determine_expected_layer_from_suffix(filename: str, convention: Dict) -> Tuple[str, str]:
|
|
227
|
+
"""
|
|
228
|
+
Determine expected layer and component type from filename suffix.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
filename: File name (e.g., "user_service.py")
|
|
232
|
+
convention: Backend convention dict
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (layer_name, component_type) or ('unknown', 'unknown')
|
|
236
|
+
"""
|
|
237
|
+
layer_suffixes = get_layer_component_suffixes(convention)
|
|
238
|
+
|
|
239
|
+
# Check more specific patterns first (e.g., *_service.py before *.py)
|
|
240
|
+
# Sort by pattern length descending
|
|
241
|
+
for layer_name, component_types in layer_suffixes.items():
|
|
242
|
+
for component_type, suffixes in component_types.items():
|
|
243
|
+
# Sort suffixes by length descending (more specific first)
|
|
244
|
+
sorted_suffixes = sorted(suffixes, key=len, reverse=True)
|
|
245
|
+
for suffix_pattern in sorted_suffixes:
|
|
246
|
+
# Skip generic patterns - causes too many false positives
|
|
247
|
+
if suffix_pattern == '*.py':
|
|
248
|
+
continue
|
|
249
|
+
if matches_suffix_pattern(filename, suffix_pattern):
|
|
250
|
+
return layer_name, component_type
|
|
251
|
+
|
|
252
|
+
# Don't fall back to generic *.py - causes too many false positives
|
|
253
|
+
return 'unknown', 'unknown'
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def find_python_files() -> list:
|
|
257
|
+
"""
|
|
258
|
+
Find all Python files in python/ directory.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
List of Path objects
|
|
262
|
+
"""
|
|
263
|
+
if not PYTHON_DIR.exists():
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
python_files = []
|
|
267
|
+
for py_file in PYTHON_DIR.rglob("*.py"):
|
|
268
|
+
# Skip test files
|
|
269
|
+
if '/test/' in str(py_file) or py_file.name.startswith('test_'):
|
|
270
|
+
continue
|
|
271
|
+
# Skip __pycache__
|
|
272
|
+
if '__pycache__' in str(py_file):
|
|
273
|
+
continue
|
|
274
|
+
# Skip __init__.py (usually just imports)
|
|
275
|
+
if py_file.name == '__init__.py':
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
python_files.append(py_file)
|
|
279
|
+
|
|
280
|
+
return python_files
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@pytest.mark.coder
|
|
284
|
+
def test_python_follows_clean_architecture():
|
|
285
|
+
"""
|
|
286
|
+
SPEC-CODER-ARCH-0001: Python code follows 4-layer clean architecture.
|
|
287
|
+
|
|
288
|
+
Clean architecture dependency rules:
|
|
289
|
+
- Domain → NOTHING (domain must be pure)
|
|
290
|
+
- Application → Domain only
|
|
291
|
+
- Presentation → Application, Domain
|
|
292
|
+
- Integration → Domain only
|
|
293
|
+
|
|
294
|
+
Forbidden dependencies:
|
|
295
|
+
- Domain → Application/Presentation/Integration
|
|
296
|
+
- Application → Presentation/Integration
|
|
297
|
+
- Integration → Application/Presentation
|
|
298
|
+
|
|
299
|
+
Given: Python files in python/
|
|
300
|
+
When: Checking import statements
|
|
301
|
+
Then: No forbidden cross-layer dependencies
|
|
302
|
+
"""
|
|
303
|
+
python_files = find_python_files()
|
|
304
|
+
|
|
305
|
+
if not python_files:
|
|
306
|
+
pytest.skip("No Python files found to validate")
|
|
307
|
+
|
|
308
|
+
violations = []
|
|
309
|
+
|
|
310
|
+
for py_file in python_files:
|
|
311
|
+
layer = determine_layer_from_path(py_file)
|
|
312
|
+
|
|
313
|
+
# Skip files we can't categorize
|
|
314
|
+
if layer == 'unknown':
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
imports = extract_python_imports(py_file)
|
|
318
|
+
|
|
319
|
+
for imp in imports:
|
|
320
|
+
target_layer = infer_layer_from_import(imp)
|
|
321
|
+
|
|
322
|
+
# Skip external imports (third-party libraries)
|
|
323
|
+
if target_layer == 'external' or target_layer == 'unknown':
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
# Check dependency rules
|
|
327
|
+
violation = None
|
|
328
|
+
|
|
329
|
+
if layer == 'domain':
|
|
330
|
+
# Domain must not import from any other layer
|
|
331
|
+
if target_layer in ['application', 'presentation', 'integration']:
|
|
332
|
+
violation = f"Domain layer cannot import from {target_layer}"
|
|
333
|
+
|
|
334
|
+
elif layer == 'application':
|
|
335
|
+
# Application can only import from domain
|
|
336
|
+
if target_layer in ['presentation', 'integration']:
|
|
337
|
+
violation = f"Application layer cannot import from {target_layer}"
|
|
338
|
+
|
|
339
|
+
elif layer == 'integration':
|
|
340
|
+
# Integration can import from application (for ports) and domain
|
|
341
|
+
# See backend.convention.yaml line 402-403: integration -> [application, domain]
|
|
342
|
+
if target_layer == 'presentation':
|
|
343
|
+
violation = f"Integration layer cannot import from {target_layer}"
|
|
344
|
+
|
|
345
|
+
if violation:
|
|
346
|
+
rel_path = py_file.relative_to(REPO_ROOT)
|
|
347
|
+
violations.append(
|
|
348
|
+
f"{rel_path}\\n"
|
|
349
|
+
f" Layer: {layer}\\n"
|
|
350
|
+
f" Import: {imp}\\n"
|
|
351
|
+
f" Violation: {violation}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if violations:
|
|
355
|
+
pytest.fail(
|
|
356
|
+
f"\\n\\nFound {len(violations)} architecture violations:\\n\\n" +
|
|
357
|
+
"\\n\\n".join(violations[:10]) +
|
|
358
|
+
(f"\\n\\n... and {len(violations) - 10} more" if len(violations) > 10 else "")
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@pytest.mark.coder
|
|
363
|
+
def test_domain_layer_is_pure():
|
|
364
|
+
"""
|
|
365
|
+
SPEC-CODER-ARCH-0002: Domain layer has no external dependencies.
|
|
366
|
+
|
|
367
|
+
Domain layer should only import:
|
|
368
|
+
- Standard library (typing, dataclasses, etc.)
|
|
369
|
+
- Other domain modules
|
|
370
|
+
|
|
371
|
+
Should NOT import:
|
|
372
|
+
- Third-party libraries (except type hints)
|
|
373
|
+
- Application/Presentation/Integration layers
|
|
374
|
+
- Database/API libraries
|
|
375
|
+
|
|
376
|
+
Given: Python files in domain/ directories
|
|
377
|
+
When: Checking imports
|
|
378
|
+
Then: Only standard library and domain imports
|
|
379
|
+
"""
|
|
380
|
+
python_files = find_python_files()
|
|
381
|
+
|
|
382
|
+
if not python_files:
|
|
383
|
+
pytest.skip("No Python files found to validate")
|
|
384
|
+
|
|
385
|
+
# Standard library modules (allowed in domain)
|
|
386
|
+
# Note: time is allowed for time.perf_counter() timing measurements (pure function)
|
|
387
|
+
ALLOWED_STDLIB = {
|
|
388
|
+
'__future__', 'typing', 'dataclasses', 'enum', 'abc', 'datetime', 'uuid',
|
|
389
|
+
'collections', 'itertools', 'functools', 're', 'json', 'pathlib',
|
|
390
|
+
'hashlib', 'warnings', 'types', 'random', 'math', 'decimal',
|
|
391
|
+
'copy', 'operator', 'string', 'textwrap', 'io', 'struct', 'time'
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
violations = []
|
|
395
|
+
|
|
396
|
+
for py_file in python_files:
|
|
397
|
+
layer = determine_layer_from_path(py_file)
|
|
398
|
+
|
|
399
|
+
# Only check domain layer
|
|
400
|
+
if layer != 'domain':
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
imports = extract_python_imports(py_file)
|
|
404
|
+
|
|
405
|
+
for imp in imports:
|
|
406
|
+
# Skip relative imports (internal to domain)
|
|
407
|
+
if imp.startswith('.'):
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# Get root module name
|
|
411
|
+
root_module = imp.split('.')[0]
|
|
412
|
+
|
|
413
|
+
# Check if it's allowed standard library
|
|
414
|
+
if root_module in ALLOWED_STDLIB:
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
# Check if it's from contracts/ (neutral DTO boundary - allowed per dto.convention.yaml)
|
|
418
|
+
if root_module == 'contracts':
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
# Check if it's domain import
|
|
422
|
+
if 'domain' in imp.lower():
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
# Check if it's from the same package
|
|
426
|
+
if 'src' in imp or root_module in str(py_file):
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
# Otherwise it's a violation
|
|
430
|
+
rel_path = py_file.relative_to(REPO_ROOT)
|
|
431
|
+
violations.append(
|
|
432
|
+
f"{rel_path}\\n"
|
|
433
|
+
f" Import: {imp}\\n"
|
|
434
|
+
f" Issue: Domain layer should not import external libraries"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if violations:
|
|
438
|
+
pytest.fail(
|
|
439
|
+
f"\\n\\nFound {len(violations)} domain purity violations:\\n\\n" +
|
|
440
|
+
"\\n\\n".join(violations[:10]) +
|
|
441
|
+
(f"\\n\\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
|
|
442
|
+
f"\\n\\nDomain layer should only import:\\n" +
|
|
443
|
+
f" - Standard library: {', '.join(sorted(ALLOWED_STDLIB))}\\n" +
|
|
444
|
+
f" - Other domain modules"
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
@pytest.mark.coder
|
|
448
|
+
def test_python_component_naming_follows_conventions():
|
|
449
|
+
"""
|
|
450
|
+
SPEC-CODER-ARCH-PY-0003: Python components follow naming conventions.
|
|
451
|
+
|
|
452
|
+
Component naming rules from conventions (with flexible layer placement):
|
|
453
|
+
- Controllers: *_controller.py (presentation layer)
|
|
454
|
+
- Services: *_service.py (domain layer)
|
|
455
|
+
- Repositories: *_repository.py (integration layer)
|
|
456
|
+
- Use Cases: *_use_case.py (application layer)
|
|
457
|
+
- Entities: *.py (domain layer ONLY - pure business objects)
|
|
458
|
+
- DTOs: *_dto.py (application layer)
|
|
459
|
+
- Validators: *_validator.py (presentation|application|domain - depends on what they validate)
|
|
460
|
+
* presentation: input shape/format validation
|
|
461
|
+
* application: cross-cutting orchestration checks
|
|
462
|
+
* domain: business invariants (often inline, not separate files)
|
|
463
|
+
- Mappers: *_mapper.py (integration|application - depends on mapping responsibility)
|
|
464
|
+
* integration: boundary mappers (IO ↔ internal types)
|
|
465
|
+
* application: internal use-case transforms
|
|
466
|
+
- Clients: *_client.py (integration layer)
|
|
467
|
+
- Stores: *_store.py or *_storage.py (integration layer)
|
|
468
|
+
- Handlers: *_handler.py (application layer)
|
|
469
|
+
- Guards: *_guard.py (presentation layer)
|
|
470
|
+
- Middleware: *_middleware.py (presentation layer)
|
|
471
|
+
- Ports: protocols.py or *_port.py (application layer)
|
|
472
|
+
- Events: *_event.py (domain layer)
|
|
473
|
+
- Exceptions: *_exception.py or exceptions.py (domain layer)
|
|
474
|
+
- Engines: *_engine.py, *_analyzer.py, *_processor.py (integration layer)
|
|
475
|
+
|
|
476
|
+
Given: Python files with recognizable suffixes
|
|
477
|
+
When: Checking file locations
|
|
478
|
+
Then: Files are in correct layers per their suffixes
|
|
479
|
+
"""
|
|
480
|
+
python_files = find_python_files()
|
|
481
|
+
|
|
482
|
+
if not python_files:
|
|
483
|
+
pytest.skip("No Python files found to validate")
|
|
484
|
+
|
|
485
|
+
backend_conv = load_backend_convention()
|
|
486
|
+
|
|
487
|
+
if not backend_conv:
|
|
488
|
+
pytest.skip("Backend convention file not found")
|
|
489
|
+
|
|
490
|
+
# Flexible layer rules: component type -> allowed layers
|
|
491
|
+
# These component types can legitimately appear in multiple layers depending on purpose
|
|
492
|
+
FLEXIBLE_LAYERS = {
|
|
493
|
+
'validators': ['presentation', 'application', 'domain', 'integration'], # Validation at any boundary
|
|
494
|
+
'mappers': ['integration', 'application'], # Depends on mapping responsibility
|
|
495
|
+
'monitors': ['domain', 'application', 'integration'], # Domain: business state, Integration: infra
|
|
496
|
+
'services': ['domain', 'application'], # Domain services (logic) and Application services (orchestration)
|
|
497
|
+
'handlers': ['application', 'domain'], # Application: CQRS, Domain: domain event handlers
|
|
498
|
+
'ports': ['application', 'integration'], # Application defines ports, Integration can have internal ports
|
|
499
|
+
'engines': ['integration', 'domain'], # Integration: external, Domain: pure computation
|
|
500
|
+
'formatters': ['integration', 'domain'], # Integration: output, Domain: value formatting
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
violations = []
|
|
504
|
+
|
|
505
|
+
for py_file in python_files:
|
|
506
|
+
actual_layer = determine_layer_from_path(py_file)
|
|
507
|
+
|
|
508
|
+
# Skip files in unknown locations
|
|
509
|
+
if actual_layer == 'unknown':
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
filename = py_file.name
|
|
513
|
+
|
|
514
|
+
# Determine expected layer from suffix
|
|
515
|
+
expected_layer, component_type = determine_expected_layer_from_suffix(filename, backend_conv)
|
|
516
|
+
|
|
517
|
+
# Skip unknown component types
|
|
518
|
+
if expected_layer == 'unknown':
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
# Skip generic "entities" matches from *.py pattern - too broad, causes false positives
|
|
522
|
+
# Only enforce entities rule if file is actually in domain/entities/ subdirectory
|
|
523
|
+
if component_type == 'entities' and actual_layer != 'domain':
|
|
524
|
+
# Check if file is in an entities subdirectory
|
|
525
|
+
if '/entities/' not in str(py_file):
|
|
526
|
+
continue # Skip - this is a false positive from generic *.py pattern
|
|
527
|
+
|
|
528
|
+
# Check if this component type has flexible layer rules
|
|
529
|
+
if component_type in FLEXIBLE_LAYERS:
|
|
530
|
+
allowed_layers = FLEXIBLE_LAYERS[component_type]
|
|
531
|
+
if actual_layer not in allowed_layers:
|
|
532
|
+
rel_path = py_file.relative_to(REPO_ROOT)
|
|
533
|
+
violations.append(
|
|
534
|
+
f"{rel_path}\n"
|
|
535
|
+
f" Component Type: {component_type}\n"
|
|
536
|
+
f" Allowed Layers: {', '.join(allowed_layers)}\n"
|
|
537
|
+
f" Actual Layer: {actual_layer}\n"
|
|
538
|
+
f" Issue: {component_type} must be in one of: {', '.join(allowed_layers)}"
|
|
539
|
+
)
|
|
540
|
+
# Otherwise, enforce strict layer placement
|
|
541
|
+
elif expected_layer != actual_layer:
|
|
542
|
+
rel_path = py_file.relative_to(REPO_ROOT)
|
|
543
|
+
violations.append(
|
|
544
|
+
f"{rel_path}\n"
|
|
545
|
+
f" Component Type: {component_type}\n"
|
|
546
|
+
f" Expected Layer: {expected_layer}\n"
|
|
547
|
+
f" Actual Layer: {actual_layer}\n"
|
|
548
|
+
f" Issue: File suffix indicates {expected_layer} layer but found in {actual_layer}"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if violations:
|
|
552
|
+
pytest.fail(
|
|
553
|
+
f"\n\nFound {len(violations)} component naming/placement violations:\n\n" +
|
|
554
|
+
"\n\n".join(violations[:10]) +
|
|
555
|
+
(f"\n\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
|
|
556
|
+
f"\n\nComponent suffixes must match their layer placement.\n" +
|
|
557
|
+
f"See: atdd/coder/conventions/backend.convention.yaml"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@pytest.mark.coder
|
|
562
|
+
def test_python_layers_have_proper_component_organization():
|
|
563
|
+
"""
|
|
564
|
+
SPEC-CODER-ARCH-PY-0004: Each layer has proper component type grouping.
|
|
565
|
+
|
|
566
|
+
Layer organization rules:
|
|
567
|
+
- Domain layer: entities/, value_objects/, aggregates/, services/, specifications/, events/, exceptions/
|
|
568
|
+
- Application layer: use_cases/, handlers/, ports/, dtos/, policies/, workflows/
|
|
569
|
+
- Presentation layer: controllers/, routes/, serializers/, validators/, middleware/, guards/, views/
|
|
570
|
+
- Integration layer: repositories/, clients/, caches/, engines/, formatters/, notifiers/, queues/, stores/, mappers/, schedulers/, monitors/
|
|
571
|
+
|
|
572
|
+
Given: Python files organized in layers
|
|
573
|
+
When: Checking directory structure
|
|
574
|
+
Then: Component types are in correct subdirectories
|
|
575
|
+
"""
|
|
576
|
+
python_files = find_python_files()
|
|
577
|
+
|
|
578
|
+
if not python_files:
|
|
579
|
+
pytest.skip("No Python files found to validate")
|
|
580
|
+
|
|
581
|
+
backend_conv = load_backend_convention()
|
|
582
|
+
|
|
583
|
+
if not backend_conv:
|
|
584
|
+
pytest.skip("Backend convention file not found")
|
|
585
|
+
|
|
586
|
+
violations = []
|
|
587
|
+
|
|
588
|
+
for py_file in python_files:
|
|
589
|
+
layer = determine_layer_from_path(py_file)
|
|
590
|
+
|
|
591
|
+
# Skip unknown layers
|
|
592
|
+
if layer == 'unknown':
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
path_str = str(py_file)
|
|
596
|
+
filename = py_file.name
|
|
597
|
+
|
|
598
|
+
# Determine expected component type from suffix
|
|
599
|
+
expected_layer, component_type = determine_expected_layer_from_suffix(filename, backend_conv)
|
|
600
|
+
|
|
601
|
+
# If can't determine, skip
|
|
602
|
+
if expected_layer == 'unknown':
|
|
603
|
+
continue
|
|
604
|
+
|
|
605
|
+
# Check if file is in a component type subdirectory
|
|
606
|
+
# Expected pattern: .../layer/component_type/file.py
|
|
607
|
+
# e.g., .../domain/entities/user.py
|
|
608
|
+
# or .../application/use_cases/create_user_use_case.py
|
|
609
|
+
|
|
610
|
+
# Files commonly placed at layer root (no subdirectory required)
|
|
611
|
+
layer_root_allowed = [
|
|
612
|
+
'exceptions.py', 'errors.py', # Exception definitions
|
|
613
|
+
'__init__.py', # Package init
|
|
614
|
+
'types.py', 'protocols.py', # Type definitions and protocols
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
# Skip validation for files commonly at layer root
|
|
618
|
+
if filename in layer_root_allowed:
|
|
619
|
+
continue
|
|
620
|
+
|
|
621
|
+
# Alternative directory patterns that are equivalent to convention patterns
|
|
622
|
+
# e.g., "api" is commonly used instead of "routes" or "controllers"
|
|
623
|
+
ALTERNATIVE_DIRS = {
|
|
624
|
+
'routes': ['api', 'endpoints'], # FastAPI/Flask common patterns
|
|
625
|
+
'controllers': ['api', 'handlers'], # API handlers pattern
|
|
626
|
+
'use_cases': ['usecases', 'services'], # Common alternatives
|
|
627
|
+
'services': ['usecases'], # Services often in usecases dir
|
|
628
|
+
'validators': ['services'], # Validation services in services dir
|
|
629
|
+
'handlers': ['services'], # Handler services in services dir
|
|
630
|
+
'monitors': ['services', 'trackers'], # Monitoring/tracking services
|
|
631
|
+
'engines': ['services', 'analyzers', 'processors'], # Computation engines
|
|
632
|
+
'formatters': ['services', 'generators'], # Formatting/generation services
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# Component types that can be at layer root (no subdirectory required)
|
|
636
|
+
# These are commonly placed directly in the layer directory
|
|
637
|
+
LAYER_ROOT_ALLOWED_COMPONENTS = [
|
|
638
|
+
'handlers', # Domain/application handlers often at root
|
|
639
|
+
'use_cases', # Small wagons may have single use case at root
|
|
640
|
+
'formatters', # Simple formatters at domain root
|
|
641
|
+
'services', # Simple services at layer root
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
# Skip if component type is allowed at layer root and file is directly in layer
|
|
645
|
+
if component_type in LAYER_ROOT_ALLOWED_COMPONENTS:
|
|
646
|
+
# Check if file is directly in the layer directory (not in a subdirectory)
|
|
647
|
+
parent_dir = py_file.parent.name
|
|
648
|
+
if parent_dir == layer:
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
# Check if component type directory (or alternative) is in path
|
|
652
|
+
dirs_to_check = [component_type] + ALTERNATIVE_DIRS.get(component_type, [])
|
|
653
|
+
found_valid_dir = any(f'/{dir_name}/' in path_str for dir_name in dirs_to_check)
|
|
654
|
+
|
|
655
|
+
if not found_valid_dir:
|
|
656
|
+
# Only flag if this is a clear architecture setup (has layer directory)
|
|
657
|
+
if f'/{layer}/' in path_str:
|
|
658
|
+
rel_path = py_file.relative_to(REPO_ROOT)
|
|
659
|
+
violations.append(
|
|
660
|
+
f"{rel_path}\n"
|
|
661
|
+
f" Layer: {layer}\n"
|
|
662
|
+
f" Component Type: {component_type}\n"
|
|
663
|
+
f" Issue: Should be in {layer}/{component_type}/ subdirectory"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if violations:
|
|
667
|
+
pytest.fail(
|
|
668
|
+
f"\n\nFound {len(violations)} component organization violations:\n\n" +
|
|
669
|
+
"\n\n".join(violations[:10]) +
|
|
670
|
+
(f"\n\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
|
|
671
|
+
f"\n\nComponents should be organized in layer/component_type/ subdirectories.\n" +
|
|
672
|
+
f"Example: domain/entities/user.py, application/use_cases/create_user_use_case.py\n" +
|
|
673
|
+
f"See: atdd/coder/conventions/backend.convention.yaml"
|
|
674
|
+
)
|