atdd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. atdd/__init__.py +0 -0
  2. atdd/cli.py +404 -0
  3. atdd/coach/__init__.py +0 -0
  4. atdd/coach/commands/__init__.py +0 -0
  5. atdd/coach/commands/add_persistence_metadata.py +215 -0
  6. atdd/coach/commands/analyze_migrations.py +188 -0
  7. atdd/coach/commands/consumers.py +720 -0
  8. atdd/coach/commands/infer_governance_status.py +149 -0
  9. atdd/coach/commands/initializer.py +177 -0
  10. atdd/coach/commands/interface.py +1078 -0
  11. atdd/coach/commands/inventory.py +565 -0
  12. atdd/coach/commands/migration.py +240 -0
  13. atdd/coach/commands/registry.py +1560 -0
  14. atdd/coach/commands/session.py +430 -0
  15. atdd/coach/commands/sync.py +405 -0
  16. atdd/coach/commands/test_interface.py +399 -0
  17. atdd/coach/commands/test_runner.py +141 -0
  18. atdd/coach/commands/tests/__init__.py +1 -0
  19. atdd/coach/commands/tests/test_telemetry_array_validation.py +235 -0
  20. atdd/coach/commands/traceability.py +4264 -0
  21. atdd/coach/conventions/session.convention.yaml +754 -0
  22. atdd/coach/overlays/__init__.py +2 -0
  23. atdd/coach/overlays/claude.md +2 -0
  24. atdd/coach/schemas/config.schema.json +34 -0
  25. atdd/coach/schemas/manifest.schema.json +101 -0
  26. atdd/coach/templates/ATDD.md +282 -0
  27. atdd/coach/templates/SESSION-TEMPLATE.md +327 -0
  28. atdd/coach/utils/__init__.py +0 -0
  29. atdd/coach/utils/graph/__init__.py +0 -0
  30. atdd/coach/utils/graph/urn.py +875 -0
  31. atdd/coach/validators/__init__.py +0 -0
  32. atdd/coach/validators/shared_fixtures.py +365 -0
  33. atdd/coach/validators/test_enrich_wagon_registry.py +167 -0
  34. atdd/coach/validators/test_registry.py +575 -0
  35. atdd/coach/validators/test_session_validation.py +1183 -0
  36. atdd/coach/validators/test_traceability.py +448 -0
  37. atdd/coach/validators/test_update_feature_paths.py +108 -0
  38. atdd/coach/validators/test_validate_contract_consumers.py +297 -0
  39. atdd/coder/__init__.py +1 -0
  40. atdd/coder/conventions/adapter.recipe.yaml +88 -0
  41. atdd/coder/conventions/backend.convention.yaml +460 -0
  42. atdd/coder/conventions/boundaries.convention.yaml +666 -0
  43. atdd/coder/conventions/commons.convention.yaml +460 -0
  44. atdd/coder/conventions/complexity.recipe.yaml +109 -0
  45. atdd/coder/conventions/component-naming.convention.yaml +178 -0
  46. atdd/coder/conventions/design.convention.yaml +327 -0
  47. atdd/coder/conventions/design.recipe.yaml +273 -0
  48. atdd/coder/conventions/dto.convention.yaml +660 -0
  49. atdd/coder/conventions/frontend.convention.yaml +542 -0
  50. atdd/coder/conventions/green.convention.yaml +1012 -0
  51. atdd/coder/conventions/presentation.convention.yaml +587 -0
  52. atdd/coder/conventions/refactor.convention.yaml +535 -0
  53. atdd/coder/conventions/technology.convention.yaml +206 -0
  54. atdd/coder/conventions/tests/__init__.py +0 -0
  55. atdd/coder/conventions/tests/test_adapter_recipe.py +302 -0
  56. atdd/coder/conventions/tests/test_complexity_recipe.py +289 -0
  57. atdd/coder/conventions/tests/test_component_taxonomy.py +278 -0
  58. atdd/coder/conventions/tests/test_component_urn_naming.py +165 -0
  59. atdd/coder/conventions/tests/test_thinness_recipe.py +286 -0
  60. atdd/coder/conventions/thinness.recipe.yaml +82 -0
  61. atdd/coder/conventions/train.convention.yaml +325 -0
  62. atdd/coder/conventions/verification.protocol.yaml +53 -0
  63. atdd/coder/schemas/design_system.schema.json +361 -0
  64. atdd/coder/validators/__init__.py +0 -0
  65. atdd/coder/validators/test_commons_structure.py +485 -0
  66. atdd/coder/validators/test_complexity.py +416 -0
  67. atdd/coder/validators/test_cross_language_consistency.py +431 -0
  68. atdd/coder/validators/test_design_system_compliance.py +413 -0
  69. atdd/coder/validators/test_dto_testing_patterns.py +268 -0
  70. atdd/coder/validators/test_green_cross_stack_layers.py +168 -0
  71. atdd/coder/validators/test_green_layer_dependencies.py +148 -0
  72. atdd/coder/validators/test_green_python_layer_structure.py +103 -0
  73. atdd/coder/validators/test_green_supabase_layer_structure.py +103 -0
  74. atdd/coder/validators/test_import_boundaries.py +396 -0
  75. atdd/coder/validators/test_init_file_urns.py +593 -0
  76. atdd/coder/validators/test_preact_layer_boundaries.py +221 -0
  77. atdd/coder/validators/test_presentation_convention.py +260 -0
  78. atdd/coder/validators/test_python_architecture.py +674 -0
  79. atdd/coder/validators/test_quality_metrics.py +420 -0
  80. atdd/coder/validators/test_station_master_pattern.py +244 -0
  81. atdd/coder/validators/test_train_infrastructure.py +454 -0
  82. atdd/coder/validators/test_train_urns.py +293 -0
  83. atdd/coder/validators/test_typescript_architecture.py +616 -0
  84. atdd/coder/validators/test_usecase_structure.py +421 -0
  85. atdd/coder/validators/test_wagon_boundaries.py +586 -0
  86. atdd/conftest.py +126 -0
  87. atdd/planner/__init__.py +1 -0
  88. atdd/planner/conventions/acceptance.convention.yaml +538 -0
  89. atdd/planner/conventions/appendix.convention.yaml +187 -0
  90. atdd/planner/conventions/artifact-naming.convention.yaml +852 -0
  91. atdd/planner/conventions/component.convention.yaml +670 -0
  92. atdd/planner/conventions/criteria.convention.yaml +141 -0
  93. atdd/planner/conventions/feature.convention.yaml +371 -0
  94. atdd/planner/conventions/interface.convention.yaml +382 -0
  95. atdd/planner/conventions/steps.convention.yaml +141 -0
  96. atdd/planner/conventions/train.convention.yaml +552 -0
  97. atdd/planner/conventions/wagon.convention.yaml +275 -0
  98. atdd/planner/conventions/wmbt.convention.yaml +258 -0
  99. atdd/planner/schemas/acceptance.schema.json +336 -0
  100. atdd/planner/schemas/appendix.schema.json +78 -0
  101. atdd/planner/schemas/component.schema.json +114 -0
  102. atdd/planner/schemas/feature.schema.json +197 -0
  103. atdd/planner/schemas/train.schema.json +192 -0
  104. atdd/planner/schemas/wagon.schema.json +281 -0
  105. atdd/planner/schemas/wmbt.schema.json +59 -0
  106. atdd/planner/validators/__init__.py +0 -0
  107. atdd/planner/validators/conftest.py +5 -0
  108. atdd/planner/validators/test_draft_wagon_registry.py +374 -0
  109. atdd/planner/validators/test_plan_cross_refs.py +240 -0
  110. atdd/planner/validators/test_plan_uniqueness.py +224 -0
  111. atdd/planner/validators/test_plan_urn_resolution.py +268 -0
  112. atdd/planner/validators/test_plan_wagons.py +174 -0
  113. atdd/planner/validators/test_train_validation.py +514 -0
  114. atdd/planner/validators/test_wagon_urn_chain.py +648 -0
  115. atdd/planner/validators/test_wmbt_consistency.py +327 -0
  116. atdd/planner/validators/test_wmbt_vocabulary.py +632 -0
  117. atdd/tester/__init__.py +1 -0
  118. atdd/tester/conventions/artifact.convention.yaml +257 -0
  119. atdd/tester/conventions/contract.convention.yaml +1009 -0
  120. atdd/tester/conventions/filename.convention.yaml +555 -0
  121. atdd/tester/conventions/migration.convention.yaml +509 -0
  122. atdd/tester/conventions/red.convention.yaml +797 -0
  123. atdd/tester/conventions/routing.convention.yaml +51 -0
  124. atdd/tester/conventions/telemetry.convention.yaml +458 -0
  125. atdd/tester/schemas/a11y.tmpl.json +17 -0
  126. atdd/tester/schemas/artifact.schema.json +189 -0
  127. atdd/tester/schemas/contract.schema.json +591 -0
  128. atdd/tester/schemas/contract.tmpl.json +95 -0
  129. atdd/tester/schemas/db.tmpl.json +20 -0
  130. atdd/tester/schemas/e2e.tmpl.json +17 -0
  131. atdd/tester/schemas/edge_function.tmpl.json +17 -0
  132. atdd/tester/schemas/event.tmpl.json +17 -0
  133. atdd/tester/schemas/http.tmpl.json +19 -0
  134. atdd/tester/schemas/job.tmpl.json +18 -0
  135. atdd/tester/schemas/load.tmpl.json +21 -0
  136. atdd/tester/schemas/metric.tmpl.json +19 -0
  137. atdd/tester/schemas/pack.schema.json +139 -0
  138. atdd/tester/schemas/realtime.tmpl.json +20 -0
  139. atdd/tester/schemas/rls.tmpl.json +18 -0
  140. atdd/tester/schemas/script.tmpl.json +16 -0
  141. atdd/tester/schemas/sec.tmpl.json +18 -0
  142. atdd/tester/schemas/storage.tmpl.json +18 -0
  143. atdd/tester/schemas/telemetry.schema.json +128 -0
  144. atdd/tester/schemas/telemetry_tracking_manifest.schema.json +143 -0
  145. atdd/tester/schemas/test_filename.schema.json +194 -0
  146. atdd/tester/schemas/test_intent.schema.json +179 -0
  147. atdd/tester/schemas/unit.tmpl.json +18 -0
  148. atdd/tester/schemas/visual.tmpl.json +18 -0
  149. atdd/tester/schemas/ws.tmpl.json +17 -0
  150. atdd/tester/utils/__init__.py +0 -0
  151. atdd/tester/utils/filename.py +300 -0
  152. atdd/tester/validators/__init__.py +0 -0
  153. atdd/tester/validators/cleanup_duplicate_headers.py +116 -0
  154. atdd/tester/validators/cleanup_duplicate_headers_v2.py +135 -0
  155. atdd/tester/validators/conftest.py +5 -0
  156. atdd/tester/validators/coverage_gap_report.py +321 -0
  157. atdd/tester/validators/fix_dual_ac_references.py +179 -0
  158. atdd/tester/validators/remove_duplicate_lines.py +93 -0
  159. atdd/tester/validators/test_acceptance_urn_filename_mapping.py +359 -0
  160. atdd/tester/validators/test_acceptance_urn_separator.py +166 -0
  161. atdd/tester/validators/test_artifact_naming_category.py +307 -0
  162. atdd/tester/validators/test_contract_schema_compliance.py +706 -0
  163. atdd/tester/validators/test_contracts_structure.py +200 -0
  164. atdd/tester/validators/test_coverage_adequacy.py +797 -0
  165. atdd/tester/validators/test_dual_ac_reference.py +225 -0
  166. atdd/tester/validators/test_fixture_validity.py +372 -0
  167. atdd/tester/validators/test_isolation.py +487 -0
  168. atdd/tester/validators/test_migration_coverage.py +204 -0
  169. atdd/tester/validators/test_migration_criteria.py +276 -0
  170. atdd/tester/validators/test_migration_generation.py +116 -0
  171. atdd/tester/validators/test_python_test_naming.py +410 -0
  172. atdd/tester/validators/test_red_layer_validation.py +95 -0
  173. atdd/tester/validators/test_red_python_layer_structure.py +87 -0
  174. atdd/tester/validators/test_red_supabase_layer_structure.py +90 -0
  175. atdd/tester/validators/test_telemetry_structure.py +634 -0
  176. atdd/tester/validators/test_typescript_test_naming.py +301 -0
  177. atdd/tester/validators/test_typescript_test_structure.py +84 -0
  178. atdd-0.1.0.dist-info/METADATA +191 -0
  179. atdd-0.1.0.dist-info/RECORD +183 -0
  180. atdd-0.1.0.dist-info/WHEEL +5 -0
  181. atdd-0.1.0.dist-info/entry_points.txt +2 -0
  182. atdd-0.1.0.dist-info/licenses/LICENSE +674 -0
  183. atdd-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,420 @@
