# v0.6.0 Roadmap

This document tracks planned features, improvements, and implementation details
for v0.6.0.

## Theme: Django 5.x/6.0 Awareness, Zero-Downtime Patterns & DX

Building on v0.5.0's Squawk-inspired rules and integrations, v0.6.0 focuses on
Django 5.x/6.0 migration safety, zero-downtime deployment patterns, and
developer experience features inspired by django-migration-linter,
strong_migrations, and community best practices.

______________________________________________________________________

## New Rules

### High Priority

| Rule  | Name                               | Severity | Status  |
| ----- | ---------------------------------- | -------- | ------- |
| SM037 | `direct_model_import_in_runpython` | ERROR    | Pending |
| SM038 | `mixed_schema_and_data_operations` | WARNING  | Pending |
| SM039 | `remove_field_without_state_split` | WARNING  | Pending |
| SM040 | `volatile_default_with_unique`     | ERROR    | Pending |
| SM041 | `adding_stored_generated_field`    | WARNING  | Pending |
| SM042 | `alter_composite_primary_key`      | ERROR    | Pending |

### Medium Priority

| Rule  | Name                                | Severity | Status  |
| ----- | ----------------------------------- | -------- | ------- |
| SM043 | `autofield_to_bigautofield_rewrite` | WARNING  | Pending |
| SM044 | `remove_field_no_cascade`           | WARNING  | Pending |
| SM045 | `volatile_db_default`               | WARNING  | Pending |
| SM046 | `rename_field_without_db_column`    | WARNING  | Pending |
| SM047 | `constraint_missing_not_valid`      | WARNING  | Pending |
| SM048 | `truncate_in_runsql`                | ERROR    | Pending |
| SM049 | `transaction_nesting_in_runsql`     | WARNING  | Pending |
| SM050 | `drop_database_in_runsql`           | ERROR    | Pending |

### Low Priority

| Rule  | Name                                | Severity | Status  |
| ----- | ----------------------------------- | -------- | ------- |
| SM051 | `recommend_db_default`              | INFO     | Pending |
| SM052 | `deprecated_check_constraint_check` | WARNING  | Pending |
| SM053 | `prefer_bigint_over_smallint`       | WARNING  | Pending |
| SM054 | `multiple_heavy_ops_same_table`     | INFO     | Pending |
| SM055 | `unnecessary_atomic_false`          | INFO     | Pending |
| SM056 | `adding_exclusion_constraint`       | WARNING  | Pending |
| SM057 | `smart_column_type_changes`         | --       | Pending |

______________________________________________________________________

## Rule Implementation Details

### SM037: direct_model_import_in_runpython

**Problem:** Using `from myapp.models import MyModel` in a RunPython function
instead of `apps.get_model('myapp', 'MyModel')` causes the migration to use the
current model class, not the historical version at migration time. Works
initially but breaks on fresh databases that run all migrations sequentially.

**Detection:**

```python
# Parse RunPython function body for direct model imports
import ast

def check_runpython_imports(func_source):
    tree = ast.parse(func_source)
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            if 'models' in (node.module or ''):
                return self.create_issue(
                    severity=Severity.ERROR,
                    message="RunPython uses direct model import instead of apps.get_model()"
                )
```

**Trigger conditions:**

- `RunPython` function contains `from X.models import Y`
- `RunPython` function contains `import X.models`
- Function does not use `apps.get_model()`

**Safe pattern:**

```python
def populate_data(apps, schema_editor):
    MyModel = apps.get_model('myapp', 'MyModel')
    MyModel.objects.filter(...).update(...)
```

**Implementation file:** `django_safe_migrations/rules/run_sql.py`

**References:**

