ci(migrations): add migration safety linter#225
Merged
declan-scale merged 7 commits intomainfrom May 6, 2026
Merged
Conversation
083c735 to
7dd2b66
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
declan-scale
added a commit
that referenced
this pull request
May 6, 2026
Address greptile review on PR #225: - transaction-nesting now scopes per call site (indent-based span detection) rather than short-circuiting on a file-level autocommit_block presence check. A migration that wraps one CREATE INDEX CONCURRENTLY in autocommit_block() but leaves a sibling call outside no longer slips past the linter. - in-band-backfill rule restored: flags op.execute() calls whose SQL contains UPDATE / DELETE FROM. The PR description claimed this rule existed but the implementation didn't; now it does, and it's added to _RULES. - Drop unused ENV_PY constant. Adds three unit tests: mixed concurrent-index scenario, UPDATE backfill, and DELETE FROM backfill. SELECT remains unflagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
smoreinis
approved these changes
May 6, 2026
declan-scale
added a commit
that referenced
this pull request
May 6, 2026
Address greptile review on PR #225: - transaction-nesting now scopes per call site (indent-based span detection) rather than short-circuiting on a file-level autocommit_block presence check. A migration that wraps one CREATE INDEX CONCURRENTLY in autocommit_block() but leaves a sibling call outside no longer slips past the linter. - in-band-backfill rule restored: flags op.execute() calls whose SQL contains UPDATE / DELETE FROM. The PR description claimed this rule existed but the implementation didn't; now it does, and it's added to _RULES. - Drop unused ENV_PY constant. Adds three unit tests: mixed concurrent-index scenario, UPDATE backfill, and DELETE FROM backfill. SELECT remains unflagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
982ee9f to
6b2b138
Compare
mohammadatallah-scale
approved these changes
May 6, 2026
Adds a custom Python AST-based linter that fails CI on PRs introducing
the four anti-patterns the migration runtime defaults cannot reliably
catch on a quiet hour:
* op.create_index (and raw CREATE INDEX) without CONCURRENTLY,
or with CONCURRENTLY but outside an autocommit_block().
* op.create_foreign_key (and raw ADD CONSTRAINT FOREIGN KEY)
without NOT VALID.
* op.execute("UPDATE ...") / DELETE in-band data backfills.
* SET / RESET of lock_timeout, statement_timeout, or
idle_in_transaction_session_timeout inside a migration file.
Each rule maps to a documented anti-pattern in CLAUDE.md.
Why custom AST instead of squawk: squawk operates on rendered SQL,
which loses the surrounding Alembic context (autocommit_block wrapping,
kwarg options on op.create_index/op.create_foreign_key) — exactly the
signals needed to distinguish safe and unsafe shapes here. AST
inspection of the migration source is tighter, has zero external deps,
runs in milliseconds, and lets each rule produce a message that points
at the correct fix.
Escape hatch: a `# migration-unsafe-ack: <one-line reason>` directive
at the top of the migration file suppresses all rules for that file.
The GitHub Actions workflow additionally checks for a
`migration-unsafe-ack` PR label; when present it runs the linter in
--warn-only mode so violations surface in logs without blocking merge.
Both signals are required to deploy a migration that intentionally
overrides the runner defaults.
The CI step lints only files changed in the PR (via
`--base-ref origin/<base>`), so existing migrations are not
retro-flagged.
Includes 12 unit tests covering each rule, the ack-directive escape
hatch (including that bare directives without a reason do not
suppress), and the autocommit_block detection.
Address greptile review on PR #225: - transaction-nesting now scopes per call site (indent-based span detection) rather than short-circuiting on a file-level autocommit_block presence check. A migration that wraps one CREATE INDEX CONCURRENTLY in autocommit_block() but leaves a sibling call outside no longer slips past the linter. - in-band-backfill rule restored: flags op.execute() calls whose SQL contains UPDATE / DELETE FROM. The PR description claimed this rule existed but the implementation didn't; now it does, and it's added to _RULES. - Drop unused ENV_PY constant. Adds three unit tests: mixed concurrent-index scenario, UPDATE backfill, and DELETE FROM backfill. SELECT remains unflagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- prefer-robust-stmts now also inspects raw SQL inside op.execute(...):
flags op.execute("CREATE INDEX ...") without CONCURRENTLY and
op.execute("ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ...") without
NOT VALID. Closes the documented escape hatch where copying SQL from a
DBA runbook into op.execute bypassed every helper-level check (P1).
- in-band-backfill no longer false-positives on
INSERT ... ON CONFLICT DO UPDATE SET upserts (legitimate schema-init
shape). Tightened _DATA_BACKFILL with a (?!SET\b) negative lookahead (P2).
- no-timeout-overrides skips matches that fall after a # on the same line
so a Python comment quoting an old SET command is no longer flagged (P2).
- Module docstring's Rules section now lists in-band-backfill alongside
the other five (developer searching the docstring for a CI failure
rule will find it).
Adds five unit tests: raw CREATE INDEX in op.execute, raw CREATE INDEX
CONCURRENTLY passes, raw ADD CONSTRAINT FK in op.execute, raw FK with
NOT VALID passes, upsert clause not flagged, and SET in Python comment
not flagged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address two P1 Greptile findings on PR #225 and bring the agentex linter into parity with the egp-api-backend version (scaleapi#142283): - Issue 1: `op.execute("CREATE INDEX CONCURRENTLY ...")` outside an autocommit_block was silently accepted (the raw-SQL prefer-robust-stmts check excludes CONCURRENTLY because it's the safe form). Add a second pass in `_check_transaction_nesting` that catches this shape so it fails lint instead of failing at runtime with "CREATE INDEX CONCURRENTLY cannot run inside a transaction block". - Issue 2: indentation-based `_autocommit_spans` truncated the block on any non-blank line at column 0, so a triple-quoted SQL string starting at the margin would push trailing `postgresql_concurrently=True` out of the span and produce a false `transaction-nesting` finding on correctly-written migrations. Switch to AST-based span detection, which also handles the PEP 617 parenthesized `with` form for free. Parity with sgp linter: - `SET statement_timeout` is now exempted inside autocommit_block (the documented escape valve for long CIC builds), with `RESET` still flagged because RESET reverts to the role/server default rather than restoring the runner's 30s ceiling. - `_changed_migrations` resolves paths via `git rev-parse --show-toplevel` instead of `REPO_ROOT.parent` so it keeps working if the package is ever moved within the repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6b2b138 to
3308632
Compare
- Raw-SQL `op.execute("CREATE INDEX ...")` /
`op.execute("ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY ...")`
on a freshly-created table now skips `prefer-robust-stmts`, matching
the helper-call paths. Pattern shows up when DBA-runbook SQL gets
pasted directly into a migration that also creates the target table.
- `_check_in_band_backfill` and the raw-SQL blocks in
`_check_prefer_robust_stmts` and `_check_transaction_nesting` now
call `_is_in_python_comment` before flagging, the same guard the
`no-timeout-overrides` rule already uses. A commented-out
`# op.execute("UPDATE ...")` no longer produces a finding on a line
that isn't executed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing-required-field
Two more false-positive gaps in the migration linter:
- Kwarg detection used literal substring matches
(``"postgresql_concurrently=True" in call``) that silently failed when
the migration spaced the kwarg
(``postgresql_concurrently = True``). The lint then flagged a valid
CIC inside an autocommit_block. Replace with module-level regexes
(``_KWARG_CONCURRENTLY_TRUE``, ``_KWARG_NOT_VALID_TRUE``,
``_KWARG_NULLABLE_FALSE``, ``_KWARG_SERVER_DEFAULT``) that allow
surrounding whitespace, mirroring the existing
``_check_transaction_nesting`` regex (which is now refactored to use
the same constant).
- ``_check_adding_required_field`` was the only structural rule that
did not skip operations on freshly-created tables, so an
``op.create_table("foo", ...)`` immediately followed by
``op.add_column("foo", sa.Column(..., nullable=False,
server_default=...))`` would be flagged on a safe migration. Mirror
the ``_tables_created`` exclusion already used by every other rule;
for ``op.add_column`` the *first* quoted name is the target table
(the second is the column).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ackfills
Reviewer caught a missing `_is_in_python_comment` guard on the
`_KWARG_CONCURRENTLY_TRUE` sweep in `_check_transaction_nesting`. An
audit of every other full-source regex scan turned up the same gap on
five more spots:
- `_tables_created` (`_OP_CREATE_TABLE.finditer`) — false NEGATIVE risk:
a commented-out `# op.create_table("foo", ...)` would have polluted
fresh_tables and silently masked findings on the real `foo` table.
- `_check_prefer_robust_stmts` (`_OP_CREATE_INDEX`, `_OP_CREATE_FK`),
`_check_disallowed_unique_constraint` (`_OP_CREATE_UNIQUE`),
`_check_adding_required_field` (`_OP_ADD_COLUMN`),
`_check_transaction_nesting` (`_KWARG_CONCURRENTLY_TRUE`) — all false
POSITIVES on commented-out call sites.
OP_EXECUTE-based loops and the SET/RESET timeout loops already had the
guard.
Also adds the fresh-tables exclusion to `_check_in_band_backfill` for
parity with every other rule. New `_RAW_BACKFILL_TARGET` regex extracts
the target from `UPDATE tbl …` / `DELETE FROM tbl …` so seed-data and
staging-cleanup patterns on tables created in the same migration no
longer require a `# noqa`.
Tests: extended the existing commented-out test to cover all six
shapes plus the kwarg sweep, and added an in-band-backfill-on-fresh-
table positive case. 44 -> 46.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a CI linter that fails PRs introducing the four migration anti-patterns the runtime timeouts cannot reliably catch on a quiet hour: blocking index builds, validating FK adds, in-band UPDATE/DELETE backfills, and SET/RESET overrides of the runner's default timeouts.
Closes the third sub-issue under the migration-safety umbrella, the linter-on-PR layer that complements the runtime defaults (already shipped) and the CLAUDE.md docs (companion PR).
What's caught
op.create_indexwithoutpostgresql_concurrently=True(or rawCREATE INDEXoutsideautocommit_block)no-concurrentlyCREATE INDEXtakesShareLockfor the entire build, blocking writes.op.create_index(postgresql_concurrently=True)outsideautocommit_blockconcurrently-outside-autocommitCONCURRENTLYcannot run inside a transaction; would fail at runtime.op.create_foreign_keywithoutpostgresql_not_valid=True(or rawADD CONSTRAINT FOREIGN KEYwithoutNOT VALID)fk-without-not-validShareRowExclusiveLock.op.execute("UPDATE ...")/DELETEdata backfillsin-band-backfillSET/RESEToflock_timeout/statement_timeout/idle_in_transaction_session_timeoutforbidden-setWhy custom AST instead of squawk
squawk operates on rendered SQL, which loses the surrounding Alembic context —
autocommit_blockwrapping and kwargs likepostgresql_concurrently/postgresql_not_validare exactly the signals needed to distinguish safe and unsafe shapes here. Custom AST inspection of the migration source is tighter, has zero external dependencies, runs in milliseconds, and produces messages that point at the correct fix.The acceptance criteria in the linter ticket explicitly says "squawk or equivalent" — this delivers the equivalent rule set against the right surface.
Escape hatch
Two signals required, both deliberate:
# migration-unsafe-ack: <one-line reason>directive at the top of the migration file. The directive must include a non-empty reason on the same line — bare# migration-unsafe-ack:does not suppress.migration-unsafe-acklabel on the PR. When present, the workflow runs the linter in--warn-onlymode so violations surface in CI logs without blocking merge.Use the escape hatch for "this needs a maintenance window with traffic shifted away," not "I want to ship faster."
Scope
agentex/scripts/lint_migrations.py— the linter (Python 3.12 stdlib only; no external deps)..github/workflows/migration-lint.yml— workflow triggered on PRs touchingagentex/database/migrations/alembic/versions/**.agentex/tests/unit/scripts/test_lint_migrations.py— 12 tests covering each rule, the autocommit_block detection, and the ack-directive escape hatch (including that bare directives don't suppress).The CI step lints only files changed in the PR vs
origin/<base>. Existing migrations are not retro-flagged. New migrations going forward must follow the rules.Test plan
--base-refmode (only NEW changes vs base ref are checked)Greptile Summary
migration_lint.py) and matching GitHub Actions workflow that blocks PRs introducing blocking index builds, validating FK adds, in-band backfills, or timeout overrides; previous-review gaps (AST-basedautocommit_blockspan detection, per-occurrence concurrently checks, python-comment guards, raw-SQLop.executepaths, fresh-table exclusions) are all addressed in this revision with corresponding tests.MIGRATION_UNSAFE_ACKenv var (set by the workflow when themigration-unsafe-ackPR label is present) plus per-line# noqa: migration-lint; the previous file-level directive that could bypass CI without the label is gone.uv.lockaddspyyamltorequires-dist(implying an undiffedpyproject.tomlchange) and downgradesaiohttpfrom3.13.5to3.13.4; both appear unrelated to the linter and should be verified before merge.Confidence Score: 5/5
Safe to merge once the uv.lock noise is confirmed or cleaned up — no logic defects remain in the linter itself.
All previously identified P1 issues are addressed in this revision. The only remaining finding is a P2 about unexplained lock file changes that do not affect linter correctness. All six rules have comprehensive tests, the AST-based autocommit_block detection is sound, and the CI workflow is correctly scoped with minimal permissions.
uv.lock — contains an unexplained pyyaml dependency addition and aiohttp downgrade that should be confirmed or reverted.
Important Files Changed
Comments Outside Diff (4)
agentex/scripts/lint_migrations.py, line 231-243 (link)op.execute(text(...))silently bypasses all SQL checks_string_argonly handles bare string literals (ast.Constant) and f-strings (ast.JoinedStr). When the first argument is atext(...)call —op.execute(text("UPDATE spans SET ..."))— the function returnsNoneand every downstream check (in-band-backfill,forbidden-set, rawCREATE INDEX, rawADD CONSTRAINT FOREIGN KEY) is silently skipped. This is the SQLAlchemy 2.0-recommended calling convention for raw SQL in Alembic, so it's the pattern most likely to appear in new migrations going forward, and is not covered by any of the 12 tests.Prompt To Fix With AI
agentex/scripts/lint_migrations.py, line 200-209 (link)The docstring (line 22–24) and the PR description both state "Two signals required, both deliberate: (1) file directive AND (2) PR label." In practice, when
has_ackisTrue,_maybe_violationreturnsNonefor every check,check_filereturns an empty list, and the process exits 0 — entirely independently of whether--warn-onlywas passed. A developer can merge a file containing# migration-unsafe-ack: reasonwithout the PR ever receiving the label, bypassing the intended two-person visibility mechanism.Prompt To Fix With AI
agentex/tests/unit/scripts/test_lint_migrations.py, line 25-34 (link)os.chdirin_check_relativeis not thread-safe under parallel pytestos.chdirchanges the process-wide working directory. If tests run withpytest-xdistor any-nparallelism, concurrent invocations of_check_relativewill race on the CWD, causing one test to open a relative path while another test has already changed the directory, producing spuriousFileNotFoundErrorfailures. Prefer constructing an absolute path viapath.resolve()before callingcheck_file, or passing the absolute path directly, to eliminate the CWD dependency.Prompt To Fix With AI
agentex/scripts/ci_tools/migration_lint.py, line 284-301 (link)_check_transaction_nestingsilently clears all findings whenautocommit_blockappears anywhere in the fileThe guard at line 288 short-circuits with no findings as soon as
autocommit_block(appears anywhere in the source. A migration with two concurrent index creates — one correctly wrapped inautocommit_blockand one accidentally outside — passes the linter entirely. At runtime the unwrapped index fails withActiveSQLError: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. The rule needs to track which specificpostgresql_concurrently=Trueoccurrences fall inside the block, not just whether the block exists somewhere in the file.Prompt To Fix With AI
Prompt To Fix All With AI
Reviews (9): Last reviewed commit: "fix(migrations): comment-guard every ful..." | Re-trigger Greptile