atdd 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. atdd/__init__.py +6 -0
  2. atdd/__main__.py +4 -0
  3. atdd/cli.py +404 -0
  4. atdd/coach/__init__.py +0 -0
  5. atdd/coach/commands/__init__.py +0 -0
  6. atdd/coach/commands/add_persistence_metadata.py +215 -0
  7. atdd/coach/commands/analyze_migrations.py +188 -0
  8. atdd/coach/commands/consumers.py +720 -0
  9. atdd/coach/commands/infer_governance_status.py +149 -0
  10. atdd/coach/commands/initializer.py +177 -0
  11. atdd/coach/commands/interface.py +1078 -0
  12. atdd/coach/commands/inventory.py +565 -0
  13. atdd/coach/commands/migration.py +240 -0
  14. atdd/coach/commands/registry.py +1560 -0
  15. atdd/coach/commands/session.py +430 -0
  16. atdd/coach/commands/sync.py +405 -0
  17. atdd/coach/commands/test_interface.py +399 -0
  18. atdd/coach/commands/test_runner.py +141 -0
  19. atdd/coach/commands/tests/__init__.py +1 -0
  20. atdd/coach/commands/tests/test_telemetry_array_validation.py +235 -0
  21. atdd/coach/commands/traceability.py +4264 -0
  22. atdd/coach/conventions/session.convention.yaml +754 -0
  23. atdd/coach/overlays/__init__.py +2 -0
  24. atdd/coach/overlays/claude.md +2 -0
  25. atdd/coach/schemas/config.schema.json +34 -0
  26. atdd/coach/schemas/manifest.schema.json +101 -0
  27. atdd/coach/templates/ATDD.md +282 -0
  28. atdd/coach/templates/SESSION-TEMPLATE.md +327 -0
  29. atdd/coach/utils/__init__.py +0 -0
  30. atdd/coach/utils/graph/__init__.py +0 -0
  31. atdd/coach/utils/graph/urn.py +875 -0
  32. atdd/coach/validators/__init__.py +0 -0
  33. atdd/coach/validators/shared_fixtures.py +365 -0
  34. atdd/coach/validators/test_enrich_wagon_registry.py +167 -0
  35. atdd/coach/validators/test_registry.py +575 -0
  36. atdd/coach/validators/test_session_validation.py +1183 -0
  37. atdd/coach/validators/test_traceability.py +448 -0
  38. atdd/coach/validators/test_update_feature_paths.py +108 -0
  39. atdd/coach/validators/test_validate_contract_consumers.py +297 -0
  40. atdd/coder/__init__.py +1 -0
  41. atdd/coder/conventions/adapter.recipe.yaml +88 -0
  42. atdd/coder/conventions/backend.convention.yaml +460 -0
  43. atdd/coder/conventions/boundaries.convention.yaml +666 -0
  44. atdd/coder/conventions/commons.convention.yaml +460 -0
  45. atdd/coder/conventions/complexity.recipe.yaml +109 -0
  46. atdd/coder/conventions/component-naming.convention.yaml +178 -0
  47. atdd/coder/conventions/design.convention.yaml +327 -0
  48. atdd/coder/conventions/design.recipe.yaml +273 -0
  49. atdd/coder/conventions/dto.convention.yaml +660 -0
  50. atdd/coder/conventions/frontend.convention.yaml +542 -0
  51. atdd/coder/conventions/green.convention.yaml +1012 -0
  52. atdd/coder/conventions/presentation.convention.yaml +587 -0
  53. atdd/coder/conventions/refactor.convention.yaml +535 -0
  54. atdd/coder/conventions/technology.convention.yaml +206 -0
  55. atdd/coder/conventions/tests/__init__.py +0 -0
  56. atdd/coder/conventions/tests/test_adapter_recipe.py +302 -0
  57. atdd/coder/conventions/tests/test_complexity_recipe.py +289 -0
  58. atdd/coder/conventions/tests/test_component_taxonomy.py +278 -0
  59. atdd/coder/conventions/tests/test_component_urn_naming.py +165 -0
  60. atdd/coder/conventions/tests/test_thinness_recipe.py +286 -0
  61. atdd/coder/conventions/thinness.recipe.yaml +82 -0
  62. atdd/coder/conventions/train.convention.yaml +325 -0
  63. atdd/coder/conventions/verification.protocol.yaml +53 -0
  64. atdd/coder/schemas/design_system.schema.json +361 -0
  65. atdd/coder/validators/__init__.py +0 -0
  66. atdd/coder/validators/test_commons_structure.py +485 -0
  67. atdd/coder/validators/test_complexity.py +416 -0
  68. atdd/coder/validators/test_cross_language_consistency.py +431 -0
  69. atdd/coder/validators/test_design_system_compliance.py +413 -0
  70. atdd/coder/validators/test_dto_testing_patterns.py +268 -0
  71. atdd/coder/validators/test_green_cross_stack_layers.py +168 -0
  72. atdd/coder/validators/test_green_layer_dependencies.py +148 -0
  73. atdd/coder/validators/test_green_python_layer_structure.py +103 -0
  74. atdd/coder/validators/test_green_supabase_layer_structure.py +103 -0
  75. atdd/coder/validators/test_import_boundaries.py +396 -0
  76. atdd/coder/validators/test_init_file_urns.py +593 -0
  77. atdd/coder/validators/test_preact_layer_boundaries.py +221 -0
  78. atdd/coder/validators/test_presentation_convention.py +260 -0
  79. atdd/coder/validators/test_python_architecture.py +674 -0
  80. atdd/coder/validators/test_quality_metrics.py +420 -0
  81. atdd/coder/validators/test_station_master_pattern.py +244 -0
  82. atdd/coder/validators/test_train_infrastructure.py +454 -0
  83. atdd/coder/validators/test_train_urns.py +293 -0
  84. atdd/coder/validators/test_typescript_architecture.py +616 -0
  85. atdd/coder/validators/test_usecase_structure.py +421 -0
  86. atdd/coder/validators/test_wagon_boundaries.py +586 -0
  87. atdd/conftest.py +126 -0
  88. atdd/planner/__init__.py +1 -0
  89. atdd/planner/conventions/acceptance.convention.yaml +538 -0
  90. atdd/planner/conventions/appendix.convention.yaml +187 -0
  91. atdd/planner/conventions/artifact-naming.convention.yaml +852 -0
  92. atdd/planner/conventions/component.convention.yaml +670 -0
  93. atdd/planner/conventions/criteria.convention.yaml +141 -0
  94. atdd/planner/conventions/feature.convention.yaml +371 -0
  95. atdd/planner/conventions/interface.convention.yaml +382 -0
  96. atdd/planner/conventions/steps.convention.yaml +141 -0
  97. atdd/planner/conventions/train.convention.yaml +552 -0
  98. atdd/planner/conventions/wagon.convention.yaml +275 -0
  99. atdd/planner/conventions/wmbt.convention.yaml +258 -0
  100. atdd/planner/schemas/acceptance.schema.json +336 -0
  101. atdd/planner/schemas/appendix.schema.json +78 -0
  102. atdd/planner/schemas/component.schema.json +114 -0
  103. atdd/planner/schemas/feature.schema.json +197 -0
  104. atdd/planner/schemas/train.schema.json +192 -0
  105. atdd/planner/schemas/wagon.schema.json +281 -0
  106. atdd/planner/schemas/wmbt.schema.json +59 -0
  107. atdd/planner/validators/__init__.py +0 -0
  108. atdd/planner/validators/conftest.py +5 -0
  109. atdd/planner/validators/test_draft_wagon_registry.py +374 -0
  110. atdd/planner/validators/test_plan_cross_refs.py +240 -0
  111. atdd/planner/validators/test_plan_uniqueness.py +224 -0
  112. atdd/planner/validators/test_plan_urn_resolution.py +268 -0
  113. atdd/planner/validators/test_plan_wagons.py +174 -0
  114. atdd/planner/validators/test_train_validation.py +514 -0
  115. atdd/planner/validators/test_wagon_urn_chain.py +648 -0
  116. atdd/planner/validators/test_wmbt_consistency.py +327 -0
  117. atdd/planner/validators/test_wmbt_vocabulary.py +632 -0
  118. atdd/tester/__init__.py +1 -0
  119. atdd/tester/conventions/artifact.convention.yaml +257 -0
  120. atdd/tester/conventions/contract.convention.yaml +1009 -0
  121. atdd/tester/conventions/filename.convention.yaml +555 -0
  122. atdd/tester/conventions/migration.convention.yaml +509 -0
  123. atdd/tester/conventions/red.convention.yaml +797 -0
  124. atdd/tester/conventions/routing.convention.yaml +51 -0
  125. atdd/tester/conventions/telemetry.convention.yaml +458 -0
  126. atdd/tester/schemas/a11y.tmpl.json +17 -0
  127. atdd/tester/schemas/artifact.schema.json +189 -0
  128. atdd/tester/schemas/contract.schema.json +591 -0
  129. atdd/tester/schemas/contract.tmpl.json +95 -0
  130. atdd/tester/schemas/db.tmpl.json +20 -0
  131. atdd/tester/schemas/e2e.tmpl.json +17 -0
  132. atdd/tester/schemas/edge_function.tmpl.json +17 -0
  133. atdd/tester/schemas/event.tmpl.json +17 -0
  134. atdd/tester/schemas/http.tmpl.json +19 -0
  135. atdd/tester/schemas/job.tmpl.json +18 -0
  136. atdd/tester/schemas/load.tmpl.json +21 -0
  137. atdd/tester/schemas/metric.tmpl.json +19 -0
  138. atdd/tester/schemas/pack.schema.json +139 -0
  139. atdd/tester/schemas/realtime.tmpl.json +20 -0
  140. atdd/tester/schemas/rls.tmpl.json +18 -0
  141. atdd/tester/schemas/script.tmpl.json +16 -0
  142. atdd/tester/schemas/sec.tmpl.json +18 -0
  143. atdd/tester/schemas/storage.tmpl.json +18 -0
  144. atdd/tester/schemas/telemetry.schema.json +128 -0
  145. atdd/tester/schemas/telemetry_tracking_manifest.schema.json +143 -0
  146. atdd/tester/schemas/test_filename.schema.json +194 -0
  147. atdd/tester/schemas/test_intent.schema.json +179 -0
  148. atdd/tester/schemas/unit.tmpl.json +18 -0
  149. atdd/tester/schemas/visual.tmpl.json +18 -0
  150. atdd/tester/schemas/ws.tmpl.json +17 -0
  151. atdd/tester/utils/__init__.py +0 -0
  152. atdd/tester/utils/filename.py +300 -0
  153. atdd/tester/validators/__init__.py +0 -0
  154. atdd/tester/validators/cleanup_duplicate_headers.py +116 -0
  155. atdd/tester/validators/cleanup_duplicate_headers_v2.py +135 -0
  156. atdd/tester/validators/conftest.py +5 -0
  157. atdd/tester/validators/coverage_gap_report.py +321 -0
  158. atdd/tester/validators/fix_dual_ac_references.py +179 -0
  159. atdd/tester/validators/remove_duplicate_lines.py +93 -0
  160. atdd/tester/validators/test_acceptance_urn_filename_mapping.py +359 -0
  161. atdd/tester/validators/test_acceptance_urn_separator.py +166 -0
  162. atdd/tester/validators/test_artifact_naming_category.py +307 -0
  163. atdd/tester/validators/test_contract_schema_compliance.py +706 -0
  164. atdd/tester/validators/test_contracts_structure.py +200 -0
  165. atdd/tester/validators/test_coverage_adequacy.py +797 -0
  166. atdd/tester/validators/test_dual_ac_reference.py +225 -0
  167. atdd/tester/validators/test_fixture_validity.py +372 -0
  168. atdd/tester/validators/test_isolation.py +487 -0
  169. atdd/tester/validators/test_migration_coverage.py +204 -0
  170. atdd/tester/validators/test_migration_criteria.py +276 -0
  171. atdd/tester/validators/test_migration_generation.py +116 -0
  172. atdd/tester/validators/test_python_test_naming.py +410 -0
  173. atdd/tester/validators/test_red_layer_validation.py +95 -0
  174. atdd/tester/validators/test_red_python_layer_structure.py +87 -0
  175. atdd/tester/validators/test_red_supabase_layer_structure.py +90 -0
  176. atdd/tester/validators/test_telemetry_structure.py +634 -0
  177. atdd/tester/validators/test_typescript_test_naming.py +301 -0
  178. atdd/tester/validators/test_typescript_test_structure.py +84 -0
  179. atdd-0.2.1.dist-info/METADATA +221 -0
  180. atdd-0.2.1.dist-info/RECORD +184 -0
  181. atdd-0.2.1.dist-info/WHEEL +5 -0
  182. atdd-0.2.1.dist-info/entry_points.txt +2 -0
  183. atdd-0.2.1.dist-info/licenses/LICENSE +674 -0
  184. atdd-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,487 @@