1
+ """
2
+ Test code quality metrics meet minimum standards.
3
+
4
+ Validates:
5
+ - Maintainability index > 60
6
+ - Code has appropriate comments
7
+ - No code duplication
8
+ - Consistent naming conventions
9
+
10
+ Inspired by: .claude/utils/coder/quality_metrics.py
11
+ But: Self-contained, no utility dependencies
12
+ """
13
+
14
+ import pytest
15
+ import re
16
+ from pathlib import Path
17
+ from typing import List, Tuple
18
+
19
+
20
+ # Path constants
21
+ REPO_ROOT = Path(__file__).resolve().parents[3]
22
+ PYTHON_DIR = REPO_ROOT / "python"
23
+
24
+
25
+ # Quality thresholds
26
+ MIN_MAINTAINABILITY_INDEX = 60
27
+ MIN_COMMENT_RATIO = 0.10 # 10% comments
28
+ MAX_DUPLICATE_LINES = 5
29
+
30
+
31
+ def find_python_files() -> List[Path]:
32
+ """Find all Python source files (excluding tests)."""
33
+ if not PYTHON_DIR.exists():
34
+ return []
35
+
36
+ files = []
37
+ for py_file in PYTHON_DIR.rglob("*.py"):
38
+ if '/test/' in str(py_file) or py_file.name.startswith('test_'):
39
+ continue
40
+ if '__pycache__' in str(py_file):
41
+ continue
42
+ files.append(py_file)
43
+
44
+ return files
45
+
46
+
47
+ def calculate_maintainability_index(file_path: Path) -> float:
48
+ """
49
+ Calculate simplified maintainability index for a file.
50
+
51
+ Based on:
52
+ - Lines of code
53
+ - Cyclomatic complexity (simplified)
54
+ - Comment ratio
55
+
56
+ Returns value 0-100 (higher is better)
57
+ """
58
+ try:
59
+ with open(file_path, 'r', encoding='utf-8') as f:
60
+ lines = f.readlines()
61
+ except Exception:
62
+ return 0
63
+
64
+ total_lines = len(lines)
65
+ code_lines = 0
66
+ comment_lines = 0
67
+ blank_lines = 0
68
+
69
+ for line in lines:
70
+ stripped = line.strip()
71
+ if not stripped:
72
+ blank_lines += 1
73
+ elif stripped.startswith('#'):
74
+ comment_lines += 1
75
+ else:
76
+ code_lines += 1
77
+
78
+ # Simple heuristic for maintainability
79
+ # Higher comment ratio = better
80
+ comment_ratio = comment_lines / total_lines if total_lines > 0 else 0
81
+
82
+ # Shorter files = better
83
+ size_factor = max(0, 100 - (code_lines / 10))
84
+
85
+ # Calculate index (simplified)
86
+ index = (size_factor * 0.4) + (comment_ratio * 100 * 0.6)
87
+
88
+ return min(100, max(0, index))
89
+
90
+
91
+ def calculate_comment_ratio(file_path: Path) -> float:
92
+ """
93
+ Calculate ratio of comments and docstrings to code.
94
+
95
+ Counts both:
96
+ - Inline comments (lines starting with #)
97
+ - Docstrings (triple-quoted strings)
98
+
99
+ Returns:
100
+ Ratio (0.0 to 1.0)
101
+ """
102
+ try:
103
+ with open(file_path, 'r', encoding='utf-8') as f:
104
+ lines = f.readlines()
105
+ except Exception:
106
+ return 0.0
107
+
108
+ code_lines = 0
109
+ comment_lines = 0
110
+ in_docstring = False
111
+ docstring_delim = None
112
+
113
+ for line in lines:
114
+ stripped = line.strip()
115
+ if not stripped:
116
+ continue
117
+
118
+ # Check for docstring delimiters
119
+ if '"""' in stripped or "'''" in stripped:
120
+ # Determine delimiter type
121
+ delim = '"""' if '"""' in stripped else "'''"
122
+
123
+ if not in_docstring:
124
+ # Starting a docstring
125
+ in_docstring = True
126
+ docstring_delim = delim
127
+ comment_lines += 1
128
+
129
+ # Check if docstring closes on same line
130
+ if stripped.count(delim) >= 2:
131
+ in_docstring = False
132
+ docstring_delim = None
133
+ else:
134
+ # Ending a docstring
135
+ if delim == docstring_delim:
136
+ in_docstring = False
137
+ docstring_delim = None
138
+ comment_lines += 1
139
+ elif in_docstring:
140
+ # Inside a docstring
141
+ comment_lines += 1
142
+ elif stripped.startswith('#'):
143
+ # Inline comment
144
+ comment_lines += 1
145
+ else:
146
+ # Code line
147
+ code_lines += 1
148
+
149
+ total = code_lines + comment_lines
150
+ return comment_lines / total if total > 0 else 0.0
151
+
152
+
153
+ def find_duplicate_code_blocks(files: List[Path]) -> List[Tuple[Path, Path, List[str]]]:
154
+ """
155
+ Find duplicate code blocks across files.
156
+
157
+ Returns:
158
+ List of (file1, file2, duplicate_lines) tuples
159
+ """
160
+ duplicates = []
161
+
162
+ # Simplified duplicate detection
163
+ # In reality, would use more sophisticated algorithm
164
+
165
+ file_contents = {}
166
+ for file in files:
167
+ try:
168
+ with open(file, 'r', encoding='utf-8') as f:
169
+ # Get normalized lines (stripped of whitespace)
170
+ lines = [line.strip() for line in f.readlines() if line.strip() and not line.strip().startswith('#')]
171
+ file_contents[file] = lines
172
+ except Exception:
173
+ continue
174
+
175
+ # Compare files pairwise (simplified)
176
+ files_list = list(file_contents.keys())
177
+ for i, file1 in enumerate(files_list):
178
+ for file2 in files_list[i+1:]:
179
+ lines1 = file_contents[file1]
180
+ lines2 = file_contents[file2]
181
+
182
+ # Find consecutive duplicate lines
183
+ for start1 in range(len(lines1) - MAX_DUPLICATE_LINES):
184
+ block1 = lines1[start1:start1 + MAX_DUPLICATE_LINES]
185
+
186
+ for start2 in range(len(lines2) - MAX_DUPLICATE_LINES):
187
+ block2 = lines2[start2:start2 + MAX_DUPLICATE_LINES]
188
+
189
+ if block1 == block2:
190
+ # Skip standard import blocks (common across port/adapter files)
191
+ block_text = '\n'.join(block1)
192
+ if 'from abc import' in block_text and 'from dataclasses import' in block_text:
193
+ # Standard port/adapter imports - acceptable
194
+ continue
195
+ duplicates.append((file1, file2, block1))
196
+ break
197
+
198
+ return duplicates
199
+
200
+
201
+ def check_naming_consistency(file_path: Path) -> List[str]:
202
+ """
203
+ Check naming conventions consistency.
204
+
205
+ Returns:
206
+ List of naming violations
207
+ """
208
+ violations = []
209
+
210
+ try:
211
+ with open(file_path, 'r', encoding='utf-8') as f:
212
+ content = f.read()
213
+ except Exception:
214
+ return violations
215
+
216
+ # Check class names (should be PascalCase)
217
+ class_pattern = r'class\s+([a-z][a-zA-Z0-9_]*)\s*[:\(]'
218
+ lowercase_classes = re.findall(class_pattern, content)
219
+ for cls in lowercase_classes:
220
+ violations.append(f"Class '{cls}' should use PascalCase")
221
+
222
+ # Check constant names (should be UPPER_CASE)
223
+ # Pattern: variable assignment at module level that looks like it should be constant
224
+ const_pattern = r'^([a-z][a-z0-9_]*)\s*=\s*["\'\d\[]'
225
+ # pytest special variables that must be lowercase
226
+ pytest_special_vars = ['pytest_plugins']
227
+
228
+ for line in content.split('\n'):
229
+ if not line.startswith(' ') and not line.startswith('\t'): # Module level
230
+ match = re.match(const_pattern, line)
231
+ if match and match.group(1).isupper():
232
+ # Good - already uppercase
233
+ pass
234
+ elif match and match.group(1) in pytest_special_vars:
235
+ # pytest special variable - must be lowercase
236
+ pass
237
+ elif match and '_' in match.group(1):
238
+ # Might be a constant with wrong case
239
+ violations.append(f"Constant '{match.group(1)}' should use UPPER_CASE")
240
+
241
+ return violations
242
+
243
+
244
+ @pytest.mark.coder
245
+ def test_maintainability_index_above_threshold():
246
+ """
247
+ SPEC-CODER-QUALITY-0001: Code has acceptable maintainability index.
248
+
249
+ Maintainability index measures:
250
+ - Code complexity
251
+ - Code size
252
+ - Documentation level
253
+
254
+ Threshold: > 60 (scale 0-100)
255
+
256
+ Given: All Python files
257
+ When: Calculating maintainability index
258
+ Then: Index > 60 for all files
259
+ """
260
+ python_files = find_python_files()
261
+
262
+ if not python_files:
263
+ pytest.skip("No Python files found")
264
+
265
+ violations = []
266
+
267
+ for py_file in python_files:
268
+ # Skip very small files
269
+ try:
270
+ with open(py_file, 'r', encoding='utf-8') as f:
271
+ lines = f.readlines()
272
+ if len(lines) < 10:
273
+ continue
274
+ except Exception:
275
+ continue
276
+
277
+ index = calculate_maintainability_index(py_file)
278
+
279
+ if index < MIN_MAINTAINABILITY_INDEX:
280
+ rel_path = py_file.relative_to(REPO_ROOT)
281
+ violations.append(
282
+ f"{rel_path}\\n"
283
+ f" Maintainability Index: {index:.1f} (min: {MIN_MAINTAINABILITY_INDEX})\\n"
284
+ f" Suggestion: Add comments, reduce complexity, or split file"
285
+ )
286
+
287
+ if violations:
288
+ pytest.fail(
289
+ f"\\n\\nFound {len(violations)} maintainability violations:\\n\\n" +
290
+ "\\n\\n".join(violations[:10]) +
291
+ (f"\\n\\n... and {len(violations) - 10} more" if len(violations) > 10 else "")
292
+ )
293
+
294
+
295
+ @pytest.mark.coder
296
+ def test_adequate_code_comments():
297
+ """
298
+ SPEC-CODER-QUALITY-0002: Code has adequate comments.
299
+
300
+ Well-commented code is easier to maintain.
301
+
302
+ Threshold: > 10% comment ratio
303
+
304
+ Given: All Python files
305
+ When: Calculating comment ratio
306
+ Then: At least 10% comments
307
+ """
308
+ python_files = find_python_files()
309
+
310
+ if not python_files:
311
+ pytest.skip("No Python files found")
312
+
313
+ violations = []
314
+
315
+ for py_file in python_files:
316
+ # Skip very small files
317
+ try:
318
+ with open(py_file, 'r', encoding='utf-8') as f:
319
+ lines = f.readlines()
320
+ if len(lines) < 20:
321
+ continue
322
+ except Exception:
323
+ continue
324
+
325
+ ratio = calculate_comment_ratio(py_file)
326
+
327
+ if ratio < MIN_COMMENT_RATIO:
328
+ rel_path = py_file.relative_to(REPO_ROOT)
329
+ violations.append(
330
+ f"{rel_path}\\n"
331
+ f" Comment ratio: {ratio*100:.1f}% (min: {MIN_COMMENT_RATIO*100:.0f}%)\\n"
332
+ f" Suggestion: Add docstrings and inline comments"
333
+ )
334
+
335
+ if violations:
336
+ pytest.fail(
337
+ f"\\n\\nFound {len(violations)} files with insufficient comments:\\n\\n" +
338
+ "\\n\\n".join(violations[:10]) +
339
+ (f"\\n\\n... and {len(violations) - 10} more" if len(violations) > 10 else "")
340
+ )
341
+
342
+
343
+ @pytest.mark.coder
344
+ def test_no_significant_code_duplication():
345
+ """
346
+ SPEC-CODER-QUALITY-0003: No significant code duplication.
347
+
348
+ Duplicate code should be extracted into functions.
349
+
350
+ Threshold: < 5 consecutive duplicate lines
351
+
352
+ Given: All Python files
353
+ When: Checking for duplicate code blocks
354
+ Then: No significant duplication found
355
+ """
356
+ python_files = find_python_files()
357
+
358
+ if not python_files:
359
+ pytest.skip("No Python files found")
360
+
361
+ # Limit to avoid long running time
362
+ sample_files = python_files[:50]
363
+
364
+ duplicates = find_duplicate_code_blocks(sample_files)
365
+
366
+ if duplicates:
367
+ violations = []
368
+ for file1, file2, block in duplicates[:10]:
369
+ violations.append(
370
+ f"{file1.relative_to(REPO_ROOT)} ↔ {file2.relative_to(REPO_ROOT)}\\n"
371
+ f" Duplicate block ({len(block)} lines):\\n" +
372
+ "\\n".join(f" {line[:60]}" for line in block[:3])
373
+ )
374
+
375
+ pytest.fail(
376
+ f"\\n\\nFound {len(duplicates)} code duplication instances:\\n\\n" +
377
+ "\\n\\n".join(violations) +
378
+ (f"\\n\\n... and {len(duplicates) - 10} more" if len(duplicates) > 10 else "") +
379
+ "\\n\\nConsider extracting duplicate code into shared functions."
380
+ )
381
+
382
+
383
+ @pytest.mark.coder
384
+ def test_consistent_naming_conventions():
385
+ """
386
+ SPEC-CODER-QUALITY-0004: Code follows consistent naming conventions.
387
+
388
+ Naming conventions:
389
+ - Classes: PascalCase
390
+ - Functions: snake_case
391
+ - Constants: UPPER_CASE
392
+ - Variables: snake_case
393
+
394
+ Given: All Python files
395
+ When: Checking naming patterns
396
+ Then: Consistent naming conventions
397
+ """
398
+ python_files = find_python_files()
399
+
400
+ if not python_files:
401
+ pytest.skip("No Python files found")
402
+
403
+ all_violations = []
404
+
405
+ for py_file in python_files:
406
+ violations = check_naming_consistency(py_file)
407
+
408
+ if violations:
409
+ rel_path = py_file.relative_to(REPO_ROOT)
410
+ all_violations.append(
411
+ f"{rel_path}\\n" +
412
+ "\\n".join(f" - {v}" for v in violations[:5])
413
+ )
414
+
415
+ if all_violations:
416
+ pytest.fail(
417
+ f"\\n\\nFound {len(all_violations)} files with naming violations:\\n\\n" +
418
+ "\\n\\n".join(all_violations[:10]) +
419
+ (f"\\n\\n... and {len(all_violations) - 10} more" if len(all_violations) > 10 else "")
420
+ )
@@ -0,0 +1,244 @@
1
+ """
2
+ Station Master Pattern Validator
3
+
4
+ Validates that wagons follow the Station Master pattern for monolith composition:
5
+ 1. composition.py accepts optional shared dependency parameters
6
+ 2. Direct adapters exist for cross-wagon data access
7
+ 3. game.py delegates to composition.py instead of duplicating wiring
8
+
9
+ Convention: atdd/coder/conventions/boundaries.convention.yaml::station_master_pattern
10
+ """
11
+
12
+ import ast
13
+ import os
14
+ from pathlib import Path
15
+ from typing import List, Dict, Any, Tuple
16
+
17
+
18
+ def get_python_dir() -> Path:
19
+ """Get the python directory path."""
20
+ return Path(__file__).parent.parent.parent.parent / "python"
21
+
22
+
23
+ def test_composition_accepts_shared_dependencies():
24
+ """
25
+ Validate that wagon composition.py files accept optional shared dependencies.
26
+
27
+ Convention: boundaries.convention.yaml::station_master_pattern.composition_function_signature
28
+
29
+ Expected pattern:
30
+ def wire_api_dependencies(
31
+ state_repository=None,
32
+ player_timebanks=None,
33
+ match_repository=None,
34
+ event_bus=None
35
+ ):
36
+ """
37
+ python_dir = get_python_dir()
38
+
39
+ # Find all composition.py files in wagon directories
40
+ composition_files = list(python_dir.glob("*/*/composition.py"))
41
+
42
+ # Track which compositions have wire_api_dependencies
43
+ wagons_with_wire_function: List[str] = []
44
+ wagons_missing_optional_params: List[Tuple[str, List[str]]] = []
45
+
46
+ for comp_file in composition_files:
47
+ wagon_name = comp_file.parent.parent.name
48
+
49
+ try:
50
+ source = comp_file.read_text()
51
+ tree = ast.parse(source)
52
+ except Exception as e:
53
+ continue # Skip files that can't be parsed
54
+
55
+ # Find wire_api_dependencies function
56
+ for node in ast.walk(tree):
57
+ if isinstance(node, ast.FunctionDef) and node.name == "wire_api_dependencies":
58
+ wagons_with_wire_function.append(wagon_name)
59
+
60
+ # Check if it has optional parameters (defaults)
61
+ # Parameters with defaults are in node.args.defaults
62
+ # kw_only args with defaults are in node.args.kw_defaults
63
+
64
+ # Get all argument names
65
+ arg_names = [arg.arg for arg in node.args.args]
66
+
67
+ # Get number of defaults (these apply to the LAST n arguments)
68
+ num_defaults = len(node.args.defaults)
69
+ num_args = len(arg_names)
70
+
71
+ # Arguments without defaults (required)
72
+ required_args = arg_names[:num_args - num_defaults] if num_defaults < num_args else []
73
+
74
+ # Recommended optional params for Station Master pattern
75
+ recommended_optional = ["state_repository", "player_timebanks", "match_repository", "event_bus"]
76
+
77
+ # Check if function has any optional parameters
78
+ optional_count = num_defaults + len([d for d in node.args.kw_defaults if d is not None])
79
+
80
+ if optional_count == 0 and num_args > 0:
81
+ # Function has only required args - doesn't follow pattern
82
+ wagons_missing_optional_params.append((wagon_name, required_args))
83
+
84
+ # Report results
85
+ print("\n" + "=" * 70)
86
+ print(" Station Master Pattern: Composition Dependencies")
87
+ print("=" * 70)
88
+ print(f"\nWagons with wire_api_dependencies(): {len(wagons_with_wire_function)}")
89
+ for wagon in wagons_with_wire_function:
90
+ print(f" ✓ {wagon}")
91
+
92
+ if wagons_missing_optional_params:
93
+ print(f"\n⚠️ Wagons missing optional shared dependency parameters:")
94
+ for wagon, required in wagons_missing_optional_params:
95
+ print(f" ❌ {wagon}: has only required params: {required}")
96
+ print("\n Recommendation: Add optional params like state_repository=None")
97
+
98
+ # This is a soft check - we want to encourage the pattern but not fail builds
99
+ # for wagons that don't need cross-wagon data
100
+ assert True, "Station Master pattern check completed (advisory)"
101
+
102
+
103
+ def test_direct_adapters_exist_for_cross_wagon_clients():
104
+ """
105
+ Validate that Direct adapters exist alongside HTTP clients for cross-wagon communication.
106
+
107
+ Convention: backend.convention.yaml::clients.adapter_variants.direct_adapter
108
+
109
+ Expected: If http_*_client.py exists, direct_*_client.py should also exist.
110
+ """
111
+ python_dir = get_python_dir()
112
+
113
+ # Find all client directories
114
+ client_dirs = list(python_dir.glob("*/*/src/integration/clients"))
115
+
116
+ http_without_direct: List[Tuple[str, str]] = []
117
+ direct_adapters_found: List[str] = []
118
+
119
+ for client_dir in client_dirs:
120
+ if not client_dir.is_dir():
121
+ continue
122
+
123
+ wagon_name = client_dir.parent.parent.parent.parent.name
124
+
125
+ # Find HTTP clients
126
+ http_clients = list(client_dir.glob("http_*_client.py"))
127
+
128
+ for http_client in http_clients:
129
+ # Extract the service name (e.g., "commit_state" from "http_commit_state_client.py")
130
+ http_name = http_client.stem # http_commit_state_client
131
+ service_name = http_name.replace("http_", "").replace("_client", "")
132
+
133
+ # Check for corresponding direct adapter
134
+ direct_name = f"direct_{service_name}_client.py"
135
+ direct_path = client_dir / direct_name
136
+
137
+ if direct_path.exists():
138
+ direct_adapters_found.append(f"{wagon_name}/{direct_name}")
139
+ else:
140
+ http_without_direct.append((wagon_name, http_client.name))
141
+
142
+ # Report results
143
+ print("\n" + "=" * 70)
144
+ print(" Station Master Pattern: Direct Adapters")
145
+ print("=" * 70)
146
+ print(f"\nDirect adapters found: {len(direct_adapters_found)}")
147
+ for adapter in direct_adapters_found:
148
+ print(f" ✓ {adapter}")
149
+
150
+ if http_without_direct:
151
+ print(f"\n⚠️ HTTP clients without corresponding Direct adapters:")
152
+ for wagon, http_file in http_without_direct:
153
+ print(f" ⚠️ {wagon}/{http_file} → missing direct_*_client.py")
154
+ print("\n Note: Direct adapters enable monolith mode without HTTP self-calls")
155
+
156
+ # Advisory check - not all HTTP clients need Direct adapters
157
+ assert True, "Direct adapter check completed (advisory)"
158
+
159
+
160
+ def test_game_py_delegates_to_composition():
161
+ """
162
+ Validate that game.py delegates wiring to wagon composition.py files
163
+ instead of duplicating wiring logic.
164
+
165
+ Convention: boundaries.convention.yaml::station_master_pattern.station_master_responsibilities
166
+
167
+ Forbidden patterns in game.py:
168
+ - Creating use cases that composition.py should own
169
+ - Directly instantiating wagon clients without delegation
170
+
171
+ Expected patterns:
172
+ - from wagon.composition import wire_api_dependencies
173
+ - wire_api_dependencies(state_repository=..., ...)
174
+ """
175
+ python_dir = get_python_dir()
176
+ game_py = python_dir / "game.py"
177
+
178
+ if not game_py.exists():
179
+ print("game.py not found - skipping Station Master delegation check")
180
+ return
181
+
182
+ source = game_py.read_text()
183
+
184
+ # Check for composition imports
185
+ imports_composition = "from play_match.orchestrate_match.composition import wire_api_dependencies" in source
186
+
187
+ # Check for delegation calls
188
+ calls_wire_api = "wire_api_dependencies(" in source
189
+
190
+ # Check for forbidden patterns (duplicated wiring)
191
+ # These are patterns that should be in composition.py, not game.py
192
+ forbidden_patterns = [
193
+ ("PlayMatchUseCase(", "PlayMatchUseCase should be created in composition.py"),
194
+ ("CommitStateClient(mode=", "CommitStateClient mode should be set in composition.py"),
195
+ ("set_play_match_use_case(PlayMatchUseCase", "Use case creation should be in composition.py"),
196
+ ]
197
+
198
+ violations: List[Tuple[str, str]] = []
199
+ for pattern, message in forbidden_patterns:
200
+ if pattern in source:
201
+ violations.append((pattern, message))
202
+
203
+ # Report results
204
+ print("\n" + "=" * 70)
205
+ print(" Station Master Pattern: game.py Delegation")
206
+ print("=" * 70)
207
+
208
+ print(f"\nDelegation to composition.py:")
209
+ print(f" {'✓' if imports_composition else '❌'} Imports wire_api_dependencies from composition")
210
+ print(f" {'✓' if calls_wire_api else '❌'} Calls wire_api_dependencies()")
211
+
212
+ if violations:
213
+ print(f"\n❌ Violations found in game.py:")
214
+ for pattern, message in violations:
215
+ print(f" ❌ {message}")
216
+ print(f" Found: {pattern}")
217
+
218
+ # This is a real validation
219
+ assert imports_composition or not calls_wire_api, \
220
+ "game.py should import wire_api_dependencies from composition.py"
221
+
222
+ assert len(violations) == 0, \
223
+ f"game.py has {len(violations)} Station Master pattern violations"
224
+
225
+ print("\n✓ game.py follows Station Master pattern")
226
+
227
+
228
+ def main():
229
+ """Run all Station Master pattern validators."""
230
+ print("\n" + "=" * 70)
231
+ print(" STATION MASTER PATTERN VALIDATION")
232
+ print("=" * 70)
233
+
234
+ test_composition_accepts_shared_dependencies()
235
+ test_direct_adapters_exist_for_cross_wagon_clients()
236
+ test_game_py_delegates_to_composition()
237
+
238
+ print("\n" + "=" * 70)
239
+ print(" ✓ All Station Master pattern checks passed")
240
+ print("=" * 70 + "\n")
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()