AI makes Rails development faster - it also makes it very easy to commit code that looks plausible, passes a quick glance, and quietly breaks a production standard.

The problem is not that AI writes bad code all the time. The problem is that it writes confident code all the time. It will add a migration that locks a large table. It will silence a warning with safety_assured. It will invent a helper, skip a validation, or add a translation key in one file and forget the locale. Sometimes the code even works locally, which is the worst kind of comfort.

The answer is not "do not use AI". The answer is to stop treating generated code as special. If a human has to pass the gates, AI output has to pass the same or even harder gates.

This article is a proposal, not a universal copy-paste checklist. Treat it as a strong starting point and adapt it to your application: RSpec or Minitest, Importmap or npm, PostgreSQL or another database, Packwerk or a simpler architecture. The useful workflow is the one that matches the tools your Rails app actually uses.

Start with the Gemfile

This is the baseline I like for Rails applications that use AI-assisted coding. Some of these tools are strict.

group :development, :test do
  # proposed gems that can help
  gem "rubocop", require: false
  gem "rubocop-rails", require: false
  gem "rubocop-performance", require: false
  gem "rubocop-rspec", require: false

  gem "packwerk", require: false
  gem "erb_lint", require: false

  gem "reek", require: false
  gem "fasterer", require: false
  gem "rails_best_practices", require: false
  gem "rubycritic", require: false # embeds flay and flog behind a failing score threshold

  gem "brakeman", require: false
  gem "bundler-audit", require: false
  gem "license_finder", require: false
  gem "csv", require: false # license_finder needs it: csv left Ruby's default gems in 3.4

  gem "database_consistency", require: false
  gem "active_record_doctor", require: false
  gem "strong_migrations"
  gem "i18n-tasks", require: false
  gem "htmlbeautifier", require: false

  gem "prosopite"

  gem "simplecov", require: false
  gem "undercover", require: false
  gem "mutation_tester" # mutation testing for Minitest or RSpec
end

RuboCop extensions do not run just because the gems are installed. They need to be enabled in .rubocop.yml.

plugins:
  - rubocop-rails
  - rubocop-performance
  - rubocop-rspec

AllCops:
  NewCops: enable
  SuggestExtensions: false
  Exclude:
    - "bin/**/*"
    - "db/schema.rb"
    - "db/structure.sql"
    - "vendor/**/*"

Rails:
  Enabled: true

Lint/SuppressedException:
  Enabled: true

Lint/RescueException:
  Enabled: true

Style/RescueStandardError:
  Enabled: true

Rails/SkipsModelValidations:
  Enabled: true

# Debug artifacts (binding.irb, binding.b, byebug, debugger, ...) are a
# linter's job, not a custom hook's; Biome's suspicious/noConsole rule
# covers console.log on the JS side.
Lint/Debugger:
  Enabled: true

# feel free to extend that to your code style

Those cops matter because AI often chooses the path of least resistance. Empty rescue, rescue Exception, update_columns, update_all, and validation bypasses are not always wrong, but they should never slip in unnoticed. The same goes for debug artifacts: Lint/Debugger catches a leftover binding.irb, byebug, or debugger call, so you do not need a custom hook for that.

Use Lefthook as the local quality runner

Lefthook gives you a practical way to split fast checks from slow checks. The important distinction is simple: pre-commit should be quick and focused on staged files, while pre-push can run heavier checks that need the full application.

Here is a sample pre-commit setup that catches common AI leftovers before they enter history.

# GLOBS: lefthook has NO `|` alternation - use brace sets like {a,b} - and `*`
# already crosses `/`. A glob that matches nothing does not error: the hook
# silently never runs.
pre-commit:
  parallel: true
  commands:
    rubocop:
      tags: [backend, style, performance, test]
      glob: "*.rb"
      run: bundle exec rubocop -a --parallel --force-exclusion {staged_files}
      stage_fixed: true

    erb-lint:
      tags: [frontend, views]
      glob: "*.html.erb"
      run: bundle exec erb_lint -a {staged_files}
      stage_fixed: true

    reek:
      tags: [backend, quality]
      # Fixed target list (not {staged_files}), so the hook cannot fail where
      # CI passes. Widen it together with .reek.yml's per-directory carve-outs.
      glob: "*.rb"
      run: bundle exec reek app lib

    fasterer:
      tags: [backend, performance]
      glob: "*.rb"
      run: bundle exec fasterer

    ruby-syntax:
      tags: [backend, syntax]
      glob: "*.rb"
      run: ruby -c {staged_files}

    gitleaks:
      tags: [security, secrets]
      run: gitleaks protect --staged --redact

    gemfile-lock:
      tags: [dependencies, hygiene]
      glob: "{Gemfile,Gemfile.lock}"
      run: ruby .hooks/check_gemfile_lock.rb {staged_files}

    schema-updated:
      tags: [backend, database, hygiene]
      glob: "db/{migrate/*.rb,schema.rb,structure.sql}"
      run: ruby .hooks/check_schema_updated.rb {staged_files}

    env-files:
      tags: [security, secrets, config]
      run: ruby .hooks/check_env_files.rb {staged_files}

    env-secret-assignments:
      tags: [security, secrets, config]
      glob: "*.{rb,erb,js,jsx,ts,tsx,yml,yaml}"
      run: ruby .hooks/check_env_secret_assignments.rb {staged_files}

    whitespace-and-conflict-markers:
      tags: [hygiene]
      run: git diff --cached --check

    yaml-parse:
      tags: [config, hygiene]
      glob: "*.{yml,yaml}"
      # Rails fixtures are ERB-templated YAML; plain Psych cannot parse them,
      # the test suite validates them instead.
      exclude:
        - "test/fixtures/**"
      run: ruby .hooks/check_yaml.rb {staged_files}

    ai-slop-markers:
      tags: [hygiene, ai]
      glob: "*.{rb,erb,js,jsx,ts,tsx,yml,yaml}"
      run: ruby .hooks/check_ai_slop_markers.rb {staged_files}

    strong-migrations-override:
      tags: [backend, database, safety]
      glob: "db/migrate/*.rb"
      run: ruby .hooks/check_strong_migrations_override.rb {staged_files}

    packwerk-todo:
      tags: [backend, architecture, safety]
      glob: "*package_todo.yml"
      run: ruby .hooks/check_packwerk_todo.rb {staged_files}

