fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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 (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1893 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +184 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +882 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,587 @@
1
+ """Mutation testing framework for database migrations.
2
+
3
+ Mutation testing verifies that migration tests would catch intentional bugs
4
+ by creating variations of migrations and checking if tests detect them.
5
+
6
+ Architecture:
7
+ - MutationRegistry: Catalog of all possible mutations
8
+ - MutationRunner: Execute migrations with mutations applied
9
+ - MutationReport: Analyze which tests caught which mutations
10
+ - MutationMetrics: Calculate mutation kill rate and effectiveness
11
+ """
12
+
13
+ import json
14
+ import re
15
+ from collections.abc import Callable
16
+ from dataclasses import asdict, dataclass, field
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import psycopg
22
+
23
+
24
+ class MutationSeverity(Enum):
25
+ """Severity level of a mutation."""
26
+
27
+ CRITICAL = "CRITICAL" # Schema/data integrity issues
28
+ IMPORTANT = "IMPORTANT" # Significant behavior changes
29
+ MINOR = "MINOR" # Edge cases, optimization
30
+
31
+
32
+ class MutationCategory(Enum):
33
+ """Category of mutation."""
34
+
35
+ SCHEMA = "schema" # Table/column/constraint changes
36
+ DATA = "data" # Data transformations
37
+ ROLLBACK = "rollback" # Rollback operations
38
+ PERFORMANCE = "performance" # Performance optimization
39
+
40
+
41
+ @dataclass
42
+ class Mutation:
43
+ """Definition of a single mutation."""
44
+
45
+ id: str # Unique identifier
46
+ name: str # Human-readable name
47
+ description: str # What the mutation does
48
+ category: MutationCategory # Type of mutation
49
+ severity: MutationSeverity # Impact level
50
+ apply_fn: Callable | None = None # Function to apply mutation
51
+ apply_regex: str | None = None # Regex for SQL transformation
52
+
53
+ def apply(self, sql: str) -> str:
54
+ """Apply this mutation to SQL code."""
55
+ if self.apply_fn:
56
+ return self.apply_fn(sql)
57
+ elif self.apply_regex:
58
+ # Simple regex-based mutations
59
+ # Format: "pattern=>replacement"
60
+ parts = self.apply_regex.split("=>")
61
+ if len(parts) == 2:
62
+ pattern, replacement = parts
63
+ return re.sub(pattern, replacement, sql, flags=re.IGNORECASE)
64
+ return sql
65
+
66
+
67
+ @dataclass
68
+ class MutationResult:
69
+ """Result of executing a migration with a mutation."""
70
+
71
+ mutation_id: str
72
+ success: bool # Migration executed
73
+ mutation_applied: bool # Mutation was successfully applied
74
+ duration_seconds: float
75
+ stdout: str
76
+ stderr: str
77
+ database_state: dict | None = None # Schema state after mutation
78
+ error: Exception | None = None
79
+
80
+
81
+ @dataclass
82
+ class MutationTestResult:
83
+ """Result of testing a mutation against test suite."""
84
+
85
+ mutation_id: str
86
+ mutation_name: str
87
+ test_name: str
88
+ caught: bool # Test caught the mutation
89
+ duration_seconds: float
90
+
91
+
92
+ @dataclass
93
+ class MutationMetrics:
94
+ """Metrics for mutation test results."""
95
+
96
+ total_mutations: int = 0
97
+ killed_mutations: int = 0 # Caught by tests
98
+ survived_mutations: int = 0 # Missed by tests
99
+ equivalent_mutations: int = 0 # Logically equivalent
100
+
101
+ by_category: dict[str, dict[str, int]] = field(default_factory=dict)
102
+ by_severity: dict[str, dict[str, int]] = field(default_factory=dict)
103
+ weak_tests: list[str] = field(default_factory=list)
104
+
105
+ @property
106
+ def kill_rate(self) -> float:
107
+ """Percentage of mutations killed by tests."""
108
+ if self.total_mutations == 0:
109
+ return 0.0
110
+ return (self.killed_mutations / self.total_mutations) * 100
111
+
112
+
113
+ @dataclass
114
+ class MutationReport:
115
+ """Complete mutation testing report."""
116
+
117
+ timestamp: str
118
+ total_mutations: int
119
+ metrics: MutationMetrics
120
+ results_by_mutation: dict[str, list[MutationTestResult]] = field(default_factory=dict)
121
+ results_by_test: dict[str, list[MutationTestResult]] = field(default_factory=dict)
122
+ recommendations: list[str] = field(default_factory=list)
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ """Convert report to dictionary."""
126
+ return {
127
+ "timestamp": self.timestamp,
128
+ "total_mutations": self.total_mutations,
129
+ "metrics": asdict(self.metrics),
130
+ "kill_rate": f"{self.metrics.kill_rate:.1f}%",
131
+ "recommendations": self.recommendations,
132
+ }
133
+
134
+
135
+ class MutationRegistry:
136
+ """Registry of all available mutations."""
137
+
138
+ def __init__(self):
139
+ self.mutations: dict[str, Mutation] = {}
140
+ self._initialize_default_mutations()
141
+
142
+ def _initialize_default_mutations(self):
143
+ """Initialize default mutation set."""
144
+ # Schema mutations
145
+ self._add_schema_mutations()
146
+ # Data mutations
147
+ self._add_data_mutations()
148
+ # Rollback mutations
149
+ self._add_rollback_mutations()
150
+ # Performance mutations
151
+ self._add_performance_mutations()
152
+
153
+ def _add_schema_mutations(self):
154
+ """Add schema-related mutations."""
155
+ schema_mutations = [
156
+ Mutation(
157
+ id="schema_001",
158
+ name="remove_primary_key",
159
+ description="Remove PRIMARY KEY constraint from table",
160
+ category=MutationCategory.SCHEMA,
161
+ severity=MutationSeverity.CRITICAL,
162
+ apply_regex=r"PRIMARY KEY\s*,?\s*" + "=>" + " ",
163
+ ),
164
+ Mutation(
165
+ id="schema_002",
166
+ name="remove_not_null",
167
+ description="Remove NOT NULL constraint from column",
168
+ category=MutationCategory.SCHEMA,
169
+ severity=MutationSeverity.CRITICAL,
170
+ apply_regex=r"\s+NOT\s+NULL" + "=>" + " ",
171
+ ),
172
+ Mutation(
173
+ id="schema_003",
174
+ name="remove_unique",
175
+ description="Remove UNIQUE constraint",
176
+ category=MutationCategory.SCHEMA,
177
+ severity=MutationSeverity.IMPORTANT,
178
+ apply_regex=r"\s+UNIQUE" + "=>" + " ",
179
+ ),
180
+ Mutation(
181
+ id="schema_004",
182
+ name="remove_foreign_key",
183
+ description="Remove FOREIGN KEY constraint",
184
+ category=MutationCategory.SCHEMA,
185
+ severity=MutationSeverity.CRITICAL,
186
+ apply_regex=r"FOREIGN\s+KEY\s+\([^)]+\)\s+REFERENCES\s+\S+\s*\([^)]+\)"
187
+ + "=>"
188
+ + " ",
189
+ ),
190
+ Mutation(
191
+ id="schema_005",
192
+ name="skip_index_creation",
193
+ description="Skip index creation in migration",
194
+ category=MutationCategory.SCHEMA,
195
+ severity=MutationSeverity.IMPORTANT,
196
+ apply_regex=r"CREATE\s+(?:UNIQUE\s+)?INDEX" + "=>" + "-- CREATE INDEX",
197
+ ),
198
+ Mutation(
199
+ id="schema_006",
200
+ name="change_column_type",
201
+ description="Change column data type",
202
+ category=MutationCategory.SCHEMA,
203
+ severity=MutationSeverity.CRITICAL,
204
+ apply_fn=lambda sql: sql.replace("TEXT", "VARCHAR(50)") if "TEXT" in sql else sql,
205
+ ),
206
+ Mutation(
207
+ id="schema_007",
208
+ name="remove_default_value",
209
+ description="Remove DEFAULT value from column",
210
+ category=MutationCategory.SCHEMA,
211
+ severity=MutationSeverity.IMPORTANT,
212
+ apply_regex=r"\s+DEFAULT\s+['\"]?[^,)]+['\"]?" + "=>" + " ",
213
+ ),
214
+ Mutation(
215
+ id="schema_008",
216
+ name="add_unnecessary_column",
217
+ description="Add extra unrequired column",
218
+ category=MutationCategory.SCHEMA,
219
+ severity=MutationSeverity.MINOR,
220
+ apply_fn=lambda sql: sql + "\nALTER TABLE ADD COLUMN mutation_marker BOOLEAN;",
221
+ ),
222
+ Mutation(
223
+ id="schema_009",
224
+ name="skip_constraint_check",
225
+ description="Skip CHECK constraint",
226
+ category=MutationCategory.SCHEMA,
227
+ severity=MutationSeverity.IMPORTANT,
228
+ apply_regex=r"CHECK\s*\([^)]+\)" + "=>" + " ",
229
+ ),
230
+ Mutation(
231
+ id="schema_010",
232
+ name="wrong_column_order",
233
+ description="Change column ordering in table",
234
+ category=MutationCategory.SCHEMA,
235
+ severity=MutationSeverity.MINOR,
236
+ apply_fn=lambda sql: sql, # Complex to implement
237
+ ),
238
+ ]
239
+
240
+ for mutation in schema_mutations:
241
+ self.mutations[mutation.id] = mutation
242
+
243
+ def _add_data_mutations(self):
244
+ """Add data-related mutations."""
245
+ data_mutations = [
246
+ Mutation(
247
+ id="data_001",
248
+ name="skip_update",
249
+ description="Skip UPDATE statement in migration",
250
+ category=MutationCategory.DATA,
251
+ severity=MutationSeverity.CRITICAL,
252
+ apply_regex=r"UPDATE\s+\w+\s+SET.*?;" + "=>" + "-- UPDATE (skipped);",
253
+ ),
254
+ Mutation(
255
+ id="data_002",
256
+ name="wrong_update_value",
257
+ description="Use wrong value in UPDATE",
258
+ category=MutationCategory.DATA,
259
+ severity=MutationSeverity.CRITICAL,
260
+ apply_fn=lambda sql: sql.replace("'active'", "'inactive'")
261
+ if "'active'" in sql
262
+ else sql,
263
+ ),
264
+ Mutation(
265
+ id="data_003",
266
+ name="skip_delete",
267
+ description="Skip DELETE statement",
268
+ category=MutationCategory.DATA,
269
+ severity=MutationSeverity.CRITICAL,
270
+ apply_regex=r"DELETE\s+FROM.*?;" + "=>" + "-- DELETE (skipped);",
271
+ ),
272
+ Mutation(
273
+ id="data_004",
274
+ name="incomplete_insert",
275
+ description="Skip INSERT statement",
276
+ category=MutationCategory.DATA,
277
+ severity=MutationSeverity.CRITICAL,
278
+ apply_regex=r"INSERT\s+INTO.*?;" + "=>" + "-- INSERT (skipped);",
279
+ ),
280
+ Mutation(
281
+ id="data_005",
282
+ name="wrong_where_clause",
283
+ description="Change WHERE condition",
284
+ category=MutationCategory.DATA,
285
+ severity=MutationSeverity.CRITICAL,
286
+ apply_fn=lambda sql: sql.replace("WHERE id > 0", "WHERE id < 0")
287
+ if "WHERE id > 0" in sql
288
+ else sql,
289
+ ),
290
+ Mutation(
291
+ id="data_006",
292
+ name="missing_coalesce",
293
+ description="Don't use COALESCE for NULLs",
294
+ category=MutationCategory.DATA,
295
+ severity=MutationSeverity.IMPORTANT,
296
+ apply_fn=lambda sql: sql.replace("COALESCE(", "") if "COALESCE(" in sql else sql,
297
+ ),
298
+ Mutation(
299
+ id="data_007",
300
+ name="partial_update",
301
+ description="Update only some rows when should update all",
302
+ category=MutationCategory.DATA,
303
+ severity=MutationSeverity.CRITICAL,
304
+ apply_fn=lambda sql: sql.replace("UPDATE table", "UPDATE table WHERE id IN (1,2,3)")
305
+ if "UPDATE table" in sql
306
+ else sql,
307
+ ),
308
+ Mutation(
309
+ id="data_008",
310
+ name="wrong_cast",
311
+ description="Use wrong type cast",
312
+ category=MutationCategory.DATA,
313
+ severity=MutationSeverity.IMPORTANT,
314
+ apply_fn=lambda sql: sql.replace("::TEXT", "::INTEGER") if "::TEXT" in sql else sql,
315
+ ),
316
+ ]
317
+
318
+ for mutation in data_mutations:
319
+ self.mutations[mutation.id] = mutation
320
+
321
+ def _add_rollback_mutations(self):
322
+ """Add rollback-related mutations."""
323
+ rollback_mutations = [
324
+ Mutation(
325
+ id="rollback_001",
326
+ name="incomplete_drop",
327
+ description="Don't drop all created objects",
328
+ category=MutationCategory.ROLLBACK,
329
+ severity=MutationSeverity.CRITICAL,
330
+ apply_regex=r"DROP\s+TABLE" + "=>" + "-- DROP TABLE",
331
+ ),
332
+ Mutation(
333
+ id="rollback_002",
334
+ name="skip_data_restore",
335
+ description="Skip restoring backup data",
336
+ category=MutationCategory.ROLLBACK,
337
+ severity=MutationSeverity.CRITICAL,
338
+ apply_regex=r"INSERT\s+INTO.*backup" + "=>" + "-- RESTORE (skipped)",
339
+ ),
340
+ Mutation(
341
+ id="rollback_003",
342
+ name="partial_rollback",
343
+ description="Rollback only partially",
344
+ category=MutationCategory.ROLLBACK,
345
+ severity=MutationSeverity.CRITICAL,
346
+ apply_fn=lambda sql: sql.replace("DROP COLUMN", "-- DROP COLUMN"),
347
+ ),
348
+ Mutation(
349
+ id="rollback_004",
350
+ name="wrong_constraint_restoration",
351
+ description="Restore wrong constraint definition",
352
+ category=MutationCategory.ROLLBACK,
353
+ severity=MutationSeverity.IMPORTANT,
354
+ apply_fn=lambda sql: sql, # Complex to implement
355
+ ),
356
+ Mutation(
357
+ id="rollback_005",
358
+ name="skip_index_drop",
359
+ description="Don't drop indexes when rolling back",
360
+ category=MutationCategory.ROLLBACK,
361
+ severity=MutationSeverity.IMPORTANT,
362
+ apply_regex=r"DROP\s+INDEX" + "=>" + "-- DROP INDEX",
363
+ ),
364
+ ]
365
+
366
+ for mutation in rollback_mutations:
367
+ self.mutations[mutation.id] = mutation
368
+
369
+ def _add_performance_mutations(self):
370
+ """Add performance-related mutations."""
371
+ performance_mutations = [
372
+ Mutation(
373
+ id="perf_001",
374
+ name="missing_index",
375
+ description="Skip index that should be created",
376
+ category=MutationCategory.PERFORMANCE,
377
+ severity=MutationSeverity.IMPORTANT,
378
+ apply_regex=r"CREATE\s+INDEX" + "=>" + "-- CREATE INDEX (skipped)",
379
+ ),
380
+ Mutation(
381
+ id="perf_002",
382
+ name="inefficient_join",
383
+ description="Use inefficient JOIN instead of WHERE",
384
+ category=MutationCategory.PERFORMANCE,
385
+ severity=MutationSeverity.IMPORTANT,
386
+ apply_fn=lambda sql: sql, # Complex implementation
387
+ ),
388
+ Mutation(
389
+ id="perf_003",
390
+ name="missing_bulk_operation",
391
+ description="Process rows one by one instead of bulk",
392
+ category=MutationCategory.PERFORMANCE,
393
+ severity=MutationSeverity.IMPORTANT,
394
+ apply_fn=lambda sql: sql, # Complex implementation
395
+ ),
396
+ Mutation(
397
+ id="perf_004",
398
+ name="scan_full_table",
399
+ description="Scan entire table instead of using index",
400
+ category=MutationCategory.PERFORMANCE,
401
+ severity=MutationSeverity.IMPORTANT,
402
+ apply_fn=lambda sql: sql.replace("WHERE id =", "WHERE TRUE")
403
+ if "WHERE id =" in sql
404
+ else sql,
405
+ ),
406
+ ]
407
+
408
+ for mutation in performance_mutations:
409
+ self.mutations[mutation.id] = mutation
410
+
411
+ def get_mutation(self, mutation_id: str) -> Mutation | None:
412
+ """Get a specific mutation by ID."""
413
+ return self.mutations.get(mutation_id)
414
+
415
+ def get_by_category(self, category: MutationCategory) -> list[Mutation]:
416
+ """Get all mutations in a category."""
417
+ return [m for m in self.mutations.values() if m.category == category]
418
+
419
+ def get_by_severity(self, severity: MutationSeverity) -> list[Mutation]:
420
+ """Get all mutations of a severity level."""
421
+ return [m for m in self.mutations.values() if m.severity == severity]
422
+
423
+ def list_all(self) -> list[Mutation]:
424
+ """Get all mutations."""
425
+ return list(self.mutations.values())
426
+
427
+
428
+ class MutationRunner:
429
+ """Execute migrations with mutations applied."""
430
+
431
+ def __init__(self, db_connection: psycopg.Connection, migrations_dir: Path):
432
+ self.connection = db_connection
433
+ self.migrations_dir = migrations_dir
434
+ self.registry = MutationRegistry()
435
+ self.test_results: list[MutationTestResult] = []
436
+
437
+ def run_migration_with_mutation(
438
+ self,
439
+ migration_name: str,
440
+ mutation: Mutation,
441
+ ) -> MutationResult:
442
+ """Execute migration with a mutation applied."""
443
+ try:
444
+ # Load migration SQL
445
+ migration_file = self.migrations_dir / f"{migration_name}.sql"
446
+ if not migration_file.exists():
447
+ raise FileNotFoundError(f"Migration not found: {migration_file}")
448
+
449
+ with open(migration_file) as f:
450
+ original_sql = f.read()
451
+
452
+ # Apply mutation
453
+ mutated_sql = mutation.apply(original_sql)
454
+ mutation_applied = mutated_sql != original_sql
455
+
456
+ # Execute mutated migration
457
+ if not mutation_applied:
458
+ # Mutation couldn't be applied
459
+ return MutationResult(
460
+ mutation_id=mutation.id,
461
+ success=False,
462
+ mutation_applied=False,
463
+ duration_seconds=0.0,
464
+ stdout="",
465
+ stderr="Mutation could not be applied to SQL",
466
+ )
467
+
468
+ # Execute in isolated transaction
469
+ import time
470
+
471
+ start_time = time.time()
472
+
473
+ try:
474
+ with self.connection.cursor() as cur:
475
+ cur.execute(mutated_sql)
476
+ self.connection.commit()
477
+
478
+ duration = time.time() - start_time
479
+
480
+ return MutationResult(
481
+ mutation_id=mutation.id,
482
+ success=True,
483
+ mutation_applied=True,
484
+ duration_seconds=duration,
485
+ stdout=f"Mutation {mutation.id} executed successfully",
486
+ stderr="",
487
+ )
488
+
489
+ except Exception as e:
490
+ self.connection.rollback()
491
+ duration = time.time() - start_time
492
+
493
+ return MutationResult(
494
+ mutation_id=mutation.id,
495
+ success=False,
496
+ mutation_applied=True,
497
+ duration_seconds=duration,
498
+ stdout="",
499
+ stderr=str(e),
500
+ error=e,
501
+ )
502
+
503
+ except Exception as e:
504
+ return MutationResult(
505
+ mutation_id=mutation.id,
506
+ success=False,
507
+ mutation_applied=False,
508
+ duration_seconds=0.0,
509
+ stdout="",
510
+ stderr=str(e),
511
+ error=e,
512
+ )
513
+
514
+ def record_test_result(
515
+ self,
516
+ mutation_id: str,
517
+ mutation_name: str,
518
+ test_name: str,
519
+ caught: bool,
520
+ duration: float,
521
+ ):
522
+ """Record test result for a mutation."""
523
+ result = MutationTestResult(
524
+ mutation_id=mutation_id,
525
+ mutation_name=mutation_name,
526
+ test_name=test_name,
527
+ caught=caught,
528
+ duration_seconds=duration,
529
+ )
530
+ self.test_results.append(result)
531
+
532
+ def generate_report(self) -> MutationReport:
533
+ """Generate comprehensive mutation report."""
534
+ from datetime import datetime
535
+
536
+ # Calculate metrics
537
+ total = len(self.registry.list_all())
538
+ killed = sum(1 for r in self.test_results if r.caught)
539
+ survived = total - killed
540
+
541
+ metrics = MutationMetrics(
542
+ total_mutations=total,
543
+ killed_mutations=killed,
544
+ survived_mutations=survived,
545
+ )
546
+
547
+ # Generate recommendations
548
+ recommendations = self._generate_recommendations()
549
+
550
+ report = MutationReport(
551
+ timestamp=datetime.now().isoformat(),
552
+ total_mutations=total,
553
+ metrics=metrics,
554
+ recommendations=recommendations,
555
+ )
556
+
557
+ return report
558
+
559
+ def _generate_recommendations(self) -> list[str]:
560
+ """Generate recommendations based on test results."""
561
+ recommendations = []
562
+
563
+ if self.test_results:
564
+ # Find weak tests
565
+ test_catches = {}
566
+ for result in self.test_results:
567
+ if result.test_name not in test_catches:
568
+ test_catches[result.test_name] = {"caught": 0, "total": 0}
569
+ test_catches[result.test_name]["total"] += 1
570
+ if result.caught:
571
+ test_catches[result.test_name]["caught"] += 1
572
+
573
+ # Identify tests that catch < 50% of mutations
574
+ for test_name, stats in test_catches.items():
575
+ catch_rate = stats["caught"] / stats["total"]
576
+ if catch_rate < 0.5:
577
+ recommendations.append(
578
+ f"Test '{test_name}' has low mutation kill rate ({catch_rate * 100:.0f}%). "
579
+ f"Consider adding more assertions or validations."
580
+ )
581
+
582
+ return recommendations
583
+
584
+ def export_report(self, report: MutationReport, path: Path):
585
+ """Export report to file."""
586
+ with open(path, "w") as f:
587
+ json.dump(report.to_dict(), f, indent=2)