1
+ """
2
+ Test that tests don't interfere with each other.
3
+
4
+ Validates:
5
+ - Tests don't share mutable state
6
+ - Fixtures are properly scoped
7
+ - Tests can run in parallel safely
8
+ - No test pollution (side effects)
9
+
10
+ Inspired by: .claude/utils/tester/isolation.py
11
+ But: Self-contained, no utility dependencies
12
+ """
13
+
14
+ import pytest
15
+ import re
16
+ import ast
17
+ from pathlib import Path
18
+ from typing import List, Set, Tuple
19
+
20
+
21
+ # Path constants
22
+ REPO_ROOT = Path(__file__).resolve().parents[4]
23
+ TEST_DIRS = [
24
+ REPO_ROOT / "test",
25
+ REPO_ROOT / "tests",
26
+ REPO_ROOT / "atdd",
27
+ ]
28
+
29
+
30
+ def find_test_files() -> List[Path]:
31
+ """
32
+ Find all test files.
33
+
34
+ Returns:
35
+ List of Path objects
36
+ """
37
+ test_files = []
38
+
39
+ for test_dir in TEST_DIRS:
40
+ if not test_dir.exists():
41
+ continue
42
+
43
+ # Python tests
44
+ for py_test in test_dir.rglob("test_*.py"):
45
+ if '__pycache__' not in str(py_test):
46
+ test_files.append(py_test)
47
+
48
+ for py_test in test_dir.rglob("*_test.py"):
49
+ if '__pycache__' not in str(py_test):
50
+ test_files.append(py_test)
51
+
52
+ return test_files
53
+
54
+
55
+ def extract_global_variables(file_path: Path) -> List[str]:
56
+ """
57
+ Extract global variable assignments from Python test file.
58
+
59
+ Args:
60
+ file_path: Path to test file
61
+
62
+ Returns:
63
+ List of global variable names
64
+ """
65
+ try:
66
+ with open(file_path, 'r', encoding='utf-8') as f:
67
+ content = f.read()
68
+ except Exception:
69
+ return []
70
+
71
+ try:
72
+ tree = ast.parse(content)
73
+ except SyntaxError:
74
+ return []
75
+
76
+ globals_list = []
77
+
78
+ for node in ast.walk(tree):
79
+ # Top-level assignments
80
+ if isinstance(node, ast.Assign):
81
+ for target in node.targets:
82
+ if isinstance(target, ast.Name):
83
+ name = target.id
84
+ # Skip constants (UPPER_CASE)
85
+ if not name.isupper():
86
+ # Skip if it's in a function/class
87
+ for parent in ast.walk(tree):
88
+ if isinstance(parent, (ast.FunctionDef, ast.ClassDef)):
89
+ if node in ast.walk(parent):
90
+ break
91
+ else:
92
+ # It's a top-level mutable global
93
+ globals_list.append(name)
94
+
95
+ return globals_list
96
+
97
+
98
+ def extract_fixture_scopes(file_path: Path) -> List[Tuple[str, str]]:
99
+ """
100
+ Extract pytest fixtures and their scopes.
101
+
102
+ Args:
103
+ file_path: Path to test file
104
+
105
+ Returns:
106
+ List of (fixture_name, scope) tuples
107
+ """
108
+ try:
109
+ with open(file_path, 'r', encoding='utf-8') as f:
110
+ content = f.read()
111
+ except Exception:
112
+ return []
113
+
114
+ fixtures = []
115
+
116
+ # Match @pytest.fixture or @pytest.fixture(scope="...")
117
+ fixture_pattern = r'@pytest\.fixture(?:\(scope=["\'](\w+)["\']\))?'
118
+
119
+ lines = content.split('\n')
120
+ for i, line in enumerate(lines):
121
+ match = re.search(fixture_pattern, line)
122
+ if match:
123
+ scope = match.group(1) or 'function' # Default scope is 'function'
124
+
125
+ # Get function name from next lines
126
+ for j in range(i+1, min(i+5, len(lines))):
127
+ func_match = re.match(r'\s*def\s+(\w+)\s*\(', lines[j])
128
+ if func_match:
129
+ fixture_name = func_match.group(1)
130
+ fixtures.append((fixture_name, scope))
131
+ break
132
+
133
+ return fixtures
134
+
135
+
136
+ def check_for_file_mutations(file_path: Path) -> List[str]:
137
+ """
138
+ Check if test file mutates global state (files, environment, etc.).
139
+
140
+ Uses AST to detect actual code mutations, avoiding false positives
141
+ from patterns in strings/comments/docstrings.
142
+
143
+ Skips file write/delete checks if test functions use tmp_path fixture.
144
+
145
+ Args:
146
+ file_path: Path to test file
147
+
148
+ Returns:
149
+ List of mutation violations
150
+ """
151
+ try:
152
+ with open(file_path, 'r', encoding='utf-8') as f:
153
+ content = f.read()
154
+ except Exception:
155
+ return []
156
+
157
+ try:
158
+ tree = ast.parse(content)
159
+ except SyntaxError:
160
+ return []
161
+
162
+ violations = set()
163
+
164
+ # Check if any test function uses tmp_path or similar temp directory fixtures
165
+ # Common fixture names that indicate temp directory usage
166
+ TEMP_FIXTURES = {'tmp_path', 'tmp_path_factory', 'temp_repo', 'temp_dir', 'tmpdir'}
167
+ uses_tmp_path = False
168
+ for node in ast.walk(tree):
169
+ if isinstance(node, ast.FunctionDef) and node.name.startswith('test_'):
170
+ for arg in node.args.args:
171
+ if arg.arg in TEMP_FIXTURES:
172
+ uses_tmp_path = True
173
+ break
174
+ if uses_tmp_path:
175
+ break
176
+
177
+ for node in ast.walk(tree):
178
+ # Check for sys.path.insert/append calls
179
+ if isinstance(node, ast.Call):
180
+ if isinstance(node.func, ast.Attribute):
181
+ # sys.path.insert(...) or sys.path.append(...)
182
+ if node.func.attr in ('insert', 'append'):
183
+ if isinstance(node.func.value, ast.Attribute):
184
+ if (node.func.value.attr == 'path' and
185
+ isinstance(node.func.value.value, ast.Name) and
186
+ node.func.value.value.id == 'sys'):
187
+ violations.add('sys.path mutation (use monkeypatch fixture)')
188
+
189
+ # os.remove(...) or shutil.rmtree(...) - skip if using tmp_path
190
+ if not uses_tmp_path:
191
+ if node.func.attr == 'remove':
192
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == 'os':
193
+ violations.add('File deletion without fixture (should use tmp_path)')
194
+ if node.func.attr == 'rmtree':
195
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == 'shutil':
196
+ violations.add('File deletion without fixture (should use tmp_path)')
197
+
198
+ # Check for os.environ[...] = ... (subscript assignment)
199
+ if isinstance(node, ast.Assign):
200
+ for target in node.targets:
201
+ if isinstance(target, ast.Subscript):
202
+ if isinstance(target.value, ast.Attribute):
203
+ if (target.value.attr == 'environ' and
204
+ isinstance(target.value.value, ast.Name) and
205
+ target.value.value.id == 'os'):
206
+ violations.add('Direct os.environ mutation (use monkeypatch fixture)')
207
+
208
+ # Check for open(..., 'w') - skip if using tmp_path fixture
209
+ if not uses_tmp_path:
210
+ if isinstance(node, ast.Call):
211
+ if isinstance(node.func, ast.Name) and node.func.id == 'open':
212
+ for arg in node.args:
213
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
214
+ if 'w' in arg.value:
215
+ violations.add('File write without fixture (should use tmp_path)')
216
+ for kw in node.keywords:
217
+ if kw.arg == 'mode' and isinstance(kw.value, ast.Constant):
218
+ if 'w' in str(kw.value.value):
219
+ violations.add('File write without fixture (should use tmp_path)')
220
+
221
+ return list(violations)
222
+
223
+
224
+ def check_for_shared_state(file_path: Path) -> List[str]:
225
+ """
226
+ Check for mutable shared state between tests.
227
+
228
+ Args:
229
+ file_path: Path to test file
230
+
231
+ Returns:
232
+ List of shared state violations
233
+ """
234
+ globals_vars = extract_global_variables(file_path)
235
+
236
+ violations = []
237
+
238
+ # Filter out common test globals that are OK
239
+ ALLOWED_GLOBALS = {
240
+ 'pytestmark', # Pytest markers
241
+ 'REPO_ROOT', # Path constants
242
+ 'TEST_DIR',
243
+ 'PROJECT_ROOT',
244
+ }
245
+
246
+ for var_name in globals_vars:
247
+ if var_name not in ALLOWED_GLOBALS and not var_name.startswith('_'):
248
+ violations.append(
249
+ f"Mutable global variable '{var_name}' (use fixture instead)"
250
+ )
251
+
252
+ return violations
253
+
254
+
255
+ @pytest.mark.tester
256
+ def test_no_mutable_global_state():
257
+ """
258
+ SPEC-TESTER-ISOLATION-0001: Tests don't use mutable global state.
259
+
260
+ Tests should not share mutable state via global variables.
261
+ Use fixtures for shared setup instead.
262
+
263
+ Violations:
264
+ - Global mutable variables (lists, dicts, objects)
265
+ - Module-level state that can be modified
266
+
267
+ OK:
268
+ - Constants (UPPER_CASE)
269
+ - Path constants (REPO_ROOT, etc.)
270
+ - Pytest markers (pytestmark)
271
+
272
+ Given: Test files in test/, tests/, atdd/
273
+ When: Checking for global variables
274
+ Then: No mutable global state
275
+ """
276
+ test_files = find_test_files()
277
+
278
+ if not test_files:
279
+ pytest.skip("No test files found to validate")
280
+
281
+ violations = []
282
+
283
+ for test_file in test_files:
284
+ shared_state_violations = check_for_shared_state(test_file)
285
+
286
+ if shared_state_violations:
287
+ rel_path = test_file.relative_to(REPO_ROOT)
288
+ for violation in shared_state_violations:
289
+ violations.append(
290
+ f"{rel_path}\n"
291
+ f" Issue: {violation}"
292
+ )
293
+
294
+ if violations:
295
+ pytest.fail(
296
+ f"\n\nFound {len(violations)} mutable global state violations:\n\n" +
297
+ "\n\n".join(violations[:10]) +
298
+ (f"\n\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
299
+ f"\n\nTests should not use mutable global variables.\n" +
300
+ f"Use pytest fixtures for shared setup/teardown."
301
+ )
302
+
303
+
304
+ @pytest.mark.tester
305
+ def test_fixtures_have_appropriate_scope():
306
+ """
307
+ SPEC-TESTER-ISOLATION-0002: Fixtures have appropriate scope.
308
+
309
+ Fixture scopes:
310
+ - function: Default, runs for each test (isolated)
311
+ - class: Shared within test class
312
+ - module: Shared within module
313
+ - session: Shared across entire test session
314
+
315
+ Best practices:
316
+ - Use 'function' scope by default (isolation)
317
+ - Use broader scopes only for expensive, read-only resources
318
+ - Never use session/module scope for mutable fixtures
319
+
320
+ Given: Fixtures in test files
321
+ When: Checking fixture scopes
322
+ Then: Appropriate scopes for isolation
323
+ """
324
+ test_files = find_test_files()
325
+
326
+ if not test_files:
327
+ pytest.skip("No test files found to validate")
328
+
329
+ violations = []
330
+
331
+ # Fixtures that should typically be function-scoped
332
+ SHOULD_BE_FUNCTION_SCOPED = {
333
+ 'mock', 'mocker', 'patch', 'temp', 'tmp', 'data', 'user', 'session', 'state'
334
+ }
335
+
336
+ for test_file in test_files:
337
+ fixtures = extract_fixture_scopes(test_file)
338
+
339
+ for fixture_name, scope in fixtures:
340
+ # Check if fixture name suggests it should be function-scoped
341
+ name_lower = fixture_name.lower()
342
+
343
+ for keyword in SHOULD_BE_FUNCTION_SCOPED:
344
+ if keyword in name_lower and scope != 'function':
345
+ rel_path = test_file.relative_to(REPO_ROOT)
346
+ violations.append(
347
+ f"{rel_path}\n"
348
+ f" Fixture: {fixture_name}\n"
349
+ f" Current Scope: {scope}\n"
350
+ f" Issue: Fixture name suggests mutable state, should use 'function' scope"
351
+ )
352
+
353
+ if violations:
354
+ pytest.fail(
355
+ f"\n\nFound {len(violations)} fixture scope violations:\n\n" +
356
+ "\n\n".join(violations[:10]) +
357
+ (f"\n\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
358
+ f"\n\nFixtures with mutable state should use 'function' scope.\n" +
359
+ f"Only use broader scopes for expensive, immutable resources."
360
+ )
361
+
362
+
363
+ @pytest.mark.tester
364
+ def test_no_direct_environment_mutations():
365
+ """
366
+ SPEC-TESTER-ISOLATION-0003: Tests don't directly mutate environment.
367
+
368
+ Tests should not directly mutate:
369
+ - Environment variables (use monkeypatch)
370
+ - System paths (use monkeypatch)
371
+ - File system (use tmp_path or tmp_dir fixtures)
372
+ - Global config (use fixtures)
373
+
374
+ Given: Test files
375
+ When: Checking for direct mutations
376
+ Then: All mutations use proper fixtures
377
+ """
378
+ # Auto-fix validators that intentionally modify repo files
379
+ AUTOFIX_VALIDATORS = {
380
+ 'test_init_file_urns.py', # Auto-fixes URN headers in __init__.py files
381
+ }
382
+
383
+ test_files = find_test_files()
384
+
385
+ if not test_files:
386
+ pytest.skip("No test files found to validate")
387
+
388
+ violations = []
389
+
390
+ for test_file in test_files:
391
+ # Skip auto-fix validators (they intentionally write to repo files)
392
+ if test_file.name in AUTOFIX_VALIDATORS:
393
+ continue
394
+
395
+ mutation_violations = check_for_file_mutations(test_file)
396
+
397
+ if mutation_violations:
398
+ rel_path = test_file.relative_to(REPO_ROOT)
399
+ for violation in mutation_violations:
400
+ violations.append(
401
+ f"{rel_path}\n"
402
+ f" Issue: {violation}"
403
+ )
404
+
405
+ if violations:
406
+ pytest.fail(
407
+ f"\n\nFound {len(violations)} environment mutation violations:\n\n" +
408
+ "\n\n".join(violations[:10]) +
409
+ (f"\n\n... and {len(violations) - 10} more" if len(violations) > 10 else "") +
410
+ f"\n\nTests should use fixtures for environment mutations:\n" +
411
+ f" - Use monkeypatch for os.environ, sys.path\n" +
412
+ f" - Use tmp_path for file operations\n" +
413
+ f" - Use fixtures for cleanup"
414
+ )
415
+
416
+
417
+ @pytest.mark.tester
418
+ def test_tests_can_run_in_parallel():
419
+ """
420
+ SPEC-TESTER-ISOLATION-0004: Tests can safely run in parallel.
421
+
422
+ Tests should be parallelizable unless explicitly marked.
423
+ Use pytest.mark.serial for tests that must run sequentially.
424
+
425
+ Indicators of parallel-safe tests:
426
+ - No shared mutable state
427
+ - No file system mutations (or use tmp_path)
428
+ - No environment mutations (or use monkeypatch)
429
+ - No timing dependencies
430
+
431
+ Given: Test files
432
+ When: Checking for parallel safety
433
+ Then: Tests marked appropriately for parallelism
434
+ """
435
+ test_files = find_test_files()
436
+
437
+ if not test_files:
438
+ pytest.skip("No test files found to validate")
439
+
440
+ violations = []
441
+
442
+ for test_file in test_files:
443
+ # Check if file has pytestmark = pytest.mark.serial
444
+ try:
445
+ with open(test_file, 'r', encoding='utf-8') as f:
446
+ content = f.read()
447
+ except Exception:
448
+ continue
449
+
450
+ has_serial_marker = 'pytest.mark.serial' in content
451
+
452
+ # Check for parallelism violations
453
+ parallel_violations = []
454
+
455
+ # Check for shared state
456
+ shared_state = check_for_shared_state(test_file)
457
+ if shared_state:
458
+ parallel_violations.extend(shared_state)
459
+
460
+ # Check for file mutations
461
+ mutations = check_for_file_mutations(test_file)
462
+ if mutations:
463
+ # File mutations are OK if using tmp_path, but flag others
464
+ dangerous_mutations = [m for m in mutations if 'tmp_path' not in m]
465
+ if dangerous_mutations:
466
+ parallel_violations.extend(dangerous_mutations)
467
+
468
+ # If has violations but no serial marker
469
+ if parallel_violations and not has_serial_marker:
470
+ rel_path = test_file.relative_to(REPO_ROOT)
471
+ violations.append(
472
+ f"{rel_path}\n"
473
+ f" Issues: {len(parallel_violations)} parallel-safety violations\n"
474
+ f" Violations:\n" +
475
+ "\n".join(f" - {v}" for v in parallel_violations[:3]) +
476
+ f" Suggestion: Add 'pytestmark = pytest.mark.serial' if tests must run sequentially"
477
+ )
478
+
479
+ if violations:
480
+ pytest.fail(
481
+ f"\n\nFound {len(violations)} parallelism violations:\n\n" +
482
+ "\n\n".join(violations[:5]) +
483
+ (f"\n\n... and {len(violations) - 5} more" if len(violations) > 5 else "") +
484
+ f"\n\nTests should either:\n" +
485
+ f" 1. Be parallel-safe (no shared state, use fixtures)\n" +
486
+ f" 2. Be marked with pytest.mark.serial"
487
+ )
@@ -0,0 +1,204 @@
1
+ """
2
+ Platform tests: Migration coverage validation.
3
+
4
+ SPEC-TESTER-CONV-0031: Validate all contracts have migrations
5
+ SPEC-TESTER-CONV-0032: Reject migrations with unresolved TODOs
6
+ """
7
+ import pytest
8
+ from pathlib import Path
9
+
10
+ # Path constants
11
+ REPO_ROOT = Path(__file__).resolve().parents[4]
12
+ CONTRACTS_DIR = REPO_ROOT / "contracts"
13
+ MIGRATIONS_DIR = REPO_ROOT / "supabase" / "migrations"
14
+
15
+
16
+ def contract_needs_migration(contract_path: Path) -> bool:
17
+ """
18
+ Check if contract needs database migration.
19
+
20
+ Mirrors logic from atdd/coach/commands/migration.py for consistency.
21
+
22
+ Decision algorithm (ordered rules, first match wins):
23
+ 1. Explicit persistence.strategy: check if != 'none'
24
+ 2. Empty properties: len(properties) == 0 → NO
25
+ 3. Event without id: aspect ends with '*ed' AND no 'id' → NO
26
+ 4. Internal only: metadata.to == 'internal' → NO
27
+ 5. Entity with id: 'id' in properties → YES
28
+ 6. Computed without id: description contains compute keywords AND no 'id' → NO
29
+ 7. Conservative default: metadata.to == 'external' AND has properties → YES
30
+ 8. Fallback: NO
31
+ """
32
+ try:
33
+ import json
34
+ with open(contract_path, 'r') as f:
35
+ contract = json.load(f)
36
+
37
+ metadata = contract.get("x-artifact-metadata", {})
38
+ properties = contract.get("properties", {})
39
+ description = contract.get("description", "").lower()
40
+
41
+ # Extract aspect name from path
42
+ aspect = contract_path.stem.replace(".schema", "")
43
+
44
+ # Rule 1: Check persistence metadata
45
+ persistence = metadata.get("persistence", {})
46
+ if persistence.get("strategy") == "none":
47
+ return False
48
+ elif persistence.get("strategy") in ["jsonb", "relational"]:
49
+ return True
50
+
51
+ # Rule 2: Empty properties
52
+ if len(properties) == 0:
53
+ return False
54
+
55
+ # Rule 3: Event without id (aspect ends with 'ed' like 'detected', 'completed')
56
+ has_id = "id" in properties
57
+ is_event_pattern = aspect.endswith("ed")
58
+ if is_event_pattern and not has_id:
59
+ return False
60
+
61
+ # Rule 4: Internal only
62
+ if metadata.get("to") == "internal":
63
+ return False
64
+
65
+ # Rule 5: Entity with id
66
+ if has_id:
67
+ return True
68
+
69
+ # Rule 6: Computed without id
70
+ compute_keywords = ["computed", "calculated", "derived", "aggregated", "aggregate"]
71
+ is_computed = any(keyword in description for keyword in compute_keywords)
72
+ if is_computed and not has_id:
73
+ return False
74
+
75
+ # Rule 7: Conservative default for external contracts
76
+ if metadata.get("to") == "external" and len(properties) > 0:
77
+ return True
78
+
79
+ # Rule 8: Fallback - assume doesn't need migration
80
+ return False
81
+
82
+ except Exception:
83
+ return False # On error, skip to avoid false positives
84
+
85
+
86
+ @pytest.mark.platform
87
+ def test_all_contracts_have_migrations():
88
+ """
89
+ SPEC-TESTER-CONV-0031: Validate all contracts have migrations
90
+
91
+ Given: Contract schemas in contracts/{theme}/{domain}/{aspect}.schema.json
92
+ When: Checking for corresponding migrations
93
+ Then: Each external/persistent contract has migration OR table exists
94
+ Internal/transient contracts are skipped
95
+ Missing contracts reported by theme/domain/aspect
96
+ """
97
+ if not CONTRACTS_DIR.exists():
98
+ pytest.skip("contracts/ directory does not exist")
99
+ return
100
+
101
+ if not MIGRATIONS_DIR.exists():
102
+ MIGRATIONS_DIR.mkdir(parents=True, exist_ok=True)
103
+
104
+ contracts = list(CONTRACTS_DIR.rglob("*.schema.json"))
105
+ missing = []
106
+ skipped = 0
107
+
108
+ for contract in contracts:
109
+ # Extract theme/domain/aspect from path
110
+ # Pattern: contracts/{theme}/{domain}/{aspect}.schema.json
111
+ relative_path = contract.relative_to(CONTRACTS_DIR)
112
+ parts = relative_path.parts
113
+
114
+ if len(parts) < 3:
115
+ continue # Skip malformed paths
116
+
117
+ # Check if contract needs migration
118
+ if not contract_needs_migration(contract):
119
+ skipped += 1
120
+ continue
121
+
122
+ theme = parts[0]
123
+ domain = parts[1]
124
+ aspect = contract.stem.replace(".schema", "")
125
+
126
+ # Expected table name
127
+ table_name = f"{theme}_{domain}_{aspect}".replace("-", "_")
128
+
129
+ # Check if migration exists mentioning this table
130
+ has_migration = False
131
+ if MIGRATIONS_DIR.exists():
132
+ for migration_file in MIGRATIONS_DIR.glob("*.sql"):
133
+ content = migration_file.read_text()
134
+ if f"CREATE TABLE {table_name}" in content or f"CREATE TABLE IF NOT EXISTS {table_name}" in content:
135
+ has_migration = True
136
+ break
137
+
138
+ if not has_migration:
139
+ missing.append(f"{theme}/{domain}/{aspect} → table: {table_name}")
140
+
141
+ if missing:
142
+ error_msg = f"Found {len(missing)} contracts without migrations:\n"
143
+ error_msg += "\n".join(f" {m}" for m in missing[:20])
144
+ if len(missing) > 20:
145
+ error_msg += f"\n ... and {len(missing) - 20} more"
146
+ if skipped > 0:
147
+ error_msg += f"\n\nℹ️ Skipped {skipped} internal/transient contracts"
148
+ error_msg += "\n\nRun: python atdd/coach/commands/migration.py to generate"
149
+ pytest.fail(error_msg)
150
+
151
+
152
+ @pytest.mark.platform
153
+ def test_migration_templates_reviewed():
154
+ """
155
+ SPEC-TESTER-CONV-0032: Reject migrations with unresolved TODOs
156
+
157
+ Given: Migration files in supabase/migrations/
158
+ Migrations may have TODO markers for human review
159
+ When: Validating migrations before applying
160
+ Then: No unresolved TODO markers (⚠️ TODO:) remain
161
+ Forces human review of foreign keys, indexes, RLS
162
+ """
163
+ if not MIGRATIONS_DIR.exists():
164
+ pytest.skip("supabase/migrations/ directory does not exist")
165
+ return
166
+
167
+ migrations_with_todos = []
168
+
169
+ for migration_file in MIGRATIONS_DIR.glob("*.sql"):
170
+ content = migration_file.read_text()
171
+
172
+ # Count unresolved TODO markers
173
+ todo_markers = [
174
+ line.strip()
175
+ for line in content.split("\n")
176
+ if "⚠️ TODO:" in line or "TODO:" in line and line.strip().startswith("--")
177
+ ]
178
+
179
+ if todo_markers:
180
+ migrations_with_todos.append({
181
+ "file": migration_file.name,
182
+ "count": len(todo_markers),
183
+ "todos": todo_markers[:5] # First 5 TODOs
184
+ })
185
+
186
+ if migrations_with_todos:
187
+ error_msg = f"Found {len(migrations_with_todos)} migrations with unresolved TODOs:\n\n"
188
+
189
+ for item in migrations_with_todos[:10]:
190
+ error_msg += f" {item['file']} ({item['count']} TODOs):\n"
191
+ for todo in item['todos']:
192
+ error_msg += f" {todo}\n"
193
+ error_msg += "\n"
194
+
195
+ if len(migrations_with_todos) > 10:
196
+ error_msg += f" ... and {len(migrations_with_todos) - 10} more files\n\n"
197
+
198
+ error_msg += "⚠️ Review and complete TODOs before applying migrations:\n"
199
+ error_msg += " - Add foreign key constraints\n"
200
+ error_msg += " - Add indexes for common queries\n"
201
+ error_msg += " - Define RLS policies\n"
202
+ error_msg += " - Review JSONB columns for normalization\n"
203
+
204
+ pytest.fail(error_msg)