One warning before anything else, because failure here is silent. Lefthook has no | alternation in globs: "Gemfile|Gemfile.lock" matches nothing, and a hook whose glob matches nothing does not error, it just never runs. Use brace sets like {Gemfile,Gemfile.lock} instead. The second trap is that * in lefthook already crosses /, so app/javascript/**/*.js requires a subdirectory and silently skips app/javascript/application.js. Both mistakes fail open, which is why the reference repo pins the rules with a tiny test that walks lefthook.yml and rejects | and misused ** in globs.

Two decisions in this file matter more with AI in the loop than they used to. First, linters that can fix mechanically fix first: rubocop -a and erb_lint -a apply safe autocorrections, and stage_fixed: true re-stages whatever they touched. A hook failure then always means a decision is needed, never a formatting round-trip, which saves a full edit-and-commit cycle every time an assistant gets a comma wrong. Second, RubyCritic embeds flay and flog and puts a failing minimum score in front of their signal, and that is where duplication and complexity are actually enforced (you will see it in pre-push).

One caveat on RubyCritic before you rely on it: its --minimum-score is an average with a per-file cost cap, so one arbitrarily awful file is diluted back to green by the clean files around it and can never fail the gate on its own. If AI-generated sprawl is the threat model, pair it with per-file RuboCop metrics (Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity) as the per-file gate, with each Max measured on your repo plus a small margin rather than guessed, and keep RubyCritic for duplication and churn.

I prefer small Ruby scripts in .hooks over long one-liners once a check becomes part of the team standard. One-liners are fine for experiments. Scripts are easier to review.

Two of these need a small config on a stock Rails app, or they fail on framework code you do not own. Reek's IrresponsibleModule fires on every generated base class, and fasterer flags Rails' own ENV.fetch(x, default) under config/ and db/:

# .reek.yml
detectors:
  IrresponsibleModule:
    enabled: false
# .fasterer.yml
exclude_paths:
  - 'config/**/*.rb'
  - 'db/**/*.rb'
  - 'bin/**/*'
  - 'vendor/**/*'

The ruby-syntax command uses ruby -c, which parses the file without running it. That is useful for generated code because it catches broken Ruby syntax before RuboCop or the test suite has to do heavier work.

This check catches leftover generated-code markers. It matches whole-word markers and placeholder tokens, not substrings, so ordinary words like implementation, a demo app, or an HTML placeholder attribute do not trip it.

# .hooks/check_ai_slop_markers.rb
#
# Flags leftover "unfinished generated code" markers while staying quiet on
# normal domain words. The original /placeholder|temporary|demo|todo|implement/i
# matched substrings, so it fired on "implementation", "demo app", "temporary
# file", the HTML `placeholder=` attribute, etc. This version matches whole-word
# markers and a few explicit placeholder phrases instead.

MARKERS = [
  # Work-left-behind comment tags (whole word, case-sensitive by convention).
  /\bTODO\b/,
  /\bFIXME\b/,
  /\bTBD\b/,
  /\bWIP\b/,
  /\bXXX\b/,
  # Stub bodies that AI leaves behind.
  /\bNotImplementedError\b/,
  /\bnot[[:space:]]+implemented\b/i,
  /\bimplement[[:space:]]+(me|this|here)\b/i,
  # Copy-paste placeholders. Kept as specific tokens, not English phrases:
  # "change this" / "replace this" appear in normal Rails/Kamal comments, so we
  # only match glued/underscored placeholders like change_me or your_api_key.
  /\byour[_-](api[_-]?key|token|secret|password|domain)/i,
  /\b(change|replace)[_-]?me\b/i,
  /\blorem[[:space:]]+ipsum\b/i
]

PATTERN = Regexp.union(MARKERS)

bad_files = ARGV.select do |path|
  # The hook scripts hold these markers as data, so skip our own directory.
  next false if path.start_with?(".hooks/")

  File.file?(path) && File.read(path).match?(PATTERN)
end

if bad_files.any?
  warn "Possible unfinished generated-code markers found:"
  warn bad_files.join("\n")
  warn "Finish the code or open a follow-up ticket instead of leaving a marker."
  exit 1
end

One refinement worth stealing for every content guard: read the staged blob, not the worktree file. Git commits the index, and the stage_fixed autofixes above make the index and the worktree diverge routinely, so File.read can judge content that is not what is being committed. git show :path/to/file returns exactly what is staged; the reference repo wraps it in a small shared helper that also skips .hooks/ itself, so the guards never flag their own pattern definitions.

This check catches a very common dependency mistake: changing Gemfile without staging the matching lockfile.

# .hooks/check_gemfile_lock.rb

changed = ARGV.map { |path| File.basename(path) }

if changed.include?("Gemfile") && !changed.include?("Gemfile.lock")
  warn "Gemfile changed, but Gemfile.lock is not staged."
  warn "Run bundle install and stage Gemfile.lock."
  exit 1
