migrolint 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 migrolint contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: migrolint
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/migrolint-py
8
+ Project-URL: Repository, https://github.com/jjdoor/migrolint-py
9
+ Project-URL: Issues, https://github.com/jjdoor/migrolint-py/issues
10
+ Keywords: migration,migrations,database,linter,sql,flyway,golang-migrate,dbmate,goose,cli,pre-commit,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # migrolint
26
+
27
+ **A framework-agnostic linter for your database migrations folder.** It catches
28
+ the boring, expensive mistakes that pass locally and only blow up when CI
29
+ actually runs the migrations — duplicate version numbers, sequence gaps, and
30
+ `up` migrations with no matching `down`. No database connection, no framework
31
+ lock-in, **zero dependencies** (pure standard library).
32
+
33
+ ```bash
34
+ pip install migrolint
35
+
36
+ migrolint # auto-detects ./migrations, db/migrations, ...
37
+ migrolint db/migrations --strict
38
+ ```
39
+
40
+ ## The problem
41
+
42
+ Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
43
+ Now your migrations folder has two migrations claiming version `0007`. Your
44
+ runner picks one and silently skips the other — or aborts the whole deploy.
45
+ This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
46
+ in every sequence-numbered migration tool.
47
+
48
+ The existing linters (`django-migration-linter`, Flyway's own checks) are tied
49
+ to one framework or need a live database. If you use raw SQL with goose,
50
+ dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
51
+ *looks at the filenames* and tells you they're sane. That's migrolint.
52
+
53
+ ## What it checks
54
+
55
+ | Rule | Severity | Meaning |
56
+ |------|----------|---------|
57
+ | `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
58
+ | `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
59
+ | `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
60
+ | `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
61
+
62
+ ## Naming conventions it understands
63
+
64
+ migrolint reads version numbers out of the filename, across the conventions
65
+ people actually use:
66
+
67
+ | Convention | Example |
68
+ |------------|---------|
69
+ | Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
70
+ | golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
71
+ | goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
72
+ | minimalist | `1_init.sql`, `2-add-index.sql` |
73
+
74
+ Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
75
+ meant to be contiguous). Well-known non-migration files (`schema.rb`,
76
+ `structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ migrolint # scan the first migrations dir it finds
82
+ migrolint db/migrations # scan a specific dir
83
+ migrolint app/migrations svc/migrations # scan several
84
+ migrolint --json # machine-readable, for tooling
85
+ migrolint --strict # warnings become errors (exit 1) — good for CI
86
+ migrolint --ext .sql,.py # override which extensions count
87
+ migrolint --ignore baseline.sql,seed.sql
88
+ ```
89
+
90
+ You can also run it as a module: `python -m migrolint db/migrations`.
91
+
92
+ ### As a pre-commit / CI gate
93
+
94
+ ```yaml
95
+ # .pre-commit-config.yaml
96
+ - repo: local
97
+ hooks:
98
+ - id: migrolint
99
+ name: migrolint
100
+ entry: migrolint --strict
101
+ language: system
102
+ pass_filenames: false
103
+ ```
104
+
105
+ ```yaml
106
+ # GitHub Actions
107
+ - run: pipx run migrolint --strict
108
+ ```
109
+
110
+ ## Example output
111
+
112
+ ```
113
+ migrolint db/migrations (14 files, 12 migrations)
114
+
115
+ ✗ DUPE_NUM version 0007 used by 2 migrations:
116
+ 0007_add_index.up.sql
117
+ 0007_add_orders_fk.up.sql
118
+ ⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
119
+ ⚠ SEQ_GAP missing version(s): 5
120
+
121
+ 1 error, 2 warnings.
122
+ ```
123
+
124
+ ## Exit codes
125
+
126
+ | Code | Meaning |
127
+ |------|---------|
128
+ | `0` | clean (or only warnings, without `--strict`) |
129
+ | `1` | errors found — or warnings, when `--strict` is set |
130
+ | `2` | usage / IO error (no migrations dir, unreadable path) |
131
+
132
+ ## Also available for Node
133
+
134
+ Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
135
+ (source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
136
+ filenames identically, so a mixed-language team gets the same verdict.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,116 @@
1
+ # migrolint
2
+
3
+ **A framework-agnostic linter for your database migrations folder.** It catches
4
+ the boring, expensive mistakes that pass locally and only blow up when CI
5
+ actually runs the migrations — duplicate version numbers, sequence gaps, and
6
+ `up` migrations with no matching `down`. No database connection, no framework
7
+ lock-in, **zero dependencies** (pure standard library).
8
+
9
+ ```bash
10
+ pip install migrolint
11
+
12
+ migrolint # auto-detects ./migrations, db/migrations, ...
13
+ migrolint db/migrations --strict
14
+ ```
15
+
16
+ ## The problem
17
+
18
+ Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
19
+ Now your migrations folder has two migrations claiming version `0007`. Your
20
+ runner picks one and silently skips the other — or aborts the whole deploy.
21
+ This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
22
+ in every sequence-numbered migration tool.
23
+
24
+ The existing linters (`django-migration-linter`, Flyway's own checks) are tied
25
+ to one framework or need a live database. If you use raw SQL with goose,
26
+ dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
27
+ *looks at the filenames* and tells you they're sane. That's migrolint.
28
+
29
+ ## What it checks
30
+
31
+ | Rule | Severity | Meaning |
32
+ |------|----------|---------|
33
+ | `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
34
+ | `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
35
+ | `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
36
+ | `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
37
+
38
+ ## Naming conventions it understands
39
+
40
+ migrolint reads version numbers out of the filename, across the conventions
41
+ people actually use:
42
+
43
+ | Convention | Example |
44
+ |------------|---------|
45
+ | Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
46
+ | golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
47
+ | goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
48
+ | minimalist | `1_init.sql`, `2-add-index.sql` |
49
+
50
+ Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
51
+ meant to be contiguous). Well-known non-migration files (`schema.rb`,
52
+ `structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ migrolint # scan the first migrations dir it finds
58
+ migrolint db/migrations # scan a specific dir
59
+ migrolint app/migrations svc/migrations # scan several
60
+ migrolint --json # machine-readable, for tooling
61
+ migrolint --strict # warnings become errors (exit 1) — good for CI
62
+ migrolint --ext .sql,.py # override which extensions count
63
+ migrolint --ignore baseline.sql,seed.sql
64
+ ```
65
+
66
+ You can also run it as a module: `python -m migrolint db/migrations`.
67
+
68
+ ### As a pre-commit / CI gate
69
+
70
+ ```yaml
71
+ # .pre-commit-config.yaml
72
+ - repo: local
73
+ hooks:
74
+ - id: migrolint
75
+ name: migrolint
76
+ entry: migrolint --strict
77
+ language: system
78
+ pass_filenames: false
79
+ ```
80
+
81
+ ```yaml
82
+ # GitHub Actions
83
+ - run: pipx run migrolint --strict
84
+ ```
85
+
86
+ ## Example output
87
+
88
+ ```
89
+ migrolint db/migrations (14 files, 12 migrations)
90
+
91
+ ✗ DUPE_NUM version 0007 used by 2 migrations:
92
+ 0007_add_index.up.sql
93
+ 0007_add_orders_fk.up.sql
94
+ ⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
95
+ ⚠ SEQ_GAP missing version(s): 5
96
+
97
+ 1 error, 2 warnings.
98
+ ```
99
+
100
+ ## Exit codes
101
+
102
+ | Code | Meaning |
103
+ |------|---------|
104
+ | `0` | clean (or only warnings, without `--strict`) |
105
+ | `1` | errors found — or warnings, when `--strict` is set |
106
+ | `2` | usage / IO error (no migrations dir, unreadable path) |
107
+
108
+ ## Also available for Node
109
+
110
+ Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
111
+ (source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
112
+ filenames identically, so a mixed-language team gets the same verdict.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "migrolint"
7
+ version = "0.1.0"
8
+ description = "Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yyfjj" }]
13
+ keywords = ["migration", "migrations", "database", "linter", "sql", "flyway", "golang-migrate", "dbmate", "goose", "cli", "pre-commit", "devops"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Database",
22
+ "Topic :: Software Development :: Quality Assurance",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/jjdoor/migrolint-py"
29
+ Repository = "https://github.com/jjdoor/migrolint-py"
30
+ Issues = "https://github.com/jjdoor/migrolint-py/issues"
31
+
32
+ [project.scripts]
33
+ migrolint = "migrolint.cli:main"
34
+
35
+ [tool.setuptools]
36
+ package-dir = { "" = "src" }
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """migrolint — framework-agnostic linter for database migration folders. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,178 @@
1
+ """migrolint command-line interface."""
2
+
3
+ import json as _json
4
+ import os
5
+ import sys
6
+
7
+ from . import core
8
+
9
+ VERSION = "0.1.0"
10
+
11
+ # ---- tiny color helpers (no dep) ----
12
+ _COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
13
+
14
+
15
+ def _c(code, s):
16
+ return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
17
+
18
+
19
+ def red(s): return _c("31", s)
20
+ def green(s): return _c("32", s)
21
+ def yellow(s): return _c("33", s)
22
+ def dim(s): return _c("2", s)
23
+ def bold(s): return _c("1", s)
24
+
25
+
26
+ # Directories migrolint looks in when you don't name one.
27
+ CANDIDATE_DIRS = [
28
+ "migrations", "db/migrations", "database/migrations", "migrate",
29
+ "sql/migrations", "priv/repo/migrations", "supabase/migrations",
30
+ ]
31
+
32
+ HELP = f"""{bold('migrolint')} — framework-agnostic sanity check for your migrations folder.
33
+
34
+ {bold('Usage')}
35
+ migrolint [dir...] Scan migration dirs (auto-detects common paths)
36
+ migrolint --json Machine-readable output
37
+ migrolint --strict Treat warnings as errors (exit 1)
38
+ migrolint --ext .sql,.py Override which extensions count as migrations
39
+ migrolint --ignore a,b Extra filenames to skip
40
+ migrolint --version
41
+
42
+ {bold('Checks')}
43
+ {red('DUPE_NUM')} two migrations share a version number {dim('(error)')}
44
+ {yellow('MISSING_DOWN')} an up migration with no matching .down {dim('(warning)')}
45
+ {yellow('SEQ_GAP')} a hole in an integer sequence {dim('(warning)')}
46
+ {yellow('BAD_FORMAT')} a file whose name no convention parses {dim('(warning)')}
47
+
48
+ {bold('Recognizes')} Flyway (V1__/U1__/R__) · golang-migrate/dbmate (0001_x.up.sql) ·
49
+ goose/Rails timestamps · minimalist (1_x.sql)
50
+
51
+ {bold('Exit')} 0 clean · 1 errors (or warnings with --strict) · 2 usage/IO error
52
+ """
53
+
54
+
55
+ def die(msg):
56
+ sys.stderr.write(red(f"migrolint: {msg}\n"))
57
+ sys.exit(2)
58
+
59
+
60
+ def parse_args(argv):
61
+ opts = {"dirs": [], "json": False, "strict": False, "quiet": False, "exts": None, "ignore": None}
62
+ i = 0
63
+ while i < len(argv):
64
+ a = argv[i]
65
+ if a == "--json":
66
+ opts["json"] = True
67
+ elif a == "--strict":
68
+ opts["strict"] = True
69
+ elif a in ("--quiet", "-q"):
70
+ opts["quiet"] = True
71
+ elif a == "--ext":
72
+ i += 1
73
+ if i >= len(argv):
74
+ die("--ext needs a comma-separated list, e.g. --ext .sql,.py")
75
+ opts["exts"] = [e if e.startswith(".") else "." + e
76
+ for e in (x.strip().lower() for x in argv[i].split(",")) if e]
77
+ elif a == "--ignore":
78
+ i += 1
79
+ if i >= len(argv):
80
+ die("--ignore needs a comma-separated list of filenames")
81
+ opts["ignore"] = [s.strip() for s in argv[i].split(",") if s.strip()]
82
+ elif a.startswith("-"):
83
+ die(f"unknown flag: {a} (try --help)")
84
+ else:
85
+ opts["dirs"].append(a)
86
+ i += 1
87
+ return opts
88
+
89
+
90
+ def resolve_dirs(requested):
91
+ if requested:
92
+ return requested
93
+ found = [d for d in CANDIDATE_DIRS if os.path.isdir(d)]
94
+ if not found:
95
+ die("no migrations directory found (looked for: " + ", ".join(CANDIDATE_DIRS) + ").\n"
96
+ " Pass a path explicitly: migrolint path/to/migrations")
97
+ return [found[0]]
98
+
99
+
100
+ def analyze_dir(directory, opts):
101
+ if not os.path.exists(directory):
102
+ die(f"cannot read {directory}: no such file or directory")
103
+ if not os.path.isdir(directory):
104
+ die(f"not a directory: {directory}")
105
+ try:
106
+ names = os.listdir(directory)
107
+ except OSError as e:
108
+ die(f"cannot read {directory}: {e}")
109
+ ignore = (core.DEFAULT_IGNORE | set(opts["ignore"])) if opts["ignore"] else core.DEFAULT_IGNORE
110
+ result = core.analyze(names, ignore=ignore, exts=opts["exts"] or core.MIGRATION_EXTS)
111
+ result["dir"] = directory
112
+ return result
113
+
114
+
115
+ def fmt_missing(missing):
116
+ if len(missing) <= 15:
117
+ return ", ".join(str(v) for v in missing)
118
+ return ", ".join(str(v) for v in missing[:15]) + dim(f" (+{len(missing) - 15} more)")
119
+
120
+
121
+ def print_human(report, opts):
122
+ errors, warnings, stats = report["errors"], report["warnings"], report["stats"]
123
+ mig = stats["migrations"]
124
+ if not errors and not warnings:
125
+ if not opts["quiet"]:
126
+ tail = dim("— {} migration(s), no issues".format(mig))
127
+ sys.stdout.write("{} {} {}\n".format(green("✓"), report["dir"], tail))
128
+ return
129
+ info = dim("({} files, {} migrations)".format(stats["scanned"], mig))
130
+ sys.stdout.write("{} {} {}\n\n".format(bold("migrolint"), report["dir"], info))
131
+ for e in errors:
132
+ if e["rule"] == "DUPE_NUM":
133
+ sys.stdout.write(f" {red('✗ DUPE_NUM')} version {bold(e['version'])} used by {len(e['files'])} migrations:\n")
134
+ for f in e["files"]:
135
+ sys.stdout.write(f" {f}\n")
136
+ for w in warnings:
137
+ if w["rule"] == "MISSING_DOWN":
138
+ sys.stdout.write(f" {yellow('⚠ MISSING_DOWN')} {w['file']} {dim('— no matching .down file')}\n")
139
+ elif w["rule"] == "SEQ_GAP":
140
+ sys.stdout.write(f" {yellow('⚠ SEQ_GAP')} missing version(s): {fmt_missing(w['missing'])}\n")
141
+ elif w["rule"] == "BAD_FORMAT":
142
+ sys.stdout.write(f" {yellow('⚠ BAD_FORMAT')} {w['file']} {dim('— no recognized version prefix')}\n")
143
+ parts = []
144
+ if errors:
145
+ parts.append(red(f"{len(errors)} error{'s' if len(errors) > 1 else ''}"))
146
+ if warnings:
147
+ parts.append(yellow(f"{len(warnings)} warning{'s' if len(warnings) > 1 else ''}"))
148
+ sys.stdout.write("\n" + ", ".join(parts) + ".\n")
149
+
150
+
151
+ def main(argv=None):
152
+ argv = list(sys.argv[1:] if argv is None else argv)
153
+ if "-h" in argv or "--help" in argv:
154
+ sys.stdout.write(HELP)
155
+ return 0
156
+ if "-v" in argv or "--version" in argv:
157
+ sys.stdout.write(VERSION + "\n")
158
+ return 0
159
+
160
+ opts = parse_args(argv)
161
+ dirs = resolve_dirs(opts["dirs"])
162
+ reports = [analyze_dir(d, opts) for d in dirs]
163
+
164
+ if opts["json"]:
165
+ payload = [{"dir": r["dir"], "stats": r["stats"], "errors": r["errors"], "warnings": r["warnings"]}
166
+ for r in reports]
167
+ sys.stdout.write(_json.dumps(payload[0] if len(dirs) == 1 else payload, indent=2) + "\n")
168
+ else:
169
+ for i, r in enumerate(reports):
170
+ if i > 0:
171
+ sys.stdout.write("\n")
172
+ print_human(r, opts)
173
+
174
+ total_errors = sum(len(r["errors"]) for r in reports)
175
+ total_warnings = sum(len(r["warnings"]) for r in reports)
176
+ if total_errors > 0 or (opts["strict"] and total_warnings > 0):
177
+ return 1
178
+ return 0
@@ -0,0 +1,161 @@
1
+ """migrolint core — pure migration-filename analysis. No fs, no DB, no clock.
2
+
3
+ Database migration folders break in three boring, expensive ways that no
4
+ framework-agnostic tool catches before CI runs the migrations:
5
+
6
+ - DUPE_NUM two migrations claim the same version number. The classic
7
+ merge-of-two-branches collision (golang-migrate #574) — the
8
+ runner picks one, silently skips the other, or aborts.
9
+ - MISSING_DOWN an ``up`` migration with no matching ``down`` (only flagged
10
+ for projects that use up/down splits).
11
+ - SEQ_GAP a hole in an integer sequence — usually a deleted/un-merged
12
+ migration. Advisory.
13
+ - BAD_FORMAT a file in the dir whose name no convention recognizes.
14
+
15
+ The parser understands the conventions people actually use: Flyway
16
+ (``V1__x.sql``, ``U1__x.sql``, ``R__x.sql``), golang-migrate / dbmate
17
+ (``0001_x.up.sql`` + ``0001_x.down.sql``), goose / Rails timestamps
18
+ (``20230101120000_x.sql``), and the minimalist ``1_x.sql``. This module mirrors
19
+ the Node port function-for-function so both produce identical verdicts.
20
+ """
21
+
22
+ import re
23
+
24
+ # File extensions that can hold a migration body. Anything else in the folder
25
+ # is ignored outright (READMEs, .gitkeep) rather than flagged.
26
+ MIGRATION_EXTS = [".sql", ".py", ".rb", ".js", ".ts", ".go"]
27
+
28
+ # Well-known non-migration files that legitimately live in a migrations dir.
29
+ DEFAULT_IGNORE = {
30
+ "schema.rb", "structure.sql", "schema.sql", "seeds.rb", "seed.sql", "database.sql",
31
+ }
32
+
33
+ # Integer versions at or above this look like dates/timestamps, not an
34
+ # incrementing sequence — so they're exempt from gap analysis.
35
+ SEQ_MAX = 1_000_000
36
+
37
+ _DIR_SUFFIX_RE = re.compile(r"\.(up|down)(\.(?:sql|py|rb|js|ts|go))$", re.IGNORECASE)
38
+ _FLYWAY_RE = re.compile(r"^([VUR])(\d[\d.]*)?__(.*)$")
39
+ _GENERIC_RE = re.compile(r"^(\d+)[_-](.*)$")
40
+ _BARE_NUM_RE = re.compile(r"^(\d+)$")
41
+
42
+
43
+ def ext_of(name):
44
+ i = name.rfind(".")
45
+ return name[i:].lower() if i > 0 else ""
46
+
47
+
48
+ def parse_migration(filename):
49
+ """Parse a migration filename into structured fields, or ``None`` if no
50
+ recognized convention matches (a BAD_FORMAT candidate).
51
+
52
+ Returns a dict with: ``versionRaw`` (str|None), ``numeric`` (int|None),
53
+ ``direction`` ('up'|'down'|'both'), ``split`` (bool), ``repeatable`` (bool),
54
+ ``label`` (str).
55
+ """
56
+ # 1. Peel off an .up/.down suffix (golang-migrate / dbmate split style).
57
+ direction = "both"
58
+ split = False
59
+ m = _DIR_SUFFIX_RE.search(filename)
60
+ if m:
61
+ direction = m.group(1).lower()
62
+ split = True
63
+ stem = filename[: m.start()]
64
+ else:
65
+ ext = ext_of(filename)
66
+ stem = filename[: -len(ext)] if ext else filename
67
+
68
+ # 2. Flyway: V/U/R prefix + double-underscore description.
69
+ fly = _FLYWAY_RE.match(stem)
70
+ if fly:
71
+ kind = fly.group(1).upper()
72
+ version_raw = fly.group(2) or None
73
+ if kind == "R":
74
+ return {"versionRaw": None, "numeric": None, "direction": "both",
75
+ "split": False, "repeatable": True, "label": fly.group(3)}
76
+ numeric = int(version_raw) if version_raw and version_raw.isdigit() else None
77
+ return {
78
+ "versionRaw": version_raw,
79
+ "numeric": numeric,
80
+ "direction": "down" if kind == "U" else (direction if split else "up"),
81
+ "split": split,
82
+ "repeatable": False,
83
+ "label": fly.group(3),
84
+ }
85
+
86
+ # 3. Generic numeric prefix: "0001_create", "20230101120000_init", "1-init".
87
+ gen = _GENERIC_RE.match(stem) or _BARE_NUM_RE.match(stem)
88
+ if gen:
89
+ version_raw = gen.group(1)
90
+ numeric = int(version_raw) if version_raw.isdigit() else None
91
+ label = gen.group(2) if gen.re.groups >= 2 else ""
92
+ return {"versionRaw": version_raw, "numeric": numeric, "direction": direction,
93
+ "split": split, "repeatable": False, "label": label}
94
+
95
+ return None # unrecognized — BAD_FORMAT
96
+
97
+
98
+ def analyze(filenames, ignore=None, exts=None):
99
+ """Analyze a folder's filenames. Returns ``{errors, warnings, stats}``.
100
+
101
+ Pure: give it the list of basenames, it gives you the verdict.
102
+ """
103
+ ignore = ignore if ignore is not None else DEFAULT_IGNORE
104
+ exts = exts if exts is not None else MIGRATION_EXTS
105
+
106
+ # Sort first so the verdict is deterministic regardless of listdir order.
107
+ candidates = [f for f in sorted(filenames) if ext_of(f) in exts and f not in ignore]
108
+ parsed = []
109
+ bad_format = []
110
+ for f in candidates:
111
+ m = parse_migration(f)
112
+ if m is None:
113
+ bad_format.append(f)
114
+ else:
115
+ parsed.append(dict(file=f, **m))
116
+
117
+ versioned = [p for p in parsed if not p["repeatable"] and p["versionRaw"] is not None]
118
+ errors = []
119
+ warnings = []
120
+
121
+ # DUPE_NUM — same (version, direction) used by more than one file. up & down
122
+ # of the same version are a legitimate pair, so they don't collide.
123
+ by_ver_dir = {}
124
+ for p in versioned:
125
+ by_ver_dir.setdefault((p["versionRaw"], p["direction"]), []).append(p["file"])
126
+ dupe_by_version = {}
127
+ for (version, _direction), files in by_ver_dir.items():
128
+ if len(files) > 1:
129
+ dupe_by_version.setdefault(version, []).extend(files)
130
+ for version, files in dupe_by_version.items():
131
+ errors.append({"rule": "DUPE_NUM", "version": version, "files": sorted(files)})
132
+
133
+ # MISSING_DOWN — only when the project actually uses .up/.down splits.
134
+ uses_split = any(p["split"] and p["direction"] == "down" for p in versioned)
135
+ if uses_split:
136
+ down_versions = {p["versionRaw"] for p in versioned if p["split"] and p["direction"] == "down"}
137
+ seen_up = set()
138
+ for p in versioned:
139
+ if (p["split"] and p["direction"] == "up"
140
+ and p["versionRaw"] not in down_versions
141
+ and p["versionRaw"] not in seen_up):
142
+ seen_up.add(p["versionRaw"])
143
+ warnings.append({"rule": "MISSING_DOWN", "version": p["versionRaw"], "file": p["file"]})
144
+
145
+ # SEQ_GAP — integer sequences only (timestamps/dotted versions skipped).
146
+ seq = sorted({p["numeric"] for p in versioned if p["numeric"] is not None and p["numeric"] < SEQ_MAX})
147
+ if len(seq) >= 2:
148
+ present = set(seq)
149
+ missing = [v for v in range(seq[0], seq[-1]) if v not in present]
150
+ if missing:
151
+ warnings.append({"rule": "SEQ_GAP", "missing": missing})
152
+
153
+ # BAD_FORMAT — recognized extension but no parseable version.
154
+ for f in bad_format:
155
+ warnings.append({"rule": "BAD_FORMAT", "file": f})
156
+
157
+ return {
158
+ "errors": errors,
159
+ "warnings": warnings,
160
+ "stats": {"scanned": len(filenames), "candidates": len(candidates), "migrations": len(versioned)},
161
+ }
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: migrolint
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic linter for database migration folders — catch duplicate version numbers, sequence gaps, and missing down-migrations before CI does. Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/migrolint-py
8
+ Project-URL: Repository, https://github.com/jjdoor/migrolint-py
9
+ Project-URL: Issues, https://github.com/jjdoor/migrolint-py/issues
10
+ Keywords: migration,migrations,database,linter,sql,flyway,golang-migrate,dbmate,goose,cli,pre-commit,devops
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # migrolint
26
+
27
+ **A framework-agnostic linter for your database migrations folder.** It catches
28
+ the boring, expensive mistakes that pass locally and only blow up when CI
29
+ actually runs the migrations — duplicate version numbers, sequence gaps, and
30
+ `up` migrations with no matching `down`. No database connection, no framework
31
+ lock-in, **zero dependencies** (pure standard library).
32
+
33
+ ```bash
34
+ pip install migrolint
35
+
36
+ migrolint # auto-detects ./migrations, db/migrations, ...
37
+ migrolint db/migrations --strict
38
+ ```
39
+
40
+ ## The problem
41
+
42
+ Two developers branch off `main`, each adds `0007_add_index.sql`, both merge.
43
+ Now your migrations folder has two migrations claiming version `0007`. Your
44
+ runner picks one and silently skips the other — or aborts the whole deploy.
45
+ This is a [known, recurring failure](https://github.com/golang-migrate/migrate/issues/574)
46
+ in every sequence-numbered migration tool.
47
+
48
+ The existing linters (`django-migration-linter`, Flyway's own checks) are tied
49
+ to one framework or need a live database. If you use raw SQL with goose,
50
+ dbmate, golang-migrate, or a hand-rolled folder, there's nothing that just
51
+ *looks at the filenames* and tells you they're sane. That's migrolint.
52
+
53
+ ## What it checks
54
+
55
+ | Rule | Severity | Meaning |
56
+ |------|----------|---------|
57
+ | `DUPE_NUM` | **error** | two migrations share a version number (the merge-collision bug) |
58
+ | `MISSING_DOWN` | warning | an `up` migration has no matching `down` (only flagged if the project uses up/down splits) |
59
+ | `SEQ_GAP` | warning | a hole in an integer sequence — usually a deleted or un-merged migration |
60
+ | `BAD_FORMAT` | warning | a file whose name no known convention recognizes |
61
+
62
+ ## Naming conventions it understands
63
+
64
+ migrolint reads version numbers out of the filename, across the conventions
65
+ people actually use:
66
+
67
+ | Convention | Example |
68
+ |------------|---------|
69
+ | Flyway | `V1__init.sql`, `U1__undo.sql`, `R__refresh.sql`, `V1.1__patch.sql` |
70
+ | golang-migrate / dbmate | `0001_create_users.up.sql` + `0001_create_users.down.sql` |
71
+ | goose / Rails | `20230101120000_create_users.sql` (timestamp prefix) |
72
+ | minimalist | `1_init.sql`, `2-add-index.sql` |
73
+
74
+ Timestamp-style versions are recognized but exempt from `SEQ_GAP` (they're not
75
+ meant to be contiguous). Well-known non-migration files (`schema.rb`,
76
+ `structure.sql`, `seeds.rb`, …) and any non-migration extension are skipped.
77
+
78
+ ## Usage
79
+
80
+ ```bash
81
+ migrolint # scan the first migrations dir it finds
82
+ migrolint db/migrations # scan a specific dir
83
+ migrolint app/migrations svc/migrations # scan several
84
+ migrolint --json # machine-readable, for tooling
85
+ migrolint --strict # warnings become errors (exit 1) — good for CI
86
+ migrolint --ext .sql,.py # override which extensions count
87
+ migrolint --ignore baseline.sql,seed.sql
88
+ ```
89
+
90
+ You can also run it as a module: `python -m migrolint db/migrations`.
91
+
92
+ ### As a pre-commit / CI gate
93
+
94
+ ```yaml
95
+ # .pre-commit-config.yaml
96
+ - repo: local
97
+ hooks:
98
+ - id: migrolint
99
+ name: migrolint
100
+ entry: migrolint --strict
101
+ language: system
102
+ pass_filenames: false
103
+ ```
104
+
105
+ ```yaml
106
+ # GitHub Actions
107
+ - run: pipx run migrolint --strict
108
+ ```
109
+
110
+ ## Example output
111
+
112
+ ```
113
+ migrolint db/migrations (14 files, 12 migrations)
114
+
115
+ ✗ DUPE_NUM version 0007 used by 2 migrations:
116
+ 0007_add_index.up.sql
117
+ 0007_add_orders_fk.up.sql
118
+ ⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
119
+ ⚠ SEQ_GAP missing version(s): 5
120
+
121
+ 1 error, 2 warnings.
122
+ ```
123
+
124
+ ## Exit codes
125
+
126
+ | Code | Meaning |
127
+ |------|---------|
128
+ | `0` | clean (or only warnings, without `--strict`) |
129
+ | `1` | errors found — or warnings, when `--strict` is set |
130
+ | `2` | usage / IO error (no migrations dir, unreadable path) |
131
+
132
+ ## Also available for Node
133
+
134
+ Same checks, same flags: [`npx migrolint`](https://www.npmjs.com/package/migrolint)
135
+ (source: [migrolint](https://github.com/jjdoor/migrolint)). Both ports read
136
+ filenames identically, so a mixed-language team gets the same verdict.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/migrolint/__init__.py
5
+ src/migrolint/__main__.py
6
+ src/migrolint/cli.py
7
+ src/migrolint/core.py
8
+ src/migrolint.egg-info/PKG-INFO
9
+ src/migrolint.egg-info/SOURCES.txt
10
+ src/migrolint.egg-info/dependency_links.txt
11
+ src/migrolint.egg-info/entry_points.txt
12
+ src/migrolint.egg-info/top_level.txt
13
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ migrolint = migrolint.cli:main
@@ -0,0 +1 @@
1
+ migrolint
@@ -0,0 +1,133 @@
1
+ from migrolint.core import parse_migration, analyze
2
+
3
+
4
+ # ---- parse_migration: the convention zoo ----------------------------------
5
+
6
+ def test_flyway_versioned_is_up():
7
+ assert parse_migration("V1__init.sql") == {
8
+ "versionRaw": "1", "numeric": 1, "direction": "up",
9
+ "split": False, "repeatable": False, "label": "init",
10
+ }
11
+
12
+
13
+ def test_flyway_undo_is_down():
14
+ m = parse_migration("U1__rollback.sql")
15
+ assert m["direction"] == "down"
16
+ assert m["versionRaw"] == "1"
17
+
18
+
19
+ def test_flyway_repeatable_has_no_version():
20
+ m = parse_migration("R__refresh_views.sql")
21
+ assert m["repeatable"] is True
22
+ assert m["versionRaw"] is None
23
+
24
+
25
+ def test_flyway_dotted_version_keeps_raw_numeric_none():
26
+ m = parse_migration("V1.1__patch.sql")
27
+ assert m["versionRaw"] == "1.1"
28
+ assert m["numeric"] is None
29
+
30
+
31
+ def test_golang_migrate_up_split():
32
+ assert parse_migration("0001_create_users.up.sql") == {
33
+ "versionRaw": "0001", "numeric": 1, "direction": "up",
34
+ "split": True, "repeatable": False, "label": "create_users",
35
+ }
36
+
37
+
38
+ def test_golang_migrate_down_split():
39
+ m = parse_migration("0001_create_users.down.sql")
40
+ assert m["direction"] == "down"
41
+ assert m["split"] is True
42
+
43
+
44
+ def test_goose_rails_timestamp_not_sequential():
45
+ m = parse_migration("20230101120000_init.rb")
46
+ assert m["versionRaw"] == "20230101120000"
47
+ assert m["numeric"] == 20230101120000
48
+ assert m["direction"] == "both"
49
+
50
+
51
+ def test_minimalist_numeric_prefix():
52
+ assert parse_migration("1_init.sql")["numeric"] == 1
53
+ assert parse_migration("2-add-index.sql")["label"] == "add-index"
54
+
55
+
56
+ def test_bare_number_no_label():
57
+ m = parse_migration("0005.sql")
58
+ assert m["versionRaw"] == "0005"
59
+ assert m["label"] == ""
60
+
61
+
62
+ def test_unrecognized_returns_none():
63
+ assert parse_migration("create_users.sql") is None
64
+ assert parse_migration("notes.sql") is None
65
+
66
+
67
+ # ---- analyze: the four rules ----------------------------------------------
68
+
69
+ def test_dupe_num_two_ups_same_version_is_error():
70
+ r = analyze(["0001_a.up.sql", "0001_b.up.sql"])
71
+ assert len(r["errors"]) == 1
72
+ assert r["errors"][0]["rule"] == "DUPE_NUM"
73
+ assert r["errors"][0]["version"] == "0001"
74
+ assert r["errors"][0]["files"] == ["0001_a.up.sql", "0001_b.up.sql"]
75
+ assert len(r["warnings"]) == 0
76
+
77
+
78
+ def test_matched_up_down_pair_is_not_duplicate():
79
+ r = analyze(["0001_a.up.sql", "0001_a.down.sql"])
80
+ assert len(r["errors"]) == 0
81
+ assert len(r["warnings"]) == 0
82
+
83
+
84
+ def test_missing_down_only_when_project_uses_splits():
85
+ r = analyze(["0001_a.up.sql", "0001_a.down.sql", "0002_b.up.sql"])
86
+ assert len(r["errors"]) == 0
87
+ md = [w for w in r["warnings"] if w["rule"] == "MISSING_DOWN"]
88
+ assert len(md) == 1
89
+ assert md[0]["version"] == "0002"
90
+
91
+
92
+ def test_single_file_does_not_trigger_missing_down():
93
+ r = analyze(["0001_a.sql", "0002_b.sql"])
94
+ assert [w for w in r["warnings"] if w["rule"] == "MISSING_DOWN"] == []
95
+
96
+
97
+ def test_seq_gap_reports_hole():
98
+ r = analyze(["0001_a.sql", "0003_c.sql"])
99
+ gap = [w for w in r["warnings"] if w["rule"] == "SEQ_GAP"]
100
+ assert len(gap) == 1
101
+ assert gap[0]["missing"] == [2]
102
+
103
+
104
+ def test_timestamps_never_produce_seq_gap():
105
+ r = analyze(["20230101120000_a.sql", "20230515090000_b.sql"])
106
+ assert [w for w in r["warnings"] if w["rule"] == "SEQ_GAP"] == []
107
+ assert len(r["errors"]) == 0
108
+
109
+
110
+ def test_bad_format_flags_versionless_migration_file():
111
+ r = analyze(["0001_a.sql", "random.sql"])
112
+ bad = [w for w in r["warnings"] if w["rule"] == "BAD_FORMAT"]
113
+ assert len(bad) == 1
114
+ assert bad[0]["file"] == "random.sql"
115
+
116
+
117
+ def test_non_migration_and_ignored_files_skipped():
118
+ r = analyze(["0001_a.sql", "README.md", ".gitkeep", "schema.rb", "structure.sql"])
119
+ assert len(r["errors"]) == 0
120
+ assert len(r["warnings"]) == 0
121
+ assert r["stats"]["migrations"] == 1
122
+
123
+
124
+ def test_stats_count_scanned_vs_migrations():
125
+ r = analyze(["0001_a.up.sql", "0001_a.down.sql", "README.md"])
126
+ assert r["stats"]["scanned"] == 3
127
+ assert r["stats"]["migrations"] == 2
128
+
129
+
130
+ def test_flyway_dir_without_downs_is_clean():
131
+ r = analyze(["V1__init.sql", "V2__users.sql", "R__view.sql"])
132
+ assert len(r["errors"]) == 0
133
+ assert len(r["warnings"]) == 0