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,491 @@
1
+ """Schema linting engine - validates PostgreSQL schemas against best practices.
2
+
3
+ This module provides the SchemaLinter class which validates database schemas
4
+ against configurable rules for naming conventions, primary keys, documentation,
5
+ and other best practices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from confiture.config.environment import Environment
18
+
19
+ if TYPE_CHECKING:
20
+ from confiture.models.lint import LintConfig
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class RuleSeverity(Enum):
26
+ """Severity levels for linting violations."""
27
+
28
+ ERROR = "error"
29
+ WARNING = "warning"
30
+ INFO = "info"
31
+
32
+
33
+ @dataclass
34
+ class LintViolation:
35
+ """Represents a single linting violation."""
36
+
37
+ rule_id: str
38
+ rule_name: str
39
+ severity: RuleSeverity
40
+ object_type: str # table, column, index, etc.
41
+ object_name: str
42
+ message: str
43
+ file_path: str | None = None
44
+ line_number: int | None = None
45
+
46
+ def __str__(self) -> str:
47
+ """String representation of violation."""
48
+ prefix = f"[{self.severity.value.upper()}]"
49
+ return f"{prefix} {self.rule_name}: {self.message} ({self.object_type}: {self.object_name})"
50
+
51
+
52
+ @dataclass
53
+ class LintReport:
54
+ """Result of schema linting."""
55
+
56
+ errors: list[LintViolation] = field(default_factory=list)
57
+ warnings: list[LintViolation] = field(default_factory=list)
58
+ info: list[LintViolation] = field(default_factory=list)
59
+
60
+ @property
61
+ def has_errors(self) -> bool:
62
+ """Check if report contains errors."""
63
+ return len(self.errors) > 0
64
+
65
+ @property
66
+ def has_warnings(self) -> bool:
67
+ """Check if report contains warnings."""
68
+ return len(self.warnings) > 0
69
+
70
+ @property
71
+ def has_info(self) -> bool:
72
+ """Check if report contains info messages."""
73
+ return len(self.info) > 0
74
+
75
+ @property
76
+ def total_violations(self) -> int:
77
+ """Total number of violations."""
78
+ return len(self.errors) + len(self.warnings) + len(self.info)
79
+
80
+ def add_violation(self, violation: LintViolation) -> None:
81
+ """Add a violation to the report."""
82
+ if violation.severity == RuleSeverity.ERROR:
83
+ self.errors.append(violation)
84
+ elif violation.severity == RuleSeverity.WARNING:
85
+ self.warnings.append(violation)
86
+ else:
87
+ self.info.append(violation)
88
+
89
+
90
+ class LintConfig:
91
+ """Configuration for schema linting."""
92
+
93
+ def __init__(
94
+ self,
95
+ enabled: bool = True,
96
+ fail_on_error: bool = True,
97
+ fail_on_warning: bool = False,
98
+ check_naming: bool = True,
99
+ check_primary_keys: bool = True,
100
+ check_documentation: bool = True,
101
+ check_indexes: bool = True,
102
+ check_constraints: bool = True,
103
+ check_security: bool = True,
104
+ ):
105
+ """Initialize linting configuration.
106
+
107
+ Args:
108
+ enabled: Whether linting is enabled
109
+ fail_on_error: Exit with error code if errors found
110
+ fail_on_warning: Exit with error code if warnings found
111
+ check_naming: Check naming conventions (snake_case)
112
+ check_primary_keys: Ensure all tables have primary keys
113
+ check_documentation: Check for COMMENT documentation
114
+ check_indexes: Check indexes on foreign keys
115
+ check_constraints: Check constraint definitions
116
+ check_security: Check for security issues (passwords, tokens)
117
+ """
118
+ self.enabled = enabled
119
+ self.fail_on_error = fail_on_error
120
+ self.fail_on_warning = fail_on_warning
121
+ self.check_naming = check_naming
122
+ self.check_primary_keys = check_primary_keys
123
+ self.check_documentation = check_documentation
124
+ self.check_indexes = check_indexes
125
+ self.check_constraints = check_constraints
126
+ self.check_security = check_security
127
+
128
+
129
+ class SchemaLinter:
130
+ """Lints PostgreSQL schema against best practices.
131
+
132
+ Provides comprehensive schema validation including:
133
+ - Naming convention enforcement (snake_case)
134
+ - Primary key requirements
135
+ - Documentation (COMMENT statements)
136
+ - Index requirements on foreign keys
137
+ - Constraint validation
138
+ - Security issue detection
139
+
140
+ Example:
141
+ >>> config = LintConfig(enabled=True)
142
+ >>> linter = SchemaLinter(env="local", config=config)
143
+ >>>
144
+ >>> # Option 1: Load schema from files
145
+ >>> report = linter.lint()
146
+ >>>
147
+ >>> # Option 2: Pass schema directly
148
+ >>> schema = "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255));"
149
+ >>> report = linter.lint(schema=schema)
150
+ >>>
151
+ >>> if report.has_errors:
152
+ ... print(f"Found {len(report.errors)} errors")
153
+ """
154
+
155
+ def __init__(
156
+ self,
157
+ env: str = "local",
158
+ project_dir: Path | None = None,
159
+ config: LintConfig | None = None,
160
+ ):
161
+ """Initialize linter.
162
+
163
+ Args:
164
+ env: Environment name (local, test, production)
165
+ project_dir: Project root directory
166
+ config: Linting configuration (optional)
167
+ """
168
+ self.env = env
169
+ self.project_dir = project_dir or Path(".")
170
+ self.config = config or LintConfig()
171
+
172
+ # Load environment configuration
173
+ self.environment = Environment.load(env, project_dir=project_dir)
174
+
175
+ # Schema cache
176
+ self._schema_sql: str | None = None
177
+ self._tables: dict[str, dict[str, Any]] | None = None
178
+
179
+ def lint(self, schema: str | None = None) -> LintReport:
180
+ """Run linting and return report.
181
+
182
+ Args:
183
+ schema: Optional schema SQL to lint. If not provided, loads from files.
184
+
185
+ Returns:
186
+ LintReport with all violations found
187
+ """
188
+ report = LintReport()
189
+
190
+ if not self.config.enabled:
191
+ return report
192
+
193
+ # Use provided schema or load from files
194
+ if schema is not None:
195
+ self._schema_sql = schema
196
+ else:
197
+ self._load_schema()
198
+
199
+ if not self._schema_sql:
200
+ logger.warning("No schema SQL found, skipping linting")
201
+ return report
202
+
203
+ # Run configured checks
204
+ if self.config.check_naming:
205
+ self._check_naming_conventions(report)
206
+
207
+ if self.config.check_primary_keys:
208
+ self._check_primary_keys(report)
209
+
210
+ if self.config.check_documentation:
211
+ self._check_documentation(report)
212
+
213
+ if self.config.check_indexes:
214
+ self._check_indexes(report)
215
+
216
+ if self.config.check_security:
217
+ self._check_security(report)
218
+
219
+ return report
220
+
221
+ def _load_schema(self) -> None:
222
+ """Load schema SQL from files."""
223
+ try:
224
+ from confiture.core.builder import SchemaBuilder
225
+
226
+ builder = SchemaBuilder(env=self.env, project_dir=self.project_dir)
227
+ self._schema_sql = builder.build()
228
+ except Exception as e:
229
+ logger.error(f"Failed to load schema: {e}")
230
+ self._schema_sql = ""
231
+
232
+ def _check_naming_conventions(self, report: LintReport) -> None:
233
+ """Check naming conventions (snake_case for identifiers).
234
+
235
+ Args:
236
+ report: Report to add violations to
237
+ """
238
+ if not self._schema_sql:
239
+ return
240
+
241
+ # Find table definitions
242
+ table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)"
243
+ for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE):
244
+ table_name = match.group(1)
245
+
246
+ # Check if table name is snake_case
247
+ if not self._is_snake_case(table_name):
248
+ violation = LintViolation(
249
+ rule_id="naming_001",
250
+ rule_name="Table Naming Convention",
251
+ severity=RuleSeverity.WARNING,
252
+ object_type="table",
253
+ object_name=table_name,
254
+ message=f"Table name '{table_name}' should be lowercase with underscores (snake_case)",
255
+ )
256
+ report.add_violation(violation)
257
+
258
+ # Check column names in this table
259
+ self._check_column_names(table_name, report)
260
+
261
+ def _check_column_names(self, table_name: str, report: LintReport) -> None:
262
+ """Check column naming conventions in a table.
263
+
264
+ Args:
265
+ table_name: Name of table to check
266
+ report: Report to add violations to
267
+ """
268
+ if not self._schema_sql:
269
+ return
270
+
271
+ # Extract table definition
272
+ table_pattern = rf"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?{re.escape(table_name)}\s*\((.*?)\);"
273
+ match = re.search(table_pattern, self._schema_sql, re.IGNORECASE | re.DOTALL)
274
+
275
+ if not match:
276
+ return
277
+
278
+ table_def = match.group(1)
279
+
280
+ # Find column definitions
281
+ column_pattern = r"(\w+)\s+\w+"
282
+ for col_match in re.finditer(column_pattern, table_def):
283
+ column_name = col_match.group(1)
284
+
285
+ # Skip if it's a keyword (PRIMARY KEY, CONSTRAINT, etc.)
286
+ if column_name.upper() in (
287
+ "PRIMARY",
288
+ "KEY",
289
+ "CONSTRAINT",
290
+ "CHECK",
291
+ "DEFAULT",
292
+ "NOT",
293
+ "NULL",
294
+ ):
295
+ continue
296
+
297
+ if not self._is_snake_case(column_name):
298
+ violation = LintViolation(
299
+ rule_id="naming_002",
300
+ rule_name="Column Naming Convention",
301
+ severity=RuleSeverity.WARNING,
302
+ object_type="column",
303
+ object_name=f"{table_name}.{column_name}",
304
+ message=f"Column '{column_name}' should be lowercase with underscores (snake_case)",
305
+ )
306
+ report.add_violation(violation)
307
+
308
+ def _check_primary_keys(self, report: LintReport) -> None:
309
+ """Check that all tables have primary keys.
310
+
311
+ Args:
312
+ report: Report to add violations to
313
+ """
314
+ if not self._schema_sql:
315
+ return
316
+
317
+ # Find all table definitions
318
+ table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)\s*\((.*?)\);"
319
+ for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE | re.DOTALL):
320
+ table_name = match.group(1)
321
+ table_def = match.group(2)
322
+
323
+ # Skip if table contains PRIMARY KEY definition
324
+ if re.search(r"PRIMARY\s+KEY", table_def, re.IGNORECASE):
325
+ continue
326
+
327
+ # Skip if this is likely a junction/bridge table
328
+ if self._is_likely_junction_table(table_name):
329
+ continue
330
+
331
+ violation = LintViolation(
332
+ rule_id="pk_001",
333
+ rule_name="Missing Primary Key",
334
+ severity=RuleSeverity.WARNING,
335
+ object_type="table",
336
+ object_name=table_name,
337
+ message=f"Table '{table_name}' should have a PRIMARY KEY",
338
+ )
339
+ report.add_violation(violation)
340
+
341
+ def _check_documentation(self, report: LintReport) -> None:
342
+ """Check for documentation (COMMENT statements).
343
+
344
+ Args:
345
+ report: Report to add violations to
346
+ """
347
+ if not self._schema_sql:
348
+ return
349
+
350
+ # Find all table definitions
351
+ table_pattern = r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(\w+)"
352
+ tables_found = set()
353
+
354
+ for match in re.finditer(table_pattern, self._schema_sql, re.IGNORECASE):
355
+ table_name = match.group(1)
356
+ tables_found.add(table_name)
357
+
358
+ # Check for COMMENT statements
359
+ comment_pattern = r"COMMENT ON TABLE (\w+)"
360
+ tables_documented = set()
361
+
362
+ for match in re.finditer(comment_pattern, self._schema_sql, re.IGNORECASE):
363
+ tables_documented.add(match.group(1))
364
+
365
+ # Find undocumented tables
366
+ for table_name in tables_found:
367
+ if table_name not in tables_documented:
368
+ violation = LintViolation(
369
+ rule_id="doc_001",
370
+ rule_name="Missing Documentation",
371
+ severity=RuleSeverity.INFO,
372
+ object_type="table",
373
+ object_name=table_name,
374
+ message=f"Table '{table_name}' should have a COMMENT describing its purpose",
375
+ )
376
+ report.add_violation(violation)
377
+
378
+ def _check_indexes(self, _report: LintReport) -> None:
379
+ """Check for indexes on foreign keys.
380
+
381
+ Args:
382
+ _report: Report to add violations to
383
+ """
384
+ if not self._schema_sql:
385
+ return
386
+
387
+ # Find foreign key definitions
388
+ fk_pattern = r"REFERENCES\s+(\w+)\s*\((\w+)\)"
389
+ fk_matches = list(re.finditer(fk_pattern, self._schema_sql, re.IGNORECASE))
390
+
391
+ if not fk_matches:
392
+ return
393
+
394
+ # Check for CREATE INDEX statements
395
+ index_pattern = r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+\w+\s+ON\s+(\w+)\s*\(([^)]+)\)"
396
+ indexes = {}
397
+
398
+ for match in re.finditer(index_pattern, self._schema_sql, re.IGNORECASE):
399
+ table = match.group(1)
400
+ columns = match.group(2)
401
+ if table not in indexes:
402
+ indexes[table] = []
403
+ indexes[table].append(columns)
404
+
405
+ # Warn if foreign keys lack indexes
406
+ # This is simplified - a full implementation would parse more thoroughly
407
+ # For now, just note that checking for indexes on FK columns is important
408
+ for _fk_match in fk_matches:
409
+ pass
410
+
411
+ def _check_security(self, report: LintReport) -> None:
412
+ """Check for common security issues.
413
+
414
+ Args:
415
+ report: Report to add violations to
416
+ """
417
+ if not self._schema_sql:
418
+ return
419
+
420
+ # Check for suspicious column names that might store sensitive data
421
+ security_patterns = [
422
+ (r"password", "password"),
423
+ (r"token", "token"),
424
+ (r"secret", "secret"),
425
+ (r"api_key", "API key"),
426
+ (r"credit_card", "credit card"),
427
+ (r"ssn", "social security number"),
428
+ ]
429
+
430
+ for pattern, description in security_patterns:
431
+ matches = re.finditer(rf"(\w*{pattern}\w*)", self._schema_sql, re.IGNORECASE)
432
+ for match in matches:
433
+ identifier = match.group(1)
434
+
435
+ # Check if it's actually a column definition
436
+ context = self._schema_sql[max(0, match.start() - 50) : match.end() + 50]
437
+ if "CREATE TABLE" in context or "ALTER TABLE" in context:
438
+ violation = LintViolation(
439
+ rule_id="sec_001",
440
+ rule_name="Sensitive Data Column",
441
+ severity=RuleSeverity.WARNING,
442
+ object_type="column",
443
+ object_name=identifier,
444
+ message=f"Column '{identifier}' appears to store {description} - ensure proper encryption and access controls",
445
+ )
446
+ report.add_violation(violation)
447
+
448
+ @staticmethod
449
+ def _is_snake_case(identifier: str) -> bool:
450
+ """Check if identifier is in snake_case.
451
+
452
+ Args:
453
+ identifier: Identifier to check
454
+
455
+ Returns:
456
+ True if identifier is snake_case, False otherwise
457
+ """
458
+ # Allow uppercase letters for backward compatibility with existing code
459
+ # but prefer lowercase
460
+ if identifier != identifier.lower() and "_" not in identifier:
461
+ return False
462
+
463
+ # Check that it only contains alphanumeric and underscore
464
+ return bool(re.match(r"^[a-z_][a-z0-9_]*$", identifier, re.IGNORECASE))
465
+
466
+ @staticmethod
467
+ def _is_likely_junction_table(table_name: str) -> bool:
468
+ """Check if table looks like a junction/bridge table.
469
+
470
+ Args:
471
+ table_name: Name of table to check
472
+
473
+ Returns:
474
+ True if table appears to be a junction table
475
+ """
476
+ # Common junction table patterns
477
+ patterns = [
478
+ r"^(.+)_(.+)$", # Format: singular_singular or table1_table2
479
+ r"^link_", # Starts with link_
480
+ r"_assoc", # Ends with _assoc
481
+ r"_join", # Ends with _join
482
+ r"_rel", # Ends with _rel
483
+ ]
484
+
485
+ # Count underscores - junction tables often have multiple
486
+ if table_name.count("_") >= 2:
487
+ for pattern in patterns:
488
+ if re.match(pattern, table_name, re.IGNORECASE):
489
+ return True
490
+
491
+ return False
@@ -0,0 +1,151 @@
1
+ """Rule versioning and compatibility management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class LintSeverity(Enum):
13
+ """Severity levels for linting rules."""
14
+
15
+ INFO = "info"
16
+ WARNING = "warning"
17
+ ERROR = "error"
18
+ CRITICAL = "critical"
19
+
20
+
21
+ @dataclass
22
+ class RuleVersion:
23
+ """Semantic version for rules."""
24
+
25
+ major: int
26
+ minor: int
27
+ patch: int
28
+
29
+ def __str__(self) -> str:
30
+ return f"{self.major}.{self.minor}.{self.patch}"
31
+
32
+ def is_compatible_with(self, other: RuleVersion) -> bool:
33
+ """Check if compatible (major version must match)."""
34
+ return self.major == other.major
35
+
36
+ def __le__(self, other: RuleVersion) -> bool:
37
+ return (self.major, self.minor, self.patch) <= (
38
+ other.major,
39
+ other.minor,
40
+ other.patch,
41
+ )
42
+
43
+ def __ge__(self, other: RuleVersion) -> bool:
44
+ return (self.major, self.minor, self.patch) >= (
45
+ other.major,
46
+ other.minor,
47
+ other.patch,
48
+ )
49
+
50
+ def __lt__(self, other: RuleVersion) -> bool:
51
+ return (self.major, self.minor, self.patch) < (
52
+ other.major,
53
+ other.minor,
54
+ other.patch,
55
+ )
56
+
57
+ def __gt__(self, other: RuleVersion) -> bool:
58
+ return (self.major, self.minor, self.patch) > (
59
+ other.major,
60
+ other.minor,
61
+ other.patch,
62
+ )
63
+
64
+ def __eq__(self, other: object) -> bool:
65
+ if not isinstance(other, RuleVersion):
66
+ return NotImplemented
67
+ return (self.major, self.minor, self.patch) == (
68
+ other.major,
69
+ other.minor,
70
+ other.patch,
71
+ )
72
+
73
+
74
+ @dataclass
75
+ class Rule:
76
+ """Individual linting rule with versioning."""
77
+
78
+ rule_id: str
79
+ name: str
80
+ description: str
81
+ version: RuleVersion
82
+ deprecated_in: RuleVersion | None = None
83
+ removed_in: RuleVersion | None = None
84
+ migration_path: str | None = None # Docs URL or replacement rule ID
85
+ severity: LintSeverity = LintSeverity.WARNING
86
+ enabled_by_default: bool = True
87
+
88
+ def is_deprecated(self, target_version: RuleVersion | None = None) -> bool:
89
+ """Check if rule is deprecated."""
90
+ if not self.deprecated_in:
91
+ return False
92
+ if target_version:
93
+ return self.deprecated_in <= target_version
94
+ return True
95
+
96
+ def is_removed(self, target_version: RuleVersion | None = None) -> bool:
97
+ """Check if rule is removed."""
98
+ if not self.removed_in:
99
+ return False
100
+ if target_version:
101
+ return self.removed_in <= target_version
102
+ return True
103
+
104
+
105
+ class RuleRemovedError(Exception):
106
+ """Exception raised when accessing a removed rule."""
107
+
108
+ pass
109
+
110
+
111
+ class RuleVersionManager:
112
+ """Manage rule versions and compatibility."""
113
+
114
+ def __init__(self, rules: list[Rule]):
115
+ self.rules = {r.rule_id: r for r in rules}
116
+
117
+ def get_rule(
118
+ self,
119
+ rule_id: str,
120
+ target_version: RuleVersion | None = None,
121
+ ) -> Rule | None:
122
+ """Get rule compatible with target version."""
123
+ rule = self.rules.get(rule_id)
124
+ if not rule:
125
+ return None
126
+
127
+ if rule.is_removed(target_version):
128
+ raise RuleRemovedError(
129
+ f"Rule {rule_id} was removed in {rule.removed_in}. "
130
+ f"Migration path: {rule.migration_path}"
131
+ )
132
+
133
+ if rule.is_deprecated(target_version):
134
+ logger.warning(
135
+ f"Rule {rule_id} is deprecated since {rule.deprecated_in}. "
136
+ f"Migration path: {rule.migration_path}"
137
+ )
138
+
139
+ return rule
140
+
141
+ def validate_compatibility(
142
+ self,
143
+ _library_version: RuleVersion,
144
+ min_rule_version: RuleVersion,
145
+ ) -> list[str]:
146
+ """Check if all rules are compatible with version."""
147
+ incompatible = []
148
+ for rule in self.rules.values():
149
+ if not rule.version.is_compatible_with(min_rule_version):
150
+ incompatible.append(rule.rule_id)
151
+ return incompatible