- [Django RunPython docs](https://docs.djangoproject.com/en/stable/ref/migration-operations/#runpython)
- [Code Review Doctor: This mistake will break your Django migrations](https://codereviewdoctor.medium.com/avoiding-flaky-migration-1fc71c7cdb66)

______________________________________________________________________

### SM038: mixed_schema_and_data_operations

**Problem:** Combining schema changes (AddField, AlterField) with data
operations (RunPython, RunSQL with DML) in the same migration extends lock
duration. On PostgreSQL, can cause
`OperationalError: cannot ALTER TABLE because it has pending trigger events`.

**Detection:**

```python
def check_migration(self, migration):
    schema_ops = []
    data_ops = []
    for op in migration.operations:
        if isinstance(op, (AddField, AlterField, RemoveField, AddIndex, ...)):
            schema_ops.append(op)
        elif isinstance(op, (RunPython, RunSQL)):
            data_ops.append(op)

    if schema_ops and data_ops:
        return self.create_issue(
            severity=Severity.WARNING,
            message="Migration mixes schema and data operations. "
                    "Split into separate migrations to reduce lock duration."
        )
```

**Trigger conditions:**

- Migration contains both schema-altering operations AND RunPython/RunSQL data
  operations
- Excludes RunSQL that is purely DDL (CREATE INDEX, ALTER TABLE, etc.)

**Safe pattern:**

```python
# Migration 001: Schema change only
operations = [
    migrations.AddField(
        model_name='user', name='status',
        field=models.CharField(max_length=20, null=True),
    ),
]

# Migration 002: Data backfill only
operations = [
    migrations.RunPython(backfill_status, migrations.RunPython.noop),
]
```

**Implementation file:** `django_safe_migrations/rules/graph.py` (or new
`migration_structure.py`)

**References:**

- [PostHog: Safe Django Migrations](https://posthog.com/handbook/engineering/safe-django-migrations)
- [Vinta Software: The dos and don'ts for writing Django migrations](https://www.vintasoftware.com/blog/writing-django-migrations)

______________________________________________________________________

### SM039: remove_field_without_state_split

**Problem:** During blue-green or rolling deployments, `RemoveField` immediately
drops the column while old application instances are still running, causing 500
errors. The safe pattern uses `SeparateDatabaseAndState` to update Django state
without touching the database, then drops the column in a later deploy.

**Detection:**

```python
if isinstance(operation, migrations.RemoveField):
    # Check if wrapped in SeparateDatabaseAndState
    if not is_inside_separate_database_and_state(operation, migration):
        return self.create_issue(
            severity=Severity.WARNING,
            message="RemoveField without SeparateDatabaseAndState may break "
                    "running instances during rolling deployments."
        )
```

**Trigger conditions:**

- `RemoveField` not wrapped in `SeparateDatabaseAndState`
- `DeleteModel` not wrapped in `SeparateDatabaseAndState`

**Safe pattern:**

```python
# Deploy 1: Remove from Django state only
operations = [
    migrations.SeparateDatabaseAndState(
        state_operations=[
            migrations.RemoveField(model_name='user', name='old_field'),
        ],
        database_operations=[],
    ),
]

# Deploy 2: Actually drop the column
operations = [
    migrations.RunSQL(
        sql="ALTER TABLE myapp_user DROP COLUMN old_field;",
        reverse_sql="ALTER TABLE myapp_user ADD COLUMN old_field varchar(100);",
    ),
]
```

**Implementation file:** `django_safe_migrations/rules/remove_field.py`

**References:**

- [flake8-django-migrations](https://github.com/browniebroke/flake8-django-migrations)
- [Fly.io: Smooth Database Changes in Blue-Green Deployments](https://fly.io/django-beats/smooth-database-changes-in-blue-green-deployments/)
- [Loopwerk: Safe Django migrations (2025)](https://www.loopwerk.io/articles/2025/safe-django-db-migrations/)

______________________________________________________________________

### SM040: volatile_default_with_unique

**Problem:** Adding a field with `unique=True` and a callable default like
`uuid.uuid4` — Django calls the default once and applies the same value to ALL
existing rows, violating the uniqueness constraint. The migration fails
immediately on any populated table.

**Detection:**

```python
if isinstance(operation, migrations.AddField):
    field = operation.field
    if getattr(field, 'unique', False) and callable(getattr(field, 'default', None)):
        return self.create_issue(
            severity=Severity.ERROR,
            message="Adding unique field with callable default applies the same "
                    "value to all existing rows, violating uniqueness."
        )
```

**Trigger conditions:**

- `AddField` with `unique=True` and a callable `default`
- Common case: `UUIDField(default=uuid.uuid4, unique=True)`

**Safe pattern:**

```python
# Migration 1: Add nullable field
migrations.AddField(
    model_name='user', name='uuid',
    field=models.UUIDField(null=True),
)

# Migration 2: Populate unique values per row
def generate_uuids(apps, schema_editor):
    import uuid
    User = apps.get_model('myapp', 'User')
    for user in User.objects.all():
        user.uuid = uuid.uuid4()
        user.save(update_fields=['uuid'])

migrations.RunPython(generate_uuids, migrations.RunPython.noop)

# Migration 3: Make NOT NULL and unique
migrations.AlterField(
    model_name='user', name='uuid',
    field=models.UUIDField(unique=True),
)
```

**Implementation file:** `django_safe_migrations/rules/add_field.py`

**References:**

- [Django Ticket #28299](https://code.djangoproject.com/ticket/28299)
- [DEV: UUID migration in Django with PostgreSQL](https://dev.to/saleor/uuid-migration-in-django-with-postgresql-4p3m)

______________________________________________________________________

### SM041: adding_stored_generated_field

**Problem:** Django 5.0 introduced `GeneratedField`. Adding a
`GeneratedField(db_persist=True)` (STORED) to an existing table causes a full
table rewrite on PostgreSQL and MySQL, taking an ACCESS EXCLUSIVE lock. Virtual
generated fields (`db_persist=False`) are metadata-only but only supported on
PostgreSQL 18+.

**Detection:**

```python
if isinstance(operation, migrations.AddField):
    from django.db.models import GeneratedField
    if isinstance(operation.field, GeneratedField):
        if getattr(operation.field, 'db_persist', False):
            return self.create_issue(
                severity=Severity.WARNING,
                message="Adding stored GeneratedField to existing table causes "
                        "a full table rewrite."
            )
```

**Trigger conditions:**

- `AddField` with `GeneratedField(db_persist=True)` on an existing model
- `AlterField` changing a `GeneratedField` expression (potential rewrite)

**Notes:**

- Django 5.0+ only
- PostgreSQL < 18 only supports STORED, not VIRTUAL
- Expression must be IMMUTABLE on PostgreSQL

**Implementation file:** `django_safe_migrations/rules/add_field.py`

**References:**

- [Django 5.0 release notes](https://docs.djangoproject.com/en/6.0/releases/5.0/)
- [Django GeneratedField docs](https://docs.djangoproject.com/en/stable/ref/models/fields/#generatedfield)

______________________________________________________________________

### SM042: alter_composite_primary_key

**Problem:** Django 5.2 introduced `CompositePrimaryKey`. However, Django does
NOT support migrating to or from a composite primary key after table creation.
`makemigrations` will generate operations, but they will fail at `migrate` time.

**Detection:**

```python
if isinstance(operation, (migrations.AddField, migrations.AlterField)):
    from django.db.models import CompositePrimaryKey
    if isinstance(operation.field, CompositePrimaryKey):
        return self.create_issue(
            severity=Severity.ERROR,
            message="Django does not support migrating to/from "
                    "CompositePrimaryKey after table creation. Use --fake "
                    "or SeparateDatabaseAndState."
        )
```

**Trigger conditions:**

- `AddField` / `AlterField` / `RemoveField` involving `CompositePrimaryKey`
- `ForeignKey` targeting a model with `CompositePrimaryKey` (unsupported)

**Notes:**

- Django 5.2+ only
- Models with composite PKs cannot use Django admin
- `ForeignKey` cannot reference composite PK models; only `ForeignObject` works

**Implementation file:** `django_safe_migrations/rules/alter_field.py`

**References:**

- [Django CompositePrimaryKey docs](https://docs.djangoproject.com/en/5.2/topics/composite-primary-key/)
- [Django Ticket #36401](https://code.djangoproject.com/ticket/36401)

______________________________________________________________________

### SM043: autofield_to_bigautofield_rewrite

**Problem:** Django 6.0 changed `DEFAULT_AUTO_FIELD` from `AutoField` (32-bit)
to `BigAutoField` (64-bit). Projects upgrading without explicitly setting
`DEFAULT_AUTO_FIELD` will see `makemigrations` generate `AlterField` operations
for every model. Each conversion is a table rewrite with ACCESS EXCLUSIVE lock.
Foreign key columns also need updating, compounding the cost.

**Detection:**

```python
if isinstance(operation, migrations.AlterField):
    if is_autofield_to_bigautofield(operation.field):
        return self.create_issue(
            severity=Severity.WARNING,
            message="Converting AutoField to BigAutoField rewrites the table. "
                    "Consider setting DEFAULT_AUTO_FIELD explicitly or migrating "
                    "large tables individually with downtime planning."
        )
```

**Trigger conditions:**

- `AlterField` changing `AutoField` to `BigAutoField`
- `AlterField` changing `SmallAutoField` to `BigAutoField`

**Implementation file:** `django_safe_migrations/rules/alter_field.py`

**References:**

- [Django 6.0 release notes](https://docs.djangoproject.com/en/6.0/releases/6.0/)
- [Django Ticket #36564](https://code.djangoproject.com/ticket/36564)

______________________________________________________________________

### SM044: remove_field_no_cascade

**Problem:** Django 6.0 removed `CASCADE` from `DROP COLUMN` on PostgreSQL.
Previously, dependent database objects (views, materialized views, triggers)
were silently cascade-dropped. Now `RemoveField` will fail if any dependent
objects exist.

**Detection:**

```python
if isinstance(operation, migrations.RemoveField):
    # Django 6.0+ specific
    return self.create_issue(
        severity=Severity.WARNING,
        message="On Django 6.0+, RemoveField no longer uses CASCADE. "
                "Dependent database objects (views, triggers) must be "
                "dropped manually first."
    )
```

**Notes:**

- Django 6.0+ only
- Only affects projects with database views, materialized views, or triggers
  that reference the dropped column
- INFO severity may be more appropriate (only relevant if dependent objects
  exist)

**Implementation file:** `django_safe_migrations/rules/remove_field.py`

**References:**

- [Django 6.0 release notes](https://docs.djangoproject.com/en/6.0/releases/6.0/)
- [Django Ticket #35487](https://code.djangoproject.com/ticket/35487)

______________________________________________________________________

### SM045: volatile_db_default

**Problem:** Django 5.0 introduced `db_default` for database-level defaults.
When `db_default` uses a volatile expression (e.g., `Now()`, `Random()`),
PostgreSQL rewrites the entire table to evaluate the expression for every
existing row — the same danger as adding a NOT NULL column with a volatile
Python default.

**Detection:**

```python
if isinstance(operation, migrations.AddField):
    db_default = getattr(operation.field, 'db_default', None)
    if db_default is not None:
        from django.db.models.functions import Now
        from django.db.models import Value
        if not isinstance(db_default, Value):
            # Non-constant db_default — likely volatile
            return self.create_issue(
                severity=Severity.WARNING,
                message="db_default with volatile expression triggers a "
                        "full table rewrite on existing tables."
            )
```

**Trigger conditions:**

- `AddField` with `db_default=Now()`, `db_default=Random()`, or other
  non-constant expressions
- Constant `db_default` values (integers, strings, `Value(...)`) are safe

**Notes:**

- Django 5.0+ only
- Constant `db_default` is actually safer than Python `default` — the rule
  should only flag volatile expressions

**Implementation file:** `django_safe_migrations/rules/add_field.py`

**References:**

- [Django db_default docs](https://docs.djangoproject.com/en/stable/ref/models/fields/#db-default)
- [PostgreSQL ALTER TABLE behavior](https://www.postgresql.org/docs/current/sql-altertable.html)

______________________________________________________________________

### SM046: rename_field_without_db_column

**Problem:** `RenameField` causes `ALTER TABLE RENAME COLUMN` at the database
level, which is backward-incompatible during rolling deployments — old code
still references the old column name. The safe pattern uses `db_column` to keep
the database column name unchanged while renaming the Python attribute.

**Detection:**

```python
if isinstance(operation, migrations.RenameField):
    return self.create_issue(
        severity=Severity.WARNING,
        message="RenameField renames the database column, breaking old code "
                "during rolling deployments. Use db_column to decouple "
                "the Python name from the database column name."
    )
```

**Notes:**

- Complements SM006 (rename column INFO) with a more actionable recommendation
- May overlap with SM006 — consider merging or having SM006 reference this
  pattern

**Safe pattern:**

```python
# Instead of RenameField, use AlterField with db_column:
class MyModel(models.Model):
    new_name = models.CharField(max_length=100, db_column='old_name')
```

**Implementation file:** `django_safe_migrations/rules/alter_field.py`

**References:**

- [PostHog: Safe Django Migrations](https://posthog.com/handbook/engineering/safe-django-migrations)
- [Loopwerk: Safe Django migrations (2025)](https://www.loopwerk.io/articles/2025/safe-django-db-migrations/)

______________________________________________________________________

### SM047: constraint_missing_not_valid

**Problem:** Adding a CHECK or FOREIGN KEY constraint directly requires a full
table scan with an ACCESS EXCLUSIVE lock. The safe PostgreSQL pattern adds the
constraint with `NOT VALID` first, then validates it separately with
`VALIDATE CONSTRAINT` (which only takes a SHARE UPDATE EXCLUSIVE lock).

**Detection:**

```python
if isinstance(operation, migrations.RunSQL):
    sql = operation.sql.upper()
    if 'ADD CONSTRAINT' in sql:
        if ('CHECK' in sql or 'FOREIGN KEY' in sql):
            if 'NOT VALID' not in sql:
                return self.create_issue(
                    severity=Severity.WARNING,
                    message="ADD CONSTRAINT without NOT VALID locks the table "
                            "for a full scan. Use NOT VALID then VALIDATE "
                            "CONSTRAINT separately."
                )
```

**Trigger conditions:**

- `RunSQL` containing `ADD CONSTRAINT ... CHECK` without `NOT VALID`
- `RunSQL` containing `ADD CONSTRAINT ... FOREIGN KEY` without `NOT VALID`
- PostgreSQL only

**Notes:**

- Broader than SM005 (FK validation) and SM017 (check constraint) which only
  detect Django ORM operations
- This rule catches raw SQL patterns in RunSQL

**Safe pattern:**

```python
migrations.RunSQL(
    sql=[
        "ALTER TABLE orders ADD CONSTRAINT orders_status_check "
        "CHECK (status IN ('new', 'paid', 'shipped')) NOT VALID;",
        "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_check;",
    ],
    reverse_sql="ALTER TABLE orders DROP CONSTRAINT orders_status_check;",
)
```

**Implementation file:** `django_safe_migrations/rules/run_sql.py`

**References:**

- [Squawk: constraint-missing-not-valid](https://squawkhq.com/docs/constraint-missing-not-valid)
- [Django Ticket #31653](https://code.djangoproject.com/ticket/31653)

______________________________________________________________________

### SM048: truncate_in_runsql

**Problem:** `TRUNCATE TABLE` in RunSQL deletes all data from a table. With
`CASCADE`, it also deletes data from all referencing tables — this caused
[Linear's 2024 production incident](https://squawkhq.com/docs/ban-truncate-cascade).

**Detection:**

```python
if isinstance(operation, migrations.RunSQL):
    sql = operation.sql.upper()
    if 'TRUNCATE' in sql:
        return self.create_issue(
            severity=Severity.ERROR,
            message="TRUNCATE in migration deletes all table data. "
                    "TRUNCATE CASCADE also deletes from referencing tables."
        )
```

**Implementation file:** `django_safe_migrations/rules/run_sql.py`

**References:**

- [Squawk: ban-truncate-cascade](https://squawkhq.com/docs/ban-truncate-cascade)

______________________________________________________________________

### SM049: transaction_nesting_in_runsql

**Problem:** Explicit `BEGIN`, `COMMIT`, `ROLLBACK`, or `START TRANSACTION`
statements inside RunSQL in an atomic migration create nested transactions.
PostgreSQL does not support true nested transactions, causing unpredictable
behavior or silent errors.

**Detection:**

```python
if isinstance(operation, migrations.RunSQL):
    sql = operation.sql.upper()
    transaction_keywords = ['BEGIN', 'COMMIT', 'ROLLBACK', 'START TRANSACTION']
    if any(kw in sql for kw in transaction_keywords):
        if migration.atomic:
            return self.create_issue(
                severity=Severity.WARNING,
                message="Explicit transaction control in RunSQL inside an "
                        "atomic migration causes nested transaction issues."
            )
```

**Implementation file:** `django_safe_migrations/rules/run_sql.py`

**References:**

- [Squawk: transaction-nesting](https://squawkhq.com/docs/)

______________________________________________________________________

### SM050: drop_database_in_runsql

**Problem:** `DROP DATABASE` in RunSQL is catastrophic and should never appear
in a migration.

**Detection:**

```python
if isinstance(operation, migrations.RunSQL):
    sql = operation.sql.upper()
    if 'DROP DATABASE' in sql:
        return self.create_issue(
            severity=Severity.ERROR,
            message="DROP DATABASE in migration would destroy the database."
        )
```

**Implementation file:** `django_safe_migrations/rules/run_sql.py`

**References:**

- [Squawk: ban-drop-database](https://squawkhq.com/docs/ban-drop-database)

______________________________________________________________________

### SM051: recommend_db_default

**Problem:** Django 5.0+ supports `db_default` for database-level defaults.
When adding a NOT NULL column, using `db_default` with a constant value is
safer than Python `default` because PostgreSQL can add the column without
rewriting the table (PG 11+), and old code that doesn't know about the column
won't trigger NOT NULL violations on INSERT.

**Detection:**

```python
if isinstance(operation, migrations.AddField):
    field = operation.field
    if not getattr(field, 'null', True):
        if hasattr(field, 'default') and field.default is not NOT_PROVIDED:
            if not hasattr(field, 'db_default') or field.db_default is NOT_PROVIDED:
                return self.create_issue(
                    severity=Severity.INFO,
                    message="Consider using db_default instead of default "
                            "for NOT NULL columns (Django 5.0+)."
                )
```

**Notes:**

- Django 5.0+ only
- Only suggest for constant defaults; volatile expressions have their own risks
  (SM045)

**Implementation file:** `django_safe_migrations/rules/add_field.py`

**References:**

- [django-migration-linter Issue #275](https://github.com/3YOURMIND/django-migration-linter/issues/275)
- [Django db_default docs](https://docs.djangoproject.com/en/stable/ref/models/fields/#db-default)

______________________________________________________________________

### SM052: deprecated_check_constraint_check

**Problem:** `CheckConstraint(check=...)` was deprecated in Django 5.1 and
removed in Django 6.0. Migration files using the old `check` parameter will
break when upgrading to Django 6.0.

**Detection:**

```python
# Parse migration file AST for CheckConstraint with check= keyword
if isinstance(operation, migrations.AddConstraint):
    # Check if constraint uses deprecated 'check' parameter
    # Requires AST analysis of the migration file
    pass
```

**Trigger conditions:**

- `CheckConstraint` created with `check=` keyword argument
- Migration files targeting Django 5.1+ or 6.0

**Implementation file:** `django_safe_migrations/rules/constraints.py`

**References:**

- [Django 5.1 release notes](https://docs.djangoproject.com/en/6.0/releases/5.1/)
- [Django 6.0 release notes](https://docs.djangoproject.com/en/6.0/releases/6.0/)

______________________________________________________________________

### SM053: prefer_bigint_over_smallint

**Problem:** `SmallIntegerField` or `SmallAutoField` as primary key maxes out
at ~32,767 rows. Even small tables can hit this limit unexpectedly.

**Detection:**

```python
if isinstance(field, (models.SmallIntegerField, models.SmallAutoField)):
    if field.primary_key:
        return self.create_issue(
            severity=Severity.WARNING,
            message="SmallIntegerField primary key maxes out at ~32K rows. "
                    "Use BigAutoField instead."
        )
```

**Notes:**

- Extension of SM028 (prefer_bigint_over_int) covering the SmallInteger variant

**Implementation file:** `django_safe_migrations/rules/add_field.py`

**References:**

- [Squawk: prefer-bigint-over-smallint](https://squawkhq.com/docs/prefer-bigint-over-smallint)

______________________________________________________________________

### SM054: multiple_heavy_ops_same_table

**Problem:** When a single migration contains multiple schema-altering
operations on the same table (e.g., AddField + AlterField + AddIndex), the
table lock is held for the combined duration. Splitting into separate migrations
reduces lock duration and deadlock risk.

**Detection:**

```python
def check_migration(self, migration):
    ops_per_table = defaultdict(int)
    for op in migration.operations:
        if hasattr(op, 'model_name'):
            ops_per_table[op.model_name] += 1

    for table, count in ops_per_table.items():
        if count >= 3:
            return self.create_issue(
                severity=Severity.INFO,
                message=f"Migration has {count} schema operations on "
                        f"'{table}'. Consider splitting to reduce lock duration."
            )
```

**Implementation file:** `django_safe_migrations/rules/graph.py`

**References:**

- [Markus Holtermann: Writing Safe Database Migrations in Django](https://markusholtermann.eu/2021/06/writing-safe-database-migrations-in-django/)

______________________________________________________________________

### SM055: unnecessary_atomic_false

**Problem:** Setting `atomic = False` on a migration disables the transaction
wrapper. This is required for concurrent index operations but dangerous when
used unnecessarily — a failed migration leaves the database in an inconsistent
state that cannot be rolled back.

**Detection:**

```python
if not migration.atomic:
    has_concurrent = any(
        isinstance(op, (AddIndexConcurrently, RemoveIndexConcurrently))
        for op in migration.operations
    )
    if not has_concurrent:
        return self.create_issue(
            severity=Severity.INFO,
            message="atomic=False without concurrent operations. "
                    "Non-atomic migrations cannot be rolled back on failure."
        )
```

**Implementation file:** `django_safe_migrations/rules/graph.py`

**References:**

- [Django Migration Operations docs](https://docs.djangoproject.com/en/stable/ref/migration-operations/)

______________________________________________________________________

### SM056: adding_exclusion_constraint

**Problem:** PostgreSQL exclusion constraints cannot be added with `NOT VALID`.
Adding one to an existing table always requires a full table scan with ACCESS
EXCLUSIVE lock. There is no safe incremental approach.

**Detection:**

```python
if isinstance(operation, migrations.AddConstraint):
    from django.contrib.postgres.constraints import ExclusionConstraint
    if isinstance(operation.constraint, ExclusionConstraint):
        return self.create_issue(
            severity=Severity.WARNING,
            message="Exclusion constraints cannot use NOT VALID. Adding one "
                    "requires a full table scan with ACCESS EXCLUSIVE lock."
        )
```

**Notes:**

- PostgreSQL only
- Django 4.1+ (ExclusionConstraint)
- No safe workaround exists — document clearly

**Implementation file:** `django_safe_migrations/rules/constraints.py`

**References:**

- [strong_migrations](https://github.com/ankane/strong_migrations)
- [PostgreSQL ALTER TABLE docs](https://www.postgresql.org/docs/current/sql-altertable.html)

______________________________________________________________________

### SM057: smart_column_type_changes (Enhancement to SM004)

**Problem:** SM004 flags ALL column type changes as warnings. Many type changes
are actually safe on PostgreSQL without a table rewrite (e.g., `varchar(n)` to
`varchar(m)` where m > n, `varchar` to `text`, increasing `numeric` precision).

**Enhancement:** Replace SM004's blanket warning with a matrix of known-safe
type conversions per database.

**Known safe conversions (PostgreSQL):**

| From             | To                      | Safe? |
| ---------------- | ----------------------- | ----- |
| `varchar(n)`     | `varchar(m)`, m>n       | Yes   |
| `varchar(any)`   | `text`                  | Yes   |
| `numeric(p1, s)` | `numeric(p2, s)`, p2>p1 | Yes   |
| `cidr`           | `inet`                  | Yes   |
| `int`            | `bigint`                | Yes   |

**Known unsafe conversions:**

| From         | To                 | Why     |
| ------------ | ------------------ | ------- |
| `varchar(m)` | `varchar(n)`, n\<m | Rewrite |
| `text`       | `varchar(n)`       | Rewrite |
| `int`        | `smallint`         | Rewrite |

**Implementation:** Extend SM004 with a safe-conversion lookup table. Only warn
when the conversion is not in the safe list.

**Implementation file:** `django_safe_migrations/rules/alter_field.py`

**References:**

- [django-pg-zero-downtime-migrations](https://github.com/tbicr/django-pg-zero-downtime-migrations)
- [strong_migrations safe type changes](https://github.com/ankane/strong_migrations)
- [PostgreSQL ALTER TABLE docs](https://www.postgresql.org/docs/current/sql-altertable.html)

______________________________________________________________________

## Features

### High Priority

#### Lint-on-makemigrations

**Description:** Hook into Django's `makemigrations` command to lint migrations
at generation time, before they are committed.

**Implementation:**

```python
# django_safe_migrations/management/commands/makemigrations.py
from django.core.management.commands.makemigrations import Command as BaseCommand

class Command(BaseCommand):
    def handle(self, *args, **options):
        super().handle(*args, **options)
        # Run analysis on newly created migrations
        analyzer = MigrationAnalyzer()
        issues = analyzer.analyze_new_migrations()
        if issues:
            self.stdout.write("Migration safety issues detected:")
            for issue in issues:
                self.stdout.write(f"  {issue}")
            if not options.get('force'):
                self.stdout.write("Use --force to create anyway.")
                sys.exit(1)
```

**Features:**

- Lint immediately after generation
- Prompt user to keep or discard unsafe migrations
- Optional `--force` to skip lint check
- Works with `--dry-run`

**Files to modify:**

- New: `django_safe_migrations/management/commands/makemigrations.py`
- `django_safe_migrations/analyzer.py`

**References:**

- [django-migration-linter makemigrations --lint](https://github.com/3YOURMIND/django-migration-linter)

______________________________________________________________________

#### Migrate-Blocking Mode (System Check)

**Description:** A Django system check that prevents `manage.py migrate` from
running when unsafe migrations exist. Makes the linter enforceable, not just
advisory.

**Implementation:**

```python
# django_safe_migrations/checks.py
from django.core.checks import Error, register

@register('migrations')
def check_migration_safety(app_configs, **kwargs):
    if not getattr(settings, 'SAFE_MIGRATIONS', {}).get('BLOCK_UNSAFE', False):
        return []

    analyzer = MigrationAnalyzer()
    issues = analyzer.analyze_all()
    errors = [i for i in issues if i.severity == Severity.ERROR]

    return [
        Error(
            f"Unsafe migration: {issue.message}",
            hint=issue.suggestion,
            id=f'safe_migrations.{issue.rule_id}',
        )
        for issue in errors
    ]
```

**Configuration:**

```python
SAFE_MIGRATIONS = {
    "BLOCK_UNSAFE": True,  # Prevent migrate when ERROR-level issues exist
}
```

**Files to modify:**

- New: `django_safe_migrations/checks.py`
- `django_safe_migrations/apps.py`

**References:**

- [django-pg-zero-downtime-migrations RAISE_FOR_UNSAFE](https://github.com/tbicr/django-pg-zero-downtime-migrations)

______________________________________________________________________

#### Target Database Override (`--database-vendor`)

**Description:** Specify which database dialect to use for analysis,
independent of the configured database. Critical for teams developing locally on
SQLite but deploying to PostgreSQL.

**Implementation:**

```python
# cli.py / check_migrations.py additions
@click.option('--database-vendor', type=click.Choice(['postgresql', 'mysql', 'sqlite']),
              help='Override detected database vendor for rule analysis')
```

**Configuration:**

```python
SAFE_MIGRATIONS = {
    "DATABASE_VENDOR": "postgresql",  # Override auto-detection
}
```

**Files to modify:**

- `django_safe_migrations/cli.py`
- `django_safe_migrations/management/commands/check_migrations.py`
- `django_safe_migrations/analyzer.py`
- `django_safe_migrations/conf.py`

**References:**

- [django-migration-linter --sql-analyser flag](https://github.com/3YOURMIND/django-migration-linter)

______________________________________________________________________

#### pyproject.toml Configuration

**Description:** Support `[tool.django_safe_migrations]` in `pyproject.toml` as
an alternative to Django settings. Useful for non-Django contexts (CLI usage,
pre-commit) and follows Python packaging conventions.

**Implementation:**

```python
# conf.py additions
import tomllib  # Python 3.11+

def load_pyproject_config():
    try:
        with open('pyproject.toml', 'rb') as f:
            config = tomllib.load(f)
        return config.get('tool', {}).get('django_safe_migrations', {})
    except FileNotFoundError:
        return {}
```

**Supported keys:**

```toml
[tool.django_safe_migrations]
disabled_rules = ["SM006", "SM008"]
disabled_categories = ["informational"]
fail_level = "WARNING"
database_vendor = "postgresql"
```

**Files to modify:**

- `django_safe_migrations/conf.py`
- Documentation

**References:**

- [django-migration-linter setup.cfg/tox.ini support](https://github.com/3YOURMIND/django-migration-linter)

______________________________________________________________________

### Medium Priority

#### Lint Result Caching

**Description:** Cache analysis results using MD5 hashes of migration file
content. Skip unchanged migrations on re-runs. Significant performance win for
large projects with hundreds of migrations.

**Implementation:**

```python
# New file: django_safe_migrations/cache.py
import hashlib
import json
from pathlib import Path

CACHE_DIR = Path('.django_safe_migrations_cache')

def get_file_hash(filepath):
    return hashlib.md5(Path(filepath).read_bytes()).hexdigest()

def is_cached(filepath, cache):
    return get_file_hash(filepath) == cache.get(filepath)
```

**Features:**

- File-based cache in `.django_safe_migrations_cache/`
- `--no-cache` flag to bypass
- `--clear-cache` flag to reset
- Respects configuration changes (re-analyze if rules changed)

**Files:**

- New: `django_safe_migrations/cache.py`
- `django_safe_migrations/analyzer.py`
- `django_safe_migrations/cli.py`

______________________________________________________________________

#### `--since-commit` Filtering

**Description:** Lint only migrations created or modified since a specific git
commit. Works without database access (unlike `--new-only`).

**Implementation:**

```python
# New file: django_safe_migrations/git.py
import subprocess

def get_changed_migrations(commit='origin/main'):
    result = subprocess.run(
        ['git', 'diff', '--name-only', commit, '--', '*/migrations/*.py'],
        capture_output=True, text=True
    )
    return [f for f in result.stdout.strip().split('\n') if f]
```

**CLI:**

```bash
python manage.py check_migrations --since-commit=origin/main
python manage.py check_migrations --since-commit=abc1234
```

**Notes:**

- More flexible than `--new-only` (doesn't need DB)
- More precise than `--diff` (specific commit reference)
- Useful in CI: `--since-commit=${{ github.event.pull_request.base.sha }}`

**Files:**

- New: `django_safe_migrations/git.py`
- `django_safe_migrations/cli.py`
- `django_safe_migrations/management/commands/check_migrations.py`

______________________________________________________________________

#### Selective `--warnings-as-errors`

**Description:** Promote specific warning-level rules to errors. More granular
than the all-or-nothing `--fail-on-warning`.

**Implementation:**

```python
SAFE_MIGRATIONS = {
    "WARNINGS_AS_ERRORS": ["SM002", "SM003"],  # Treat these warnings as errors
}
```

**CLI:**

```bash
python manage.py check_migrations --warnings-as-errors=SM002,SM003
```

**Files to modify:**

- `django_safe_migrations/conf.py`
- `django_safe_migrations/analyzer.py`
- `django_safe_migrations/cli.py`

______________________________________________________________________

#### Target Database Version

**Description:** Specify the production database version so rules can make
version-specific decisions. Reduces false positives.

**Examples:**

- PostgreSQL 12+ can skip NOT NULL validation if a valid CHECK constraint exists
- PostgreSQL 11+ can add columns with constant defaults without table rewrite
- MySQL 8.0+ supports INSTANT column addition
- PostgreSQL 18+ supports VIRTUAL generated columns

**Configuration:**

```python
SAFE_MIGRATIONS = {
    "TARGET_DB_VERSION": "postgresql:14",
}
```

**Files to modify:**

- `django_safe_migrations/conf.py`
- Various rule files that have version-dependent behavior

**References:**

- [strong_migrations target_version](https://github.com/ankane/strong_migrations)

______________________________________________________________________

#### Reverse Migration Safety

**Description:** Analyze reverse migrations (`reverse_sql`, `reverse_code`) for
safety issues, not just forward migrations. A rollback that takes an ACCESS
EXCLUSIVE lock for minutes is just as dangerous.

**Implementation:**

```python
# analyzer.py additions
def analyze_migration(self, migration, check_reverse=False):
    issues = self._analyze_forward(migration)
    if check_reverse:
        issues += self._analyze_reverse(migration)
    return issues
```

**CLI:**

```bash
python manage.py check_migrations --check-reverse
```

**Files to modify:**

- `django_safe_migrations/analyzer.py`
- `django_safe_migrations/cli.py`

______________________________________________________________________

#### Deployment Phase Classification

**Description:** Automatically classify each migration as "pre-deploy safe" or
"post-deploy only" based on operation types. Integrates with CI to prevent
running destructive migrations before code is deployed.

**Classification logic:**

- **Pre-deploy safe:** AddField (nullable), AddIndex (concurrent),
  AddConstraint (NOT VALID), RunSQL (DDL with IF NOT EXISTS)
- **Post-deploy only:** RemoveField, DeleteModel, RenameField, RenameModel,
  AlterField (type change)

**Output:**

```bash
python manage.py check_migrations --classify-phase

myapp/0042_add_email:        pre-deploy
myapp/0043_drop_old_email:   post-deploy
myapp/0044_backfill:         post-deploy
```

**Files:**

- New: `django_safe_migrations/classifier.py`
- `django_safe_migrations/cli.py`

**References:**

- [django-safemigrate](https://github.com/aspiredu/django-safemigrate)
- [Better Simple: Reducing downtime with django-safemigrate](https://www.better-simple.com/django/2024/07/22/django-safemigrate/)

______________________________________________________________________

#### GitHub PR Comment Reporter

**Description:** Post inline review comments on migration files in GitHub PRs.
Goes beyond SARIF (which only shows in Code Scanning) to provide visible PR
feedback.

**Implementation:**

```python
# New file: django_safe_migrations/reporters/github_pr.py
class GitHubPRReporter(BaseReporter):
    def report(self, issues):
        # Use GitHub API to post review comments
        # Requires GITHUB_TOKEN
        pass
```

**CLI:**

```bash
python manage.py check_migrations --format=github-pr --pr=123
```

**References:**

- [Squawk GitHub App](https://squawkhq.com/)

______________________________________________________________________

#### PostGIS Backend Support

**Description:** PostGIS reports `database_vendor = 'postgis'` instead of
`'postgresql'`, causing all PostgreSQL-specific rules to be silently skipped.

**Fix:**

```python
# utils.py or analyzer.py
def get_normalized_vendor(connection):
    vendor = connection.vendor
    if vendor == 'postgis':
        return 'postgresql'
    return vendor
```

**Files to modify:**

- `django_safe_migrations/analyzer.py` or `django_safe_migrations/utils.py`

**References:**

- [django-migration-linter Issue #169](https://github.com/3YOURMIND/django-migration-linter/issues/169)

______________________________________________________________________

### Low Priority (Future / v0.7.0+)

| Feature                            | Description                                                  |
| ---------------------------------- | ------------------------------------------------------------ |
| Migration complexity score         | Risk rating per migration based on operation types           |
| Auto-fix generator                 | Generate safe multi-step migration patterns from unsafe ones |
| Squashing safety check             | Validate squashed migrations for lost ops and broken deps    |
| `--ignore-initial-migrations`      | Skip `0001_initial.py` automatically                         |
| MySQL ALGORITHM awareness          | Detect MySQL INSTANT/INPLACE-safe operations                 |
| Lock timeout retry recommendations | Suggest retry-with-backoff patterns                          |
| Auto-ANALYZE after index creation  | Recommend `ANALYZE` after creating indexes                   |
| Circular dependency detection      | Detect cycles in cross-app migration dependency graph        |

______________________________________________________________________

## Testing Requirements

### New Test Files

- `tests/unit/rules/test_runpython_import_rules.py` — SM037 tests
- `tests/unit/rules/test_migration_structure_rules.py` — SM038, SM054, SM055
- `tests/unit/rules/test_deployment_safety_rules.py` — SM039, SM046
- `tests/unit/rules/test_django5_rules.py` — SM041, SM042, SM045, SM051, SM052
- `tests/unit/rules/test_django6_rules.py` — SM043, SM044
- `tests/unit/rules/test_runsql_extra_rules.py` — SM047, SM048, SM049, SM050
- `tests/unit/test_makemigrations.py` — Lint-on-makemigrations tests
- `tests/unit/test_system_check.py` — Migrate-blocking mode tests
- `tests/unit/test_cache.py` — Lint caching tests
- `tests/unit/test_classifier.py` — Deployment phase classification tests
- `tests/unit/reporters/test_github_pr.py` — GitHub PR reporter tests

### Integration Tests

- Django 5.0+ GeneratedField migration analysis
- Django 5.2+ CompositePrimaryKey detection
- Django 6.0 DEFAULT_AUTO_FIELD migration detection
- PostGIS backend vendor normalization
- pyproject.toml configuration loading

______________________________________________________________________

## Breaking Changes

None planned.

______________________________________________________________________

## Migration Guide

N/A — no breaking changes.

______________________________________________________________________

## Priority Legend

- **High Priority**: Core functionality, high user impact, common mistakes
- **Medium Priority**: Nice-to-have, moderate impact, reduces false positives
- **Low Priority**: Future enhancements, can defer to v0.7.0+

______________________________________________________________________

## References

- [django-migration-linter](https://github.com/3YOURMIND/django-migration-linter)
- [django-pg-zero-downtime-migrations](https://github.com/tbicr/django-pg-zero-downtime-migrations)
- [Squawk PostgreSQL Linter](https://squawkhq.com/)
- [strong_migrations (Ruby)](https://github.com/ankane/strong_migrations)
- [flake8-django-migrations](https://github.com/browniebroke/flake8-django-migrations)
- [django-safemigrate](https://github.com/aspiredu/django-safemigrate)
- [Django 5.0 Release Notes](https://docs.djangoproject.com/en/6.0/releases/5.0/)
- [Django 5.1 Release Notes](https://docs.djangoproject.com/en/6.0/releases/5.1/)
- [Django 5.2 Release Notes](https://docs.djangoproject.com/en/6.0/releases/5.2/)
- [Django 6.0 Release Notes](https://docs.djangoproject.com/en/6.0/releases/6.0/)
- [PostHog: Safe Django Migrations](https://posthog.com/handbook/engineering/safe-django-migrations)
- [Fly.io: Smooth Database Changes in Blue-Green Deployments](https://fly.io/django-beats/smooth-database-changes-in-blue-green-deployments/)
- [Loopwerk: Safe Django migrations (2025)](https://www.loopwerk.io/articles/2025/safe-django-db-migrations/)
- [Vinta Software: Django Migration Best Practices](https://www.vintasoftware.com/blog/writing-django-migrations)
- [Markus Holtermann: Writing Safe Database Migrations](https://markusholtermann.eu/2021/06/writing-safe-database-migrations-in-django/)
- [DjangoCon US 2025: Peaceful Django Migrations](https://2025.djangocon.us/talks/peaceful-django-migrations/)