end

This one catches another Rails classic: adding a migration and forgetting to stage the updated schema file.

# .hooks/check_schema_updated.rb

changed = ARGV
migration_changed = changed.any? { |path| path.start_with?("db/migrate/") && path.end_with?(".rb") }
schema_changed = changed.any? { |path| path == "db/schema.rb" || path == "db/structure.sql" }

if migration_changed && !schema_changed
  warn "A migration is staged, but db/schema.rb or db/structure.sql is not staged."
  warn "Run the migration and stage the updated schema file."
  exit 1
end

This pair blocks accidental environment leakage. The first script rejects committed .env files except safe templates. The second rejects code that assigns secret-looking values into ENV, which is usually a sign that a secret is being smuggled into the codebase instead of coming from the deployment environment.

# .hooks/check_env_files.rb

allowed = [
  ".env.example",
  ".env.sample",
  ".env.template"
]

bad_files = ARGV.select do |path|
  basename = File.basename(path)
  basename.start_with?(".env") && !allowed.include?(basename)
end

if bad_files.any?
  warn "Do not commit environment files with real secrets:"
  warn bad_files.join("\n")
  warn
  warn "Commit .env.example, .env.sample, or .env.template instead."
  exit 1
end
# .hooks/check_env_secret_assignments.rb

SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PRIVATE|API_KEY|ACCESS_KEY|AUTH|CREDENTIAL)/i
ASSIGNMENT = /ENV\[['"][^'"]*#{SECRET_NAME.source}[^'"]*['"]\]\s*=/

bad_files = ARGV.select do |path|
  File.file?(path) && File.read(path).match?(ASSIGNMENT)
end

if bad_files.any?
  warn "Do not assign secrets into ENV from committed code:"
  warn bad_files.join("\n")
  warn ""
  warn "Load secrets from the deployment environment or credentials manager."
  exit 1
end

And this one turns safety_assured into a hard stop.

# .hooks/check_strong_migrations_override.rb

bad_files = ARGV.select do |path|
  File.file?(path) && File.read(path).include?("safety_assured")
end

if bad_files.any?
  warn "safety_assured is not allowed in committed migrations:"
  warn bad_files.join("\n")
  exit 1
end

That rule is intentionally blunt. If a migration needs safety_assured, it needs human review and a production rollout note. It should not be something an assistant casually adds to make a warning disappear.

Packwerk deserves the same bluntness. When packwerk check finds a violation, the tooling can record it in a package_todo.yml file and go green, and an AI assistant will take that exit every time. Fix the violation or redesign the boundary; never record it for later. Gitignore package_todo.yml and make committing one a hard stop:

# .hooks/check_packwerk_todo.rb

bad_files = ARGV.select { |path| File.basename(path) == "package_todo.yml" }

if bad_files.any?
  warn "Committing package_todo.yml is not allowed:"
  warn bad_files.join("\n")
  warn "Resolve the violation or rethink the package boundary instead of recording it."
  exit 1
end

Validate commit messages too

Commit messages are part of the maintenance surface. A clean history makes release notes, bisecting, reviews, and automation easier. It also makes AI-generated changes less anonymous. A vague commit like fix stuff tells the next engineer almost nothing.

Use Git's commit-msg hook for this, not pre-commit.

commit-msg:
  commands:
    commit-message:
      tags: [hygiene, git]
      run: ruby .hooks/lint_commit_msg.rb {1}

The {1} argument is important. Git passes the commit message file path to the hook, and Lefthook forwards it to the command. Reading .git/COMMIT_EDITMSG directly works in simple cases, but accepting the file path is cleaner and easier to test.

# .hooks/lint_commit_msg.rb

commit_message_path = ARGV.fetch(0)
commit_message = File.read(commit_message_path).lines.reject do |line|
  line.start_with?("#")
end.join.strip

valid_types = %w[
  build
  chore
  ci
  docs
  feat
  fix
  perf
  refactor
  revert
  security
  style
  test
]

pattern = /\A(#{valid_types.join('|')})(\([a-z0-9_.-]+\))?!?: .{1,120}\z/

if commit_message.match?(pattern)
  puts "Nice commit message."
  exit 0
end

warn "Bad commit message:"
warn commit_message
warn ""
warn "Expected format:"
warn "  type(scope): short imperative summary"
warn ""
warn "Allowed types:"
warn "  #{valid_types.join(', ')}"
warn ""
warn "Examples:"
warn "  feat(auth): add magic link sign in"
warn "  fix(migrations): add index concurrently"
warn "  security: rotate leaked API token"

exit 1

This accepts messages like feat(auth): add magic link sign in, fix(migrations): add index concurrently, and security: update gems. It rejects empty messages, vague messages, and messages outside the agreed structure.

Move heavier checks to pre-push

Some checks are valuable but too expensive for every commit. Run them before pushing and again in CI.

# Static checks run in parallel; everything touching the shared test database
# runs as one sequential (piped) chain alongside them, in dependency order.
# Wall time is max(static, db-chain) instead of the sum of everything.
pre-push:
  parallel: true
  jobs:
    - name: brakeman
      tags: [backend, security]
      run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error --confidence-level 2

    - name: bundler-audit
      tags: [backend, security, dependencies]
      run: bin/bundler-audit check --update

    - name: importmap-audit
      tags: [frontend, security, dependencies]
      glob: "{config/importmap.rb,app/javascript/*.js}"
      run: bin/importmap audit

    - name: license-finder
      tags: [dependencies, licenses, compliance]
      # Licenses only change when dependencies (or the policy) do. The policy
      # lives in doc/dependency_decisions.yml - see below.
      glob: "{Gemfile,Gemfile.lock,doc/dependency_decisions.yml}"
      run: bundle exec license_finder

    - name: packwerk
      tags: [backend, architecture]
      # validate catches graph-level problems check cannot (dependency cycles,
      # malformed package.yml); check catches boundary violations.
      run: bundle exec packwerk validate && bundle exec packwerk check

    - name: zeitwerk
      tags: [backend, rails, autoloading]
      run: bundle exec rails zeitwerk:check

    - name: rails-best-practices
      tags: [backend, rails, quality]
      run: bundle exec rails_best_practices .

    - name: rubycritic
      tags: [backend, quality, complexity]
      run: bundle exec rubycritic app lib --no-browser --minimum-score 80

    - name: i18n-tasks
      tags: [backend, frontend, i18n]
      run: bundle exec i18n-tasks health

    - name: routes
      tags: [backend, rails]
      run: bundle exec rails routes > /dev/null

    - name: erb-lint-all
      tags: [frontend, views, quality]
      run: bundle exec erb_lint --lint-all

    - name: database-chain
      group:
        piped: true
        jobs:
          - name: db-test-prepare
            tags: [backend, database, migrations]
            glob: "db/{migrate/*.rb,schema.rb,structure.sql}"
            run: env RAILS_ENV=test bin/rails db:test:prepare

          - name: pending-migrations
            tags: [backend, database, migrations]
            run: env RAILS_ENV=test bin/rails db:abort_if_pending_migrations

          - name: schema-dump-drift
            tags: [backend, database, migrations, schema]
            # Asserts the committed schema is a fixed point of its own load.
            # Runs against the throwaway test database, so it is safe locally.
            run: env RAILS_ENV=test bin/rails db:schema:dump && git diff --exit-code -- db/schema.rb db/structure.sql

          - name: database-consistency
            tags: [backend, database, integrity]
            run: env RAILS_ENV=test bundle exec database_consistency

          - name: active-record-doctor
            tags: [backend, database, integrity, indexes, constraints]
            run: env RAILS_ENV=test bundle exec rake active_record_doctor

          - name: factory-bot-lint
            tags: [backend, test, factories]
            run: bundle exec rails runner 'if defined?(FactoryBot); FactoryBot.lint; else; puts "FactoryBot is not configured, skipping"; end'

          - name: test-seeds
            tags: [backend, test, database]
            glob: "{db/seeds.rb,db/seeds/*.rb,app/models/*.rb}"
            run: env RAILS_ENV=test bin/rails db:seed:replant

          - name: rspec
            tags: [backend, test]
            run: bundle exec rspec

          - name: undercover
            tags: [backend, test, coverage]
            # After the test run on purpose: it reads the coverage report that run wrote.
            run: bundle exec undercover --compare origin/main

          - name: mutation-test
            tags: [backend, test, quality, mutation]
            glob: "{app,packs}/**/*.rb"
            run: ruby .hooks/check_mutation_score.rb {push_files}

Two structural details are easy to miss. parallel: true plus the piped database-chain group means the static analyzers all run at the same time, while the steps that share one test database run strictly in order next to them. The wall time is the maximum of the two, not the sum of every check. On the reference app, this entire process, excluding mutation testing, takes around eight seconds. You don't need to use all of them, it's just a sample what can be added.

license_finder needs one decision up front, or it fails on the very Gemfile from the beginning of this article: brakeman ships under its own Brakeman Public Use License and bundler-audit under GPL-3.0. Commit a policy first: permit the permissive licenses your product can ship with (license_finder permitted_licenses add MIT --why "...") and approve the dev-only tools by name (license_finder approvals add brakeman --why "build-time tool, never linked into or shipped with the app"). Both land in doc/dependency_decisions.yml, which is why the hook above is globbed to Gemfile, Gemfile.lock, and the decisions file: a new license can only arrive through those. From then on a gem with a license outside the policy fails the push, and the honest way out is extending the policy with a written reason, never deleting the gate.

Check database integrity in layers

Database checks should run in dependency order. First prepare the test database and reject pending migrations. Then compare Rails models with the schema through database_consistency. Finally, run active_record_doctor against the loaded application and database.

active_record_doctor catches database problems that model-focused checks can miss, including missing foreign keys, uniqueness validations without unique indexes, missing NOT NULL constraints, mismatched foreign key types, tables without primary keys, and redundant indexes. It returns a non-zero status when configured detectors report errors, so it works as a hard CI gate.

Two more checks need a database they are allowed to break: a schema dump rewrites db/schema.rb or db/structure.sql on disk, and rollback testing deliberately migrates the database down and up. That used to be an argument for keeping them CI-only, but the throwaway test database the chain above prepares is just as safe, which is why schema-dump drift sits in pre-push too. Never point these commands at a shared development or staging database.

- name: Prepare test database
  run: env RAILS_ENV=test bin/rails db:test:prepare

- name: Reject pending migrations
  run: env RAILS_ENV=test bin/rails db:abort_if_pending_migrations

- name: Database consistency
  run: env RAILS_ENV=test bundle exec database_consistency

- name: Active Record Doctor
  run: env RAILS_ENV=test bundle exec rake active_record_doctor

- name: Detect schema dump drift
  run: |
    env RAILS_ENV=test bin/rails db:schema:dump
    git diff --exit-code -- db/schema.rb db/structure.sql

# The final diff is what makes this a gate: every db:migrate rewrites the
# schema dump, so a migration that cannot restore it must FAIL, not just
# avoid raising.
- name: Verify migrations are reversible
  run: |
    env RAILS_ENV=test bin/rails db:migrate
    env RAILS_ENV=test bin/rails db:rollback STEP=3 # you can create a better script by checking the last version before making changes
    env RAILS_ENV=test bin/rails db:migrate
    git diff --exit-code -- db/schema.rb db/structure.sql

Two details decide whether the rollback check is a gate or a decoration. First, the closing git diff --exit-code is mandatory: every db:migrate rewrites the schema dump, so without the diff the step only proves the commands did not raise, while a migration whose dump differs from the committed schema leaves the run green and the dump dirty. Second, scope it deliberately: locally, roll back only the migrations being pushed, so the cost stays flat as history grows; in CI, where wall time is cheap, roll the whole history down (bin/rails db:migrate VERSION=0) and back up. None of this proves that rollback is operationally safe on production data, nor should destructive migrations be made reversible merely to satisfy CI. strong_migrations still owns the production-safety question.

On a stock Rails 8 app, tell active_record_doctor to ignore framework models that have no table by default, or it reports them as referencing missing tables:

# .active_record_doctor.rb

ActiveRecordDoctor.configure do
  global :ignore_models, [
    /^SolidQueue::/, /^SolidCache::/, /^SolidCable::/,
    /^ActiveStorage::/, /^ActionText::/, /^ActionMailbox::/
  ]
end

Strong Migrations should be a hard line

The easiest Rails migration to generate is often the least production-safe one.

class AddEmailToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :email, :string, null: false, default: ""
    add_index :users, :email
  end
end

On a large table, that shape of migration can be painful. A safer first step is usually smaller and more boring.

class AddEmailToUsers < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def change
    add_column :users, :email, :string
    add_index :users, :email, algorithm: :concurrently
  end
end

After that, deploy code that writes the new column, backfill in batches outside the schema migration, then add constraints once the data is ready. AI is not good at knowing the size of your production tables, the lock behavior of your database, or the operational risk of "just one migration". Let strong_migrations be loud.

Add a small YAML sanity check

Rails apps often hide important behavior in YAML. Locales, credentials examples, CI files, database config, queue config, and Lefthook itself can all fail because of one indentation mistake.

yaml-parse:
  tags: [config, hygiene]
  glob: "*.{yml,yaml}"
  exclude:
    - "test/fixtures/**"
  run: ruby .hooks/check_yaml.rb {staged_files}
# .hooks/check_yaml.rb
#
# Can Ruby parse the changed YAML? Psych.parse_file builds the AST without
# constructing objects, so anchors/aliases and typed scalars parse fine while a
# broken document still raises. (YAML.load_file would reject valid Rails config.)
require "yaml"

ARGV.each do |path|
  # A staged deletion is handed to the hook as a path with nothing left on disk.
  next unless File.exist?(path)

  Psych.parse_file(path)
rescue Psych::Exception => e
  warn "Invalid YAML: #{path}"
  warn e.message
  exit 1
end

This is not a style checker. It just answers one question: can Ruby parse the changed YAML files? The one exclusion matters: Rails fixtures are ERB-templated YAML (think bcrypt digests computed inline), so a plain Psych parse rejects perfectly valid fixture files; the test suite validates those instead.

Keep frontend import boundaries explicit

Packwerk protects Ruby package boundaries. It will not protect a React, TypeScript, or JavaScript frontend. If the Rails app has a modern frontend, add ESLint import boundary rules so generated UI code cannot casually reach into private modules.

One simple option is eslint-plugin-boundaries.

// eslint.config.js

import boundaries from "eslint-plugin-boundaries";

export default [
  {
    plugins: {
      boundaries
    },
    settings: {
      "boundaries/elements": [
        {
          type: "app",
          pattern: "app/javascript/app/*"
        },
        {
          type: "shared",
          pattern: "app/javascript/shared/*"
        }
      ]
    },
    rules: {
      "boundaries/element-types": [
        "error",
        {
          default: "disallow",
          rules: [
            {
              from: "app",
              allow: ["shared"]
            },
            {
              from: "shared",
              allow: ["shared"]
            }
          ]
        }
      ]
    }
  }
];

Then add the frontend check to pre-push or CI.

eslint:
  tags: [frontend, architecture]
  run: npm run lint

Not every Rails app has npm. On an importmap plus Tailwind stack there is no package.json to hang ESLint on, but two standalone binaries close the gap: Biome lints and formats JavaScript (one binary, one biome.json, no node_modules) and rustywind keeps Tailwind class lists in canonical order, so generated markup stops producing noisy class-order diffs. Both fit the autocorrect-first pattern from pre-commit:

rustywind:
  tags: [frontend, style, tailwind]
  glob: "*.html.erb"
  run: rustywind --write {staged_files}
  stage_fixed: true

biome:
  tags: [frontend, javascript, quality]
  # NOT "app/javascript/**/*.js": in lefthook `**/` requires a subdirectory,
  # which would silently skip app/javascript/application.js.
  glob: "app/javascript/*.js"
  run: biome check --write {staged_files}
  stage_fixed: true

Duplicate the important gates in CI

Local hooks are useful, but they are not enough. Anyone can run git commit --no-verify. CI should be the source of truth.

name: quality

on:
  pull_request:
  push:
    branches:
      - main

# Supersede in-flight runs when new commits arrive, but never cancel a main
# build: its run history should stay complete.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  rails-quality:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    services:
      postgres:
        # Pin the major version: an unpinned `postgres` image means a new
        # major release can break CI overnight.
        image: postgres:17
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3

    env:
      RAILS_ENV: test
      DATABASE_URL: postgres://postgres:postgres@localhost:5432

    steps:
      - uses: actions/checkout@v7

      # Before setup-ruby on purpose: the secret scan must never see vendored gems.
      - name: "Security: Gitleaks secret scan"
        run: |
          curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
          ./gitleaks dir . --redact --no-banner
          rm gitleaks

      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true

      - name: Style and correctness
        run: bundle exec rubocop --parallel

      - name: "Security: Importmap vulnerability audit"
        run: bin/importmap audit

      - name: "Security: Brakeman code analysis"
        run: bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error --confidence-level 2

      - name: "Security: Gem audit"
        run: bin/bundler-audit check --update

      - name: Zeitwerk autoloading
        run: bin/rails zeitwerk:check

      - name: Packwerk architecture
        run: bundle exec packwerk validate && bundle exec packwerk check

      - name: Prepare test database
        run: bin/rails db:test:prepare

      - name: Reject pending migrations
        run: bin/rails db:abort_if_pending_migrations

      - name: Database consistency
        run: bundle exec database_consistency

      - name: Active Record Doctor
        run: bundle exec rake active_record_doctor

      - name: Detect schema dump drift
        run: |
          bin/rails db:schema:dump
          git diff --exit-code -- db/schema.rb db/structure.sql

      # The final diff is what makes this a gate: every db:migrate rewrites the
      # schema dump, so an irreversible or drifting migration must leave a diff,
      # not just avoid raising. VERSION=0 rolls the WHOLE history - CI wall time
      # is cheap; the pre-push hook's is not, so locally scope it to the push.
      - name: Verify the whole migration history is reversible
        run: |
          bin/rails db:migrate
          bin/rails db:migrate VERSION=0
          bin/rails db:migrate
          git diff --exit-code -- db/schema.rb db/structure.sql

      - name: Full ERB lint
        run: bundle exec erb_lint --lint-all

      - name: "Tests: Seeds"
        run: bin/rails db:seed:replant

      - name: "Tests: System"
        env:
          # The coverage minimum is a statement about the unit suite; a single
          # browser flow can never meet it.
          SKIP_COVERAGE: "1"
        run: bin/rails test:system

      - name: Test suite
        run: bundle exec rspec

CI does not need to run every local convenience check, but it should run the checks that protect security, architecture, database integrity, migrations, views, seeds, and tests. The schema drift and migration reversibility steps require a disposable CI database and a clean checkout. If the application uses RSpec system specs instead of Rails system tests, make the system test step run the relevant directory, for example bundle exec rspec spec/system, rather than keeping two commands that execute the same examples.

A practical parity rule: every gate that can fail locally should also exist in CI, otherwise git push --no-verify becomes a permanent side door. On the reference setup that is a whole second, database-free job: it re-runs the static gates (gitleaks, reek, fasterer, rails_best_practices, i18n-tasks, rubycritic, biome, rustywind, the scoped license check) and every .hooks/ guard over git ls-files output, and lints the commit messages of the pushed range, so an edit made in the GitHub web UI (which runs no hook at all) meets the same wall. A third job mutation-tests the sources the push or PR changed, on pushes to main as well, since --no-verify skips the local mutation gate too. Two details are easy to get wrong there: pin external binaries to versions (a floating npx rustywind can break CI with no change in the repo), and give range-based guards an explicit fallback for force-pushes, because a guard that reports "skipped" reads as "passed".

Add project-dependent checks where they fit

Some Rails checks are excellent, but only if the project already uses the matching tool or workflow. I would not force them into every app on day one, but I would add them as soon as the project has the relevant surface area.

If the app has meaningful test coverage, add simplecov with a minimum threshold. AI tends to add code without tests, and a coverage gate makes that visible. Give the branch dimension its own threshold too: enable_coverage :branch without a branch: minimum is decoration. The two environment guards below will earn their keep shortly: MUTATION_TEST keeps single-file mutant runs from tripping the minimum, and SKIP_COVERAGE does the same for system tests.

# spec/spec_helper.rb or test/test_helper.rb

unless ENV["MUTATION_TEST"] || ENV["SKIP_COVERAGE"]
  require "simplecov"
  require "undercover/simplecov_formatter"

  SimpleCov.formatter = SimpleCov::Formatter::Undercover

  SimpleCov.start "rails" do
    enable_coverage :branch
    minimum_coverage line: 85, branch: 80
  end
end

One trap is worth knowing before you trust that number. Rails parallel testing forks worker processes, and with a stock setup every worker records its coverage under the same command name, overwriting the other workers' entry in .resultset.json. The symptom is maddening: all tests green, and the merged report randomly missing whole directories, dipping below the minimum only sometimes. Give each worker its own command name and flush its result on teardown:

# test/test_helper.rb (Minitest; parallel_tests handles this for RSpec)
parallelize(workers: :number_of_processors)

if defined?(SimpleCov)
  parallelize_setup do |worker|
    SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}"
  end

  parallelize_teardown do |_worker|
    SimpleCov.result
  end
end

SimpleCov protects the whole project from drifting below an agreed baseline. Undercover is more targeted: it reads the Git diff, parses the changed Ruby structures, and compares them with SimpleCov output so a pull request fails when new or changed methods are not tested. Run the test suite first, then run Undercover against the branch you merge into.

rspec:
  tags: [backend, test]
  run: bundle exec rspec

undercover:
  tags: [backend, test, coverage]
  run: bundle exec undercover --compare origin/main

Undercover has a bootstrap problem worth handling explicitly, because its failure mode is silent. On a repo with no remote yet there is genuinely nothing to compare against, and skipping out loud is correct. But when origin/main exists on the server and simply does not resolve locally, exiting 0 leaves a gate that looks green while checking no code at all; that case should fail with a "fetch the baseline" message. The reference setup wraps the command in a small script that distinguishes the two.

For the highest-risk code paths, add mutation testing with the mutation_tester gem. Coverage can tell you that a line ran. mutation_tester tells you whether changing that line would make a test fail. That is a much stronger signal against shallow AI-generated specs that only exercise the happy path.

mutation-test:
  tags: [backend, test, quality, mutation]
  glob: "{app,packs}/**/*.rb"
  run: ruby .hooks/check_mutation_score.rb {push_files}
# Mutation-tests the pushed app sources (mutation_tester gem, min score 80).
# Each {app,packs/*/app}/**/*.rb source maps to exactly ONE test by mirroring its
# path with the pack prefix kept (packs/identity/app/models/party.rb ->
# packs/identity/test/models/party_test.rb). No test -> FAIL; two sources claiming
# one test -> FAIL; an allowlist entry (with a written reason) -> skip, out loud.
# --dry-run prints the source/test pairs and stops before any database work.

require "etc"
require "yaml"

ALLOWLIST_PATH = ".hooks/mutation_allowlist.yml"
# App code only: a pack's config/ (routes) is wiring, not mutation-testable logic.
APP_SOURCE = %r{\A(?:packs/[^/]+/)?app/.+\.rb\z}
SOURCE_GLOB = "{app,packs/*/app}/**/*.rb"

# MUTATION_TEST disables SimpleCov (a single-file run can never meet
# minimum_coverage, and its failing exit would count every mutant as killed);
# -Itest puts test_helper on the load path for the gem's `ruby test_file.rb` runs.
ENV["MUTATION_TEST"] = "1"
ENV["RAILS_ENV"] = "test"
ENV["RUBYOPT"] = [ ENV["RUBYOPT"], "-Itest" ].compact.join(" ").strip

WORKERS = [ Etc.nprocessors, 8 ].min

test_path_for = lambda do |source|
  source.sub(%r{\A((?:packs/[^/]+/)?)app/}, '\1test/').sub(/\.rb\z/, "_test.rb")
end

# An exemption is only reviewable if it says why, so a missing reason fails too.
load_allowlist = lambda do |path|
  next {} unless File.exist?(path)

  entries = YAML.safe_load_file(path) || {}
  abort "mutation-test: #{path} must be a mapping of 'source path' => 'reason'." unless entries.is_a?(Hash)

  unreasoned = entries.reject { |_source, reason| reason.is_a?(String) && !reason.strip.empty? }
  abort "mutation-test: every #{path} entry needs a reason. Missing: #{unreasoned.keys.join(', ')}" if unreasoned.any?

  entries
end

dry_run = ARGV.delete("--dry-run")
sources = ARGV.select { |path| path.match?(APP_SOURCE) && File.exist?(path) }
if sources.empty?
  puts "mutation-test: no pushed app sources to mutate."
  exit 0
end

allowlist = load_allowlist.call(ALLOWLIST_PATH)
# Keyed on every source on disk, not just the pushed ones: a pushed plan.rb
# collides with its twin whether or not the twin is part of this push.
claimants = Dir.glob(SOURCE_GLOB).group_by { |source| test_path_for.call(source) }

pairs = []
untested = []
collisions = []

sources.uniq.sort.each do |source|
  test_file = test_path_for.call(source)
  sharers = claimants.fetch(test_file, [ source ])

  if sharers.size > 1
    collisions << [ test_file, sharers.sort ]
  elsif File.exist?(test_file)
    pairs << [ source, test_file ]
  elsif allowlist.key?(source)
    puts "mutation-test: ALLOWED #{source} - #{allowlist[source]}"
  else
    untested << [ source, test_file ]
  end
end

collisions.uniq.each do |test_file, sharers|
  warn "mutation-test: #{sharers.join(' and ')} both map to #{test_file}"
end
if collisions.any?
  warn "One test file cannot vouch for two sources. Give each source the test that mirrors its own path."
  exit 1
end

untested.each do |source, test_file|
  warn "mutation-test: #{source} has no test (expected #{test_file})"
end
if untested.any?
  warn "Write the missing test, or record the exemption and its reason in #{ALLOWLIST_PATH}."
  exit 1
end

if pairs.empty?
  puts "mutation-test: nothing to mutate; every pushed source is allowlisted above."
  exit 0
end

pairs.each { |source, test_file| puts "mutation-test: #{source} vs #{test_file}" }
exit 0 if dry_run

# One test database per parallel worker (config/database.yml keys the name on
# TEST_ENV_NUMBER). db:test:prepare is idempotent and near-instant when current.
puts "mutation-test: preparing #{WORKERS} worker databases"
([ "" ] + (2..WORKERS).map(&:to_s)).each do |number|
  next if system({ "TEST_ENV_NUMBER" => number }, "bin/rails", "db:test:prepare", out: File::NULL)

  abort "mutation-test: db:test:prepare failed for TEST_ENV_NUMBER=#{number.inspect}"
end

# Since mutation_tester 1.3.0 an infrastructure failure (shadow-workspace abort,
# zero scored mutants) exits 3 with its own message, distinct from a genuine
# threshold failure (exit 1). nil = the command could not even be spawned.
INFRA_EXIT = 3

# --timeout-policy separate keeps load-induced timeouts out of the score
# (default policy counts them as killed, inflating it under contention).
run_pair = lambda do |source, test_file, workers|
  system("bundle", "exec", "mutation_test", source, test_file,
         "-p", workers.to_s, "--worker-env", "TEST_ENV_NUMBER",
         "--timeout-policy", "separate")
  Process.last_status&.exitstatus
end

failed = pairs.reject { |source, test_file| run_pair.call(source, test_file, WORKERS) == 0 }

# A red parallel run is not believed yet: database contention can produce false
# survivors that a single-worker run clears. One serial re-run settles it - a
# real test gap stays red, a load artifact goes green.
genuine = []
infra = []
failed.each do |source, test_file|
  warn "mutation-test: #{source} was red under parallel load; retrying serially before believing it."
  case run_pair.call(source, test_file, 1)
  when 0 then next
  when INFRA_EXIT, nil then infra << source
  else genuine << source
  end
end

infra.each do |source|
  warn "mutation-test: INFRASTRUCTURE failure for #{source} - a runner abort, not a test-quality verdict."
end
genuine.each do |source|
  warn "mutation-test: #{source} - surviving mutant(s) confirmed in a serial run."
end
if genuine.any? || infra.any?
  warn "Reports are in tmp/mutation_reports/. Close the surviving-mutant gaps; re-run infrastructure failures."
  exit 1
end

Two design decisions matter more than the plumbing. First, the source-to-test mapping is strict: each source maps to exactly one test by mirroring its path, a source with no test fails the gate instead of being skipped, and the only way past is an allowlist entry with a written reason. A gate that silently skips untested files is exactly the hole AI-generated code walks through. Second, parallelism must not corrupt the verdict. Workers get one test database each: key the test database name on TEST_ENV_NUMBER in database.yml (the parallel_tests convention) and pass --worker-env TEST_ENV_NUMBER; the script provisions them. --timeout-policy separate keeps load-induced timeouts out of the score, where the default policy counts them as killed and quietly inflates it. And a red parallel run is retried serially before it is believed, because database contention can produce false survivors that deflate the score; a real test gap stays red, a load artifact goes green. An infrastructure failure (a runner abort, zero scored mutants) exits with its own code and message, never as a threshold verdict. The MUTATION_TEST guard exists for the same reason on the coverage side: a single-file run can never meet minimum_coverage, and its failing exit would count every mutant as killed.

If the project uses FactoryBot, lint all factories in pre-push or CI. This is more useful than checking only changed factory files, because one generated factory can break another factory through traits, associations, callbacks, or validations.

factory-bot-lint:
  tags: [backend, test, factories]
  run: bundle exec rails runner 'if defined?(FactoryBot); FactoryBot.lint; else; puts "FactoryBot is not configured, skipping"; end'

If the app keeps .env.example or similar templates, dotenv-linter is a useful extra config check. It should lint templates, not real secret-bearing .env files.

dotenv-linter:
  tags: [config, hygiene]
  glob: ".env.{example,sample,template}"
  run: dotenv-linter {staged_files}

If the app ships with a Dockerfile, and on Rails 8 with Kamal it does by default, lint it with hadolint. It is a static Dockerfile linter that also runs ShellCheck on every RUN line, and it flags exactly the things AI edits get wrong while looking plausible: an unpinned base image, apt-get install without cleanup in the same layer, a missing --no-install-recommends, instructions ordered so that every commit invalidates the build cache. A bloated or slow image never fails a spec, so nothing else in this workflow would catch it.

hadolint:
  tags: [docker, hygiene]
  glob: "{Dockerfile,Dockerfile.*}"
  run: hadolint {staged_files}

hadolint is one static binary with no Ruby dependencies, so the check costs almost nothing. Dockerfiles change rarely and the glob means it only fires when they do, which makes it safe to keep in pre-commit rather than pre-push. When a rule genuinely does not apply, silence it explicitly with an inline # hadolint ignore=DL3008 or a project-level .hadolint.yaml, so the exception is visible in review instead of the whole check being skipped.

Make N+1 queries fail with Prosopite

Static checks are useful, but some Rails problems only show up when the app actually runs. N+1 queries are a perfect example. A controller action can look clean in review and still issue one query per record once the view starts touching associations.

prosopite catches those cases at runtime. In development, it can raise as soon as it detects an N+1 query. In test, it can make the spec fail instead of letting the problem drift into production.

# config/initializers/prosopite.rb

if Rails.env.development? || Rails.env.test?
  Prosopite.enabled = true
  Prosopite.rails_logger = true
  Prosopite.raise = true
end

That changes the team habit in a useful way. If AI generates a controller, serializer, or view that accidentally introduces an N+1, the test suite should fail. The fix is usually explicit eager loading with includes, preload, or a narrower query shape. The important part is that the mistake becomes visible while the change is still small.

Separate standards from review gates

Some things should fail automatically. Secrets, broken YAML, conflict markers, debug artifacts, safety_assured, Brakeman warnings, Packwerk violations, Zeitwerk failures, and test failures are good examples.

Other things need human context. Raw SQL, update_all, delete_all, destroy_all, update_columns, save(validate: false), and permission changes are not always wrong. They are often legitimate. But they should be visible in review, not hidden inside a huge generated diff.

That distinction matters. If you turn every suspicious pattern into an automatic failure, developers will fight the tool. If you let everything through because "sometimes it is valid", the tool becomes decoration. The trick is to make objective standards fail fast and keep contextual decisions visible.

The workflow is the product

The point of all this is not to make AI code impossible to commit. The point is to make bad generated code expensive to ignore. A good Rails workflow should catch boring mistakes before review and risky mistakes before production.

AI can write the first draft. The hooks decide whether that draft is ready to enter the codebase. Humans decide to merge that code to production.

Happy workflowing!