Design notes

migraguard is a pipeline of small, separately testable stages (lint.py):

discover(root) -> Project            AST parse of every */migrations/[0-9]*.py (+ models.py class names)
scope (optional git base)            which migrations / apps are "in scope"
analyze(project, scope)              operation rules + graph rules      -> raw findings
run_sql_mode(...) (optional)         Postgres SQL via schema editor      -> SQL attached / MG2xx findings
apply_policy(...)                    ignores, severity overrides, table-size escalation
report                               rich | json | github | sarif

1. Discovery without Django

A migration is parsed, never imported. parse_migration finds the Migration class and reads four class attributes with ast: operations (a literal list of calls), dependencies and replaces (lists of 2-tuples), and atomic. Each operation (and each field) is wrapped in CallInfo, which resolves a keyword or positional argument and evaluates it with ast.literal_eval. When a value is not a literal, lit() returns the UNKNOWN sentinel and rules skip the case rather than guess. That is why migraguard produces no false positive for null=SOME_FLAG.

The app label is the directory holding migrations/. Directories such as .venv, node_modules, site-packages and .git are not walked.

2. The graph

Project offers three views, all computed from dependencies and replaces:

Worked example. 0001_a, 0002_b, a squash 0001_squashed_0002_b with replaces = [0001_a, 0002_b], and 0003_c depending on ("app", "0002_b"):

effective nodes   0001_squashed_0002_b, 0003_c     (0001_a, 0002_b are replaced)
0003_c depends on 0002_b  ->  remapped to 0001_squashed_0002_b
leaves            [0003_c]                         no conflict

__first__ resolves to the app's root migration, __latest__ to its leaf. swappable_dependency(...) and dependencies on apps that are not in the scanned tree are ignored. A dependency on a missing migration of a scanned app is MG103. Apps with an unparseable migration (MG104) are excluded from conflict detection because their dependencies are unknown.

MG101 compares the concrete model classes found in models.py / models/*.py (classes deriving from Model or from another model in the file, minus abstract = True) with the models that exist after replaying the app's migrations.

3. Replaying state for AlterField

AlterField cannot be judged from its own text: models.CharField(max_length=100) is dangerous only if it was 200 before. SchemaState replays CreateModel, AddField, AlterField, RemoveField, RenameField, RenameModel, DeleteModel, AlterModelTable, AlterUniqueTogether and the state_operations of SeparateDatabaseAndState in dependency order, keeping the last CallInfo per field plus table names (<app>_<model> unless db_table is set) and unique_together.

All migrations of an app are replayed, but rules only run for migrations in scope. In git base mode that means old migrations feed the state and only new ones produce findings. Rules run before an operation is applied to the state, so they see the "old" field.

_alter_reasons compares old and new:

change verdict
null=True -> NOT NULL scan under ACCESS EXCLUSIVE
CharField/SlugField/EmailField/URLField among themselves same column type
varchar family -> TextField no rewrite, safe
any other class change (IntegerField -> BigIntegerField) table rewrite
max_length decreased scan, may fail on data
DecimalField max_digits/decimal_places changed rewrite
anything else (default, verbose_name, longer max_length) no database work

Example from the sample project: catalog.0001 creates Product.name = CharField(max_length=200); 0005 has AlterField(name, CharField(max_length=100)), giving MG006 ... shrinks max_length 200 -> 100.

Operations on a model created in the same migration never fire (new_models): an empty table cannot be locked for long, so CreateModel followed by AddIndex is fine.

4. Rule semantics worth knowing

5. SQL mode

Django's Postgres backend needs a server for a few things: a version check, and compose_sql, which asks the server to render parameters. sqlmode.generate_sql removes both dependencies:

  1. build a DatabaseWrapper with dummy settings and register it as connections["migraguard"];
  2. pre-seed its pg_version so feature flags do not connect;
  3. replace ops.compose_sql with a local renderer based on psycopg.sql.quote;
  4. for each migration: loader.project_state(key, at_end=False) for the state before it, then migration.apply(state, schema_editor, collect_sql=True) with schema_editor(collect_sql=True, atomic=False).

Result for accounts.0002_customer_phone:

ALTER TABLE "accounts_customer" ADD COLUMN "phone" varchar(20) DEFAULT '' NOT NULL;
ALTER TABLE "accounts_customer" ALTER COLUMN "phone" DROP DEFAULT;

The DROP DEFAULT on the second line is the SQL-level proof of MG002.

scan_statements pattern-matches each statement (whitespace and case normalised; statements touching a table created earlier in the same migration are skipped). Every SQL rule has a family of AST rules (SQL_FAMILY): for example MG202 CREATE INDEX belongs with MG007, MG008, MG011. When an AST finding of that family exists for the same migration and table, the statement is attached to it (finding.sql) and no duplicate is reported. If the AST rules missed it, for instance a raw RunSQL('ALTER TABLE "t" ADD COLUMN "x" integer NOT NULL'), a new MG201 finding is emitted. Because the match happens before ignores are applied, ignoring MG007 also silences the corresponding MG202.

6. Policy: ignores, severities, table sizes

apply_policy runs last, in this order for each finding: drop if disabled (ignore, severity off, exclude-apps, ignore-migrations, inline comment); apply the severity override; then, if a sizes file was given and the rule is size-aware, raise to error when rows >= big-table-rows.

Inline comments apply when the comment line is between one line above the operation's first line and its last line, or when it appears before class Migration (file-wide).

Table sizes: sizes.csv keys are database table names (case-insensitive); the finding's table comes from state (db_table, renames) so a renamed model is looked up under its new table.

7. Output contracts