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,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)
|