schema_reaper 2.0.0 → 2.1.0

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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/.schema_reaper.yml.example +17 -0
  4. data/CHANGELOG.md +144 -0
  5. data/PRO.md +11 -6
  6. data/README.md +421 -71
  7. data/RELEASE_CHECKLIST.md +13 -2
  8. data/lib/generators/schema_reaper/install/install_generator.rb +11 -11
  9. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  10. data/lib/schema_reaper/alert_config.rb +21 -1
  11. data/lib/schema_reaper/alert_state.rb +35 -0
  12. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  13. data/lib/schema_reaper/analyzers/base.rb +66 -2
  14. data/lib/schema_reaper/analyzers/dead_column.rb +12 -6
  15. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  16. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  17. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  18. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  19. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  20. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  21. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  22. data/lib/schema_reaper/baseline.rb +95 -10
  23. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  24. data/lib/schema_reaper/cli.rb +69 -14
  25. data/lib/schema_reaper/config.rb +28 -0
  26. data/lib/schema_reaper/doctor.rb +151 -0
  27. data/lib/schema_reaper/explain.rb +102 -0
  28. data/lib/schema_reaper/gem_awareness.rb +31 -1
  29. data/lib/schema_reaper/inflect.rb +36 -0
  30. data/lib/schema_reaper/introspect/postgres.rb +258 -55
  31. data/lib/schema_reaper/migration_generator.rb +90 -4
  32. data/lib/schema_reaper/notifier.rb +69 -8
  33. data/lib/schema_reaper/railtie.rb +1 -1
  34. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  35. data/lib/schema_reaper/reporters/slack.rb +67 -0
  36. data/lib/schema_reaper/runner.rb +58 -6
  37. data/lib/schema_reaper/runtime.rb +94 -11
  38. data/lib/schema_reaper/scan_job.rb +24 -2
  39. data/lib/schema_reaper/schema.rb +32 -3
  40. data/lib/schema_reaper/schema_locator.rb +75 -0
  41. data/lib/schema_reaper/static/cache.rb +66 -0
  42. data/lib/schema_reaper/static/scanner.rb +174 -6
  43. data/lib/schema_reaper/tasks/schema_reaper.rake +2 -2
  44. data/lib/schema_reaper/version.rb +1 -1
  45. data/lib/schema_reaper.rb +18 -1
  46. data/schema_reaper.gemspec +1 -2
  47. metadata +13 -4
data/README.md CHANGED
@@ -1,7 +1,18 @@
1
- # schema_reaper
1
+ <div align="center">
2
2
 
3
- Find dead columns, dead tables, unused indexes and other schema dead-weight in
4
- Rails / ActiveRecord apps — then remove them safely.
3
+ <img src="docs/assets/hero.svg" width="100%" alt="schema_reaper — find and safely remove the dead columns, tables and indexes your Rails + PostgreSQL app no longer uses" />
4
+
5
+ [![Gem Version](https://img.shields.io/gem/v/schema_reaper?color=cc342d&logo=rubygems&logoColor=white&label=gem)](https://rubygems.org/gems/schema_reaper)
6
+ [![Gem Downloads](https://img.shields.io/gem/dt/schema_reaper?color=cc342d&logo=rubygems&logoColor=white&label=downloads)](https://rubygems.org/gems/schema_reaper)
7
+ [![CI](https://github.com/aksshatt/schema_reaper/actions/workflows/main.yml/badge.svg)](https://github.com/aksshatt/schema_reaper/actions/workflows/main.yml)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE.txt)
9
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-CC342D?logo=ruby&logoColor=white)](schema_reaper.gemspec)
10
+ [![PostgreSQL](https://img.shields.io/badge/postgres-only%20(for%20now)-336791?logo=postgresql&logoColor=white)](#install)
11
+
12
+ | ⚡ [Quickstart](#quickstart) | ⚙️ [Usage](#usage) | 🤖 [Production automation](#production-automation) | 🔍 [Analyzers](#analyzers) | 🛡️ [Safety model](#safety-model) | ✅ [CI](#ci) | 🧩 [Configuration](#configuration) | 💼 [Pro](#pro-for-teams) |
13
+ |:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
14
+
15
+ </div>
5
16
 
6
17
  `schema_reaper` reads your **live PostgreSQL schema and planner statistics** and
7
18
  cross-references them against a **static scan of your codebase** (Ruby via the
@@ -10,129 +21,461 @@ Prism AST, plus views and SQL string literals). Optionally it also fuses in a
10
21
  production. Every finding is scored by confidence and severity, carries an
11
22
  estimate of the disk it reclaims, and comes with a concrete fix.
12
23
 
13
- ## Install
24
+ It finds three kinds of schema debt:
25
+
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/debt-dark.svg">
28
+ <img src="docs/assets/debt-light.svg" width="100%" alt="Dead weight — columns and tables nothing references any more (dead_column, dead_table). Index trouble — indexes nobody queries, indexes a wider one already covers, and foreign keys with no index (unused_index, duplicate_index, missing_fk_index). Degenerate data — columns that are always NULL or hold one value in every row (always_null_column, single_value_column).">
29
+ </picture>
30
+
31
+ ## Quickstart
14
32
 
15
33
  ```ruby
16
34
  # Gemfile
17
- gem "schema_reaper", group: :development
35
+ gem "schema_reaper"
18
36
  ```
19
37
 
20
- ```
38
+ ```sh
21
39
  bundle install
40
+ bundle exec schema_reaper scan
41
+ ```
42
+
43
+ In a Rails app that's it — the connection comes from `config/database.yml`.
44
+ You get a report like this:
45
+
46
+ <p align="center">
47
+ <img src="docs/assets/terminal.svg" width="100%" alt="Terminal output of bundle exec schema_reaper scan: 3 findings across 2 tables — users.team_id has no covering index (90%, medium, fix: add_index :users, :team_id); users.api_key is NULL in every row (85%, high, 46.9 KB reclaimable); table stale_exports has no model or query reference and holds 0 rows (85%, high, fix: drop_table :stale_exports after confirming no external consumer)." />
48
+ </p>
49
+
50
+ > [!TIP]
51
+ > Scan a database that has served real traffic — a production read-replica or a
52
+ > recent snapshot. `unused_index` needs query history (it skips itself on a
53
+ > fresh database and tells you why), and the data analyzers read `pg_stats`,
54
+ > which PostgreSQL only fills in after `ANALYZE`.
55
+
56
+ ## How it works
57
+
58
+ <picture>
59
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/pipeline-dark.svg">
60
+ <img src="docs/assets/pipeline-light.svg" width="100%" alt="Three inputs — the live PostgreSQL schema and statistics, a static scan of your codebase, and an optional runtime signal — feed seven analyzers. Their scored findings become reports in four formats, a CI baseline gate, scheduled webhook and email reports, and staged removal migrations.">
61
+ </picture>
62
+
63
+ Findings that say the same thing about different tables are rolled up into one
64
+ entry; when several analyzers flag the same column, the strongest finding wins
65
+ and notes the others that agreed. `schema_reaper` never changes your database —
66
+ it only reads, and hands you migrations to review.
67
+
68
+ ## Install
69
+
70
+ ```ruby
71
+ # Gemfile
72
+ gem "schema_reaper"
22
73
  ```
23
74
 
24
- Requires Ruby >= 2.7 and PostgreSQL. The database connection is resolved in
25
- this order:
75
+ | requirement | notes |
76
+ |---|---|
77
+ | Ruby >= 2.7 | CI-tested on 2.7 – 3.3 |
78
+ | PostgreSQL + the `pg` gem | uses your app's own `pg` (every Rails + PostgreSQL app already bundles it); add `gem "pg"` for standalone CLI use. Partitioned tables are reported once, as the parent. Tables in the `public` schema are scanned by default; list others under `schemas:` in the [config](#configuration). MySQL is on the [roadmap](#roadmap). |
79
+ | Rails | optional for the CLI; the rake tasks and production automation need it, and the runtime tracker needs ActiveRecord |
80
+
81
+ > [!IMPORTANT]
82
+ > **Only scanning locally or in CI?** Scope it out of your production bundle:
83
+ > `gem "schema_reaper", group: :development`.
84
+ >
85
+ > **Planning to use [production automation](#production-automation)?** Leave it
86
+ > unscoped. Most deploys exclude the development and test groups
87
+ > (`BUNDLE_WITHOUT=development:test`), so a dev-scoped gem is simply absent when
88
+ > the scheduled scan tries to run. The install generator warns you about this.
89
+
90
+ <details>
91
+ <summary><b>How the database connection is resolved</b></summary>
92
+
93
+ <br>
26
94
 
27
95
  1. `database_url:` in `.schema_reaper.yml`
28
96
  2. `ENV["DATABASE_URL"]`
29
- 3. `config/database.yml` for the current environment (`SCHEMA_REAPER_ENV` /
30
- `RAILS_ENV`, default `development`) — ERB and YAML aliases are handled, as
31
- are Rails 6+ multi-database sections
97
+ 3. `config/database.yml` for the current environment (`SCHEMA_REAPER_ENV`, then
98
+ `RAILS_ENV`, then `RACK_ENV`, default `development`) — ERB and YAML aliases
99
+ are handled, as are Rails 6+ multi-database sections (the `primary` entry is
100
+ used)
32
101
 
33
- So in a Rails app, `bundle exec schema_reaper scan` works with no setup.
102
+ </details>
34
103
 
35
104
  ## Usage
36
105
 
37
- ```
106
+ ```sh
38
107
  bundle exec schema_reaper scan # grouped terminal report
39
108
  bundle exec schema_reaper scan --format markdown # PR-comment table
40
109
  bundle exec schema_reaper scan --format sarif # GitHub code scanning
41
110
  bundle exec schema_reaper scan --format json
42
- bundle exec schema_reaper scan --ci # exit 1 on new findings
111
+ bundle exec schema_reaper scan --ci # exit 1 on findings not in the baseline
43
112
  bundle exec schema_reaper scan --min-confidence 0.8
44
- bundle exec schema_reaper scan --no-color # force plain output
113
+ bundle exec schema_reaper scan --only dead_column unused_index # or --except ...
45
114
  bundle exec schema_reaper baseline # accept current findings
115
+ bundle exec schema_reaper baseline --reason "billing ETL" --expires 2027-03-31
116
+ bundle exec schema_reaper baseline --prune # drop entries that no longer occur
117
+ bundle exec schema_reaper explain users/legacy_api_token # one finding in full + next step
118
+ bundle exec schema_reaper doctor # is the setup good enough to trust a scan?
46
119
  bundle exec schema_reaper trend # snapshot + progress delta
47
- bundle exec schema_reaper generate-migration users legacy_api_token
120
+ bundle exec schema_reaper generate-migration users legacy_api_token # column: staged pair
121
+ bundle exec schema_reaper generate-migration users --index index_users_on_legacy_token
122
+ bundle exec schema_reaper generate-migration old_audit_logs # whole table
123
+ ```
124
+
125
+ <details>
126
+ <summary><b>All <code>scan</code> options</b></summary>
127
+
128
+ <br>
129
+
130
+ | option | default | what it does |
131
+ |---|---|---|
132
+ | `--format` | `table` | `table`, `json`, `markdown` or `sarif` |
133
+ | `--ci` | off | exit 1 when a finding isn't in `.schema_reaper/baseline.json` |
134
+ | `--min-confidence N` | `min_confidence` in config, else `0.0` | hide findings below this confidence (0.0 – 1.0) |
135
+ | `--only A B` / `--except A B` | all | run only, or skip, these analyzers (also on `baseline` and `trend`) |
136
+ | `--record` | off | also append this run to the history log used by `trend` |
137
+ | `--color` / `--no-color` | auto | force colour on or off (works on every command) |
138
+ | `--config PATH` | `.schema_reaper.yml` | config file to load (works on every command) |
139
+
140
+ Colour is automatic on a terminal, and off when output is piped or `NO_COLOR` is set.
141
+
142
+ </details>
143
+
144
+ <details>
145
+ <summary><b>Rake tasks (Rails)</b></summary>
146
+
147
+ <br>
148
+
149
+ The railtie adds these to any Rails app with the gem in its bundle:
150
+
151
+ | task | what it does |
152
+ |---|---|
153
+ | `rake schema_reaper:scan` | same as `schema_reaper scan`; pick a format with `FORMAT=json` etc. |
154
+ | `rake schema_reaper:baseline` | write the current findings to the baseline file |
155
+ | `rake schema_reaper:trend` | record a snapshot and print the trend |
156
+ | `rake schema_reaper:alert` | run one production scan and send the report — what the [schedule](#production-automation) calls |
157
+
158
+ </details>
159
+
160
+ ## Production automation
161
+
162
+ *New in v2.0.* Run `schema_reaper` unattended in production and have the report
163
+ land in your team's chat and inbox — no DevOps ticket, and no server access
164
+ beyond a normal deploy.
165
+
166
+ ```sh
167
+ bin/rails generate schema_reaper:install
168
+ ```
169
+
170
+ <picture>
171
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/automation-dark.svg">
172
+ <img src="docs/assets/automation-light.svg" width="100%" alt="whenever or cron runs rake schema_reaper:alert, sidekiq-cron enqueues the job directly, and the token-protected manual trigger POST /internal/schema_scan enqueues it on demand. All three run SchemaReaper::ScanJob, which scans the live database and your code; the Notifier sends the report to a Slack-compatible webhook and by email through your ApplicationMailer.">
173
+ </picture>
174
+
175
+ | generated | purpose |
176
+ |---|---|
177
+ | `config/initializers/schema_reaper.rb` | where reports go (webhook and/or email) |
178
+ | an entry in `config/schedule.rb` **or** `config/schedule.yml` | the quarterly scheduled scan (whenever or sidekiq-cron) |
179
+ | `app/controllers/schema_reaper_controller.rb` + a route | a token-protected "scan now" endpoint |
180
+ | a trigger token, printed once | you paste it into your encrypted credentials |
181
+
182
+ Re-running the generator is safe: every step checks for its own marker and
183
+ skips itself rather than duplicating anything. Commit the files, deploy, and
184
+ check it works end to end with one manual run in production:
185
+
186
+ ```sh
187
+ bin/rails schema_reaper:alert
188
+ ```
189
+
190
+ ### 1. Where reports go
191
+
192
+ The generated initializer is commented out, so an unfilled config is a safe
193
+ no-op, not an error. Uncomment one or both:
194
+
195
+ ```ruby
196
+ SchemaReaper::AlertConfig.configure do |config|
197
+ # config.emails = %w[dev1@example.com dev2@example.com]
198
+ # config.webhook_url = "https://hooks.slack.com/services/T000/B000/XXXX"
199
+ end
48
200
  ```
49
201
 
50
- The `scan` report rolls up findings that say the same thing about different
51
- tables, then groups the rest by table and sorts by confidence:
202
+ Both channels fire when both are set — not a fallback chain, but two audiences:
203
+ a dev-facing chat channel, and an inbox record for people who don't watch chat.
204
+
205
+ Every report says what changed since the previous one (remembered in
206
+ `Rails.cache`). Set `config.alert_on = :changes` to send only when findings
207
+ appeared or were resolved; the default, `:always`, sends every run.
208
+
209
+ - **Webhook** — for a `hooks.slack.com` URL, a Slack Block Kit summary (counts,
210
+ the top or newly added findings, what was resolved); for any other URL, a
211
+ `{"text": "..."}` JSON POST carrying the full Markdown report. Force either
212
+ with `config.webhook_format = :slack` or `:text`. 10-second timeout.
213
+ - **Email** — sent by `SchemaReaper::Mailer`, which inherits your app's
214
+ `ApplicationMailer` (its `default from:`, delivery settings and so on), or
215
+ `ActionMailer::Base` if you don't have one. Delivered with `deliver_later`.
216
+
217
+ Delivery failures are logged with a `[schema_reaper]` prefix and never raised,
218
+ so a flaky endpoint can't fail or retry-storm the scan.
219
+
220
+ ### 2. The schedule
221
+
222
+ The generator looks at your `Gemfile.lock` and uses whichever scheduler you
223
+ already run. The default cadence is **quarterly**; edit the generated entry to
224
+ change it.
225
+
226
+ | you use | the generator adds | also needed |
227
+ |---|---|---|
228
+ | [whenever](https://github.com/javan/whenever) | an `every 3.months` block running `rake "schema_reaper:alert"` in `config/schedule.rb` | whenever only writes your crontab when `whenever --update-crontab` runs on the server — usually via its Capistrano recipe. Platforms without a crontab (Heroku, most container hosts) need one of the other rows. |
229
+ | [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron) | a `schema_reaper_scan` entry in `config/schedule.yml` (`0 4 1 */3 *` — 04:00 on the 1st, every 3 months) that enqueues `SchemaReaper::ScanJob` | nothing — sidekiq-cron ≥ 1.6 loads `config/schedule.yml` automatically |
230
+ | neither | nothing — it prints the command instead of guessing | point any scheduler (cron, Heroku Scheduler, a Kubernetes CronJob) at `bin/rails schema_reaper:alert` |
231
+
232
+ `rake schema_reaper:alert` enqueues the scan on your ActiveJob backend. If that
233
+ backend is Rails' in-process `:async` adapter (the default before Rails 8 unless
234
+ you've set one up), the job would die with the short-lived rake process — so
235
+ the task runs the scan inline instead, email included.
236
+
237
+ ### 3. The manual trigger
238
+
239
+ For "scan now" without shelling into a box:
52
240
 
241
+ ```sh
242
+ curl -X POST https://your-app.example.com/internal/schema_scan \
243
+ -H "Authorization: Bearer <token>"
53
244
  ```
54
- schema_reaper 5 findings across 2 tables
55
- missing_fk_index 3 · always_null_column 1 · dead_column 1
56
- ~93.8 KB reclaimable
57
245
 
58
- users
59
- █████ 90% medium missing_fk_index team_id
60
- team_id is a foreign key with no covering index
61
- → add_index :users, :team_id
62
- ████░ 85% high always_null_column api_key 46.9 KB
63
- pg_stats.null_frac = 1.0 across ~3000 row(s) · column carries no data
64
- → verify with `SELECT count(api_key) FROM users` then stage a removal
246
+ | response | meaning |
247
+ |---|---|
248
+ | `202 Accepted` | scan enqueued — the report arrives through the channels above |
249
+ | `401 Unauthorized` | missing or wrong token, or no token configured in this environment |
250
+ | `429 Too Many Requests` | a scan was already triggered in the last 5 minutes |
65
251
 
66
- stale_exports
67
- ████░ 85% high dead_table
68
- no model or query reference · table holds ~0 row(s)
69
- → confirm no external consumer, then `drop_table :stale_exports`
252
+ The generator prints the token once, with the exact snippet to paste into
253
+ `bin/rails credentials:edit`:
70
254
 
71
- high 2 medium 1 low 2
255
+ ```yaml
256
+ schema_reaper:
257
+ trigger_token: <token>
72
258
  ```
73
259
 
74
- Colour is automatic on a terminal, off when piped or `NO_COLOR` is set.
260
+ Credentials are decrypted with the `RAILS_MASTER_KEY` your app already has, so
261
+ this adds no new production configuration. The token is checked with a
262
+ constant-time comparison. The controller deliberately doesn't inherit your
263
+ `ApplicationController` and skips CSRF (it's a token-authenticated API endpoint,
264
+ not a form), so your own auth filters don't apply to it.
265
+
266
+ > [!NOTE]
267
+ > The 5-minute cooldown lives in `Rails.cache`, so it's only as shared as your
268
+ > cache store. Redis, Memcached or Solid Cache give one cooldown for the whole
269
+ > app; a file or memory store gives one per host or process; `:null_store`
270
+ > turns it off.
271
+
272
+ ### 4. Install-time warnings
273
+
274
+ Two misconfigurations would otherwise fail silently much later, so the
275
+ generator flags them when it runs:
276
+
277
+ - the gem is scoped to `group: :development` — the scheduled scan would never
278
+ run in production
279
+ - neither `ApplicationMailer` nor `config.action_mailer.default_options` sets a
280
+ `from:` address — the mailer would fall back to a placeholder sender that most
281
+ SMTP relays reject, so email reports would never arrive
282
+
283
+ > [!WARNING]
284
+ > **Still on 2.0.0?** Two production-automation bugs are fixed on `main` and
285
+ > ship in the next release:
286
+ > - Report emails ignore `ApplicationMailer` and are sent from
287
+ > `schema_reaper@localhost`. Work around it by adding
288
+ > `SchemaReaper::Mailer.default from: "you@your-domain.com"` to
289
+ > `config/initializers/schema_reaper.rb`.
290
+ > - With the `:async` ActiveJob adapter, `rake schema_reaper:alert` enqueues a
291
+ > job that never runs. Use a persistent backend (Sidekiq, GoodJob, Solid
292
+ > Queue, …) for the whenever and plain-cron paths.
75
293
 
76
- In a Rails app the railtie also gives you
77
- `rake schema_reaper:scan|baseline|trend` (with `FORMAT=`).
294
+ <details>
295
+ <summary><b>Why not just run it in CI?</b></summary>
296
+
297
+ <br>
298
+
299
+ [`--format sarif` in CI](#ci) is for PR- and staging-level checks. GitHub-hosted
300
+ runners have no network path to your production database by default, and a CI
301
+ database has no production traffic statistics. Production automation is the path
302
+ for watching the real, live database.
303
+
304
+ </details>
78
305
 
79
306
  ## Analyzers
80
307
 
81
308
  | type | what it flags | main signal |
82
309
  |---|---|---|
83
310
  | `dead_column` | column no code path references | static scan (+ runtime) |
84
- | `dead_table` | table with no model/query reference | static scan + row count |
85
- | `unused_index` | non-unique index, `idx_scan = 0` | `pg_stat_user_indexes` |
86
- | `duplicate_index` | index that is a prefix of a wider one | schema shape |
87
- | `missing_fk_index` | `*_id` / FK column with no index | schema shape |
311
+ | `dead_table` | table with no model or query reference | static scan + row count |
312
+ | `unused_index` | non-unique index with `idx_scan = 0` (needs query history; stats are per server, so check replicas too) | `pg_stat_user_indexes` |
313
+ | `duplicate_index` | index that exactly duplicates another, or is a prefix of a wider one | schema shape |
314
+ | `missing_fk_index` | `*_id` / foreign-key column with no index (polymorphic pairs need a `(type, id)` index) | schema shape |
88
315
  | `always_null_column` | `null_frac = 1.0` — no data at all | `pg_stats` |
89
- | `single_value_column` | one distinct value on a large table | `pg_stats` |
90
-
91
- Columns owned by common gems (devise, paper_trail, activestorage, actiontext,
92
- friendly_id, audited, pg_search, ahoy_matey, paranoia family) are whitelisted
93
- automatically when the gem is in your bundle.
316
+ | `single_value_column` | one distinct value on a table of 500+ rows | `pg_stats` |
317
+ | `sequence_exhaustion` | serial / IDENTITY sequence past 50% of its column type's range (high at 75%) | `pg_sequences` |
318
+ | `duplicate_foreign_key` | two FK constraints on the same columns to the same table | `pg_constraint` |
319
+
320
+ Columns and tables owned by common gems are whitelisted automatically when the
321
+ gem is in your bundle: **devise**, **devise-api**, **paper_trail**,
322
+ **audited**, **friendly_id**, **paranoia** / **acts_as_paranoid**,
323
+ **activestorage**, **actiontext**, **pg_search**, **ahoy_matey**,
324
+ **activeadmin**, **solid_queue**, **solid_cache**, **solid_cable**,
325
+ **good_job**, **delayed_job_active_record**, **flipper-active_record**,
326
+ **acts-as-taggable-on**, **doorkeeper**, **noticed**,
327
+ **activerecord-session_store**, **discard**, **acts_as_list** and
328
+ **ancestry**. A table a gem owns outright is left alone by every analyzer.
94
329
 
95
330
  ## Runtime signal (optional, raises confidence)
96
331
 
97
332
  Static analysis alone can't see metaprogrammed access, so `dead_column`
98
- confidence is capped at **0.6** without runtime data. To lift the cap:
333
+ confidence is capped at **0.6** without runtime data. To lift the cap, sample
334
+ real column reads:
99
335
 
100
336
  ```ruby
101
- # config/initializers or manually
337
+ # config/initializers/schema_reaper_tracker.rb
102
338
  SchemaReaper::Runtime::Tracker.install!(
103
339
  store: SchemaReaper::Runtime::Store.new(path: ".schema_reaper/runtime.jsonl"),
104
340
  sample_rate: 0.05
105
341
  )
106
342
  ```
107
343
 
108
- or, in Rails, boot with `SCHEMA_REAPER_TRACK=1`. Let it run in staging or
344
+ or, in Rails, boot with `SCHEMA_REAPER_TRACK=1` (and optionally
345
+ `SCHEMA_REAPER_SAMPLE=0.05` for the sample rate). The tracker also samples
346
+ the tables named in executed SQL (pass `track_sql: false` to opt out), so a
347
+ table read only through raw SQL isn't reported as dead. The log rotates to
348
+ `runtime.jsonl.1` past `runtime_log_max_bytes` (50 MB), and both generations
349
+ are read. Let it run in staging or
109
350
  production for a couple of weeks. A column unseen in **both** code and
110
- >= 14 observed days of runtime data reaches ~0.9 confidence.
351
+ ≥ 14 observed days of runtime data (`min_age_days` in
352
+ [`.schema_reaper.yml`](#configuration)) reaches **0.9** confidence (0.8 if it's
353
+ `NOT NULL`).
354
+
355
+ > [!NOTE]
356
+ > This is separate from [production automation](#production-automation): the
357
+ > tracker raises confidence in the findings a scan already makes, while
358
+ > production automation runs the scan on a schedule and delivers the report.
359
+ > Most teams want both.
111
360
 
112
361
  ## Safety model
113
362
 
114
- `schema_reaper` never drops anything itself. `generate-migration` emits a pair:
363
+ <picture>
364
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/safety-dark.svg">
365
+ <img src="docs/assets/safety-light.svg" width="100%" alt="generate-migration writes two migrations. Step 1 adds the column to ignored_columns and is deployed; nothing is dropped. Once it has soaked in production and nothing reads the column, step 2 runs remove_column, which is irreversible.">
366
+ </picture>
367
+
368
+ `schema_reaper` never drops anything itself. For a dead column,
369
+ `generate-migration users legacy_api_token` writes a pair:
115
370
 
116
- 1. **Ignore** — you add `self.ignored_columns += %w[col]` to the model and
117
- deploy. Nothing is dropped.
118
- 2. **Drop** — run only after step 1 has soaked in production and nothing broke.
371
+ 1. **`…_ignore_users_legacy_api_token.rb`** — a no-op migration that reminds
372
+ you to add `self.ignored_columns += %w[legacy_api_token]` to the model. Deploy
373
+ that. Nothing is dropped; ActiveRecord just stops selecting the column.
374
+ 2. **`…_drop_users_legacy_api_token.rb`** — the `remove_column`. Run it only
375
+ after step 1 has soaked in production and nothing broke. Its `down` raises
376
+ `ActiveRecord::IrreversibleMigration` on purpose.
119
377
 
120
- `always_null_column` / `single_value_column` fixes ask you to confirm with a
121
- `SELECT` first.
378
+ The data-driven fixes are cautious too: `always_null_column` asks you to
379
+ confirm with a `SELECT count(...)` first, `single_value_column` to check the
380
+ value isn't a meaningful default, and `dead_table` to rule out external
381
+ consumers.
122
382
 
123
383
  ## CI
124
384
 
385
+ <picture>
386
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/ci-dark.svg">
387
+ <img src="docs/assets/ci-light.svg" width="100%" alt="On each pull request, schema_reaper scan --ci compares the findings with the committed baseline.json. It passes when every finding is already in the baseline and exits 1 when the change adds new dead weight.">
388
+ </picture>
389
+
390
+ ```yaml
391
+ # .github/workflows/schema_reaper.yml (the relevant parts)
392
+ permissions:
393
+ contents: read
394
+ security-events: write # required by upload-sarif
395
+
396
+ # ...job setup: Ruby, a PostgreSQL service, DATABASE_URL...
397
+ steps:
398
+ - run: bin/rails db:schema:load
399
+ - run: bundle exec schema_reaper scan --ci --format sarif > reaper.sarif
400
+ - uses: github/codeql-action/upload-sarif@v4
401
+ if: always() # upload even when --ci fails the step above
402
+ with: { sarif_file: reaper.sarif }
403
+ ```
404
+
405
+ Commit `.schema_reaper/baseline.json` (from `schema_reaper baseline`) so the job
406
+ fails only when a change adds *new* dead weight.
407
+
408
+ > [!NOTE]
409
+ > A freshly loaded CI database has no rows, statistics or query history, so CI
410
+ > catches the schema- and code-shaped findings (`dead_column`, `dead_table`,
411
+ > `duplicate_index`, `missing_fk_index`). The data-driven ones need a real
412
+ > database — that's what [production automation](#production-automation) is for.
413
+
414
+ ## Configuration
415
+
416
+ Every key is optional. Drop a `.schema_reaper.yml` in the project root to
417
+ override any of these defaults.
418
+
419
+ > [!CAUTION]
420
+ > A list you set **replaces** the default list rather than adding to it. Setting
421
+ > `ignore: { tables: [legacy_audit] }` stops ignoring `schema_migrations` and
422
+ > `ar_internal_metadata`, and `always_keep_columns: [uuid]` stops protecting
423
+ > `created_at`, `updated_at` and `type`. Repeat the defaults you still want.
424
+
425
+ <details>
426
+ <summary><b>.schema_reaper.yml — all keys with their defaults</b></summary>
427
+
428
+ <br>
429
+
125
430
  ```yaml
126
- # .github/workflows/schema_reaper.yml
127
- - run: bundle exec schema_reaper scan --ci --format sarif > reaper.sarif
128
- - uses: github/codeql-action/upload-sarif@v3
129
- with: { sarif_file: reaper.sarif }
431
+ database_url: # falls back to DATABASE_URL, then config/database.yml
432
+ database_yml: config/database.yml
433
+ schemas: [public] # PostgreSQL schemas to scan; non-public tables appear as schema.table
434
+ scan_paths: [app, lib, config]
435
+ view_globs: ["app/**/*.erb", "app/**/*.haml", "app/**/*.slim", "app/**/*.jbuilder"]
436
+ ignore:
437
+ tables: [schema_migrations, ar_internal_metadata]
438
+ columns: [] # exact names, or "/regex/" patterns
439
+ always_keep_columns: [id, created_at, updated_at, type]
440
+ gem_awareness: true # auto-whitelist columns owned by known gems
441
+ min_age_days: 14 # days of runtime data before dead_column trusts it
442
+ min_confidence: 0.0 # hide findings below this on every command
443
+ runtime_log_max_bytes: 52428800 # rotate the runtime log past this size (null: never)
444
+ runtime_log: .schema_reaper/runtime.jsonl
445
+ history_log: .schema_reaper/history.jsonl
446
+ baseline: .schema_reaper/baseline.json
447
+ require: [] # extra files to load, e.g. custom analyzers
448
+ scan_cache: .schema_reaper/scan_cache.json # per-file scan cache (null: off)
449
+ column_scope: global # or model -- see "Stricter column matching"
130
450
  ```
131
451
 
132
- Commit `.schema_reaper/baseline.json` so the job fails only when a change adds
133
- *new* dead weight.
452
+ </details>
453
+
454
+ ### Keeping a finding from code
134
455
 
135
- ## Custom analyzers
456
+ A `schema_reaper:keep` comment anywhere in scanned Ruby or view files silences
457
+ matching findings, with the reason kept next to the code it's about:
458
+
459
+ ```ruby
460
+ class User < ApplicationRecord
461
+ # schema_reaper:keep legacy_api_token, users.import_ref -- read by the billing ETL
462
+ end
463
+ ```
464
+
465
+ A target is a column (on any table), `table.column`, a table (every finding on
466
+ it), or an index name.
467
+
468
+ ### Stricter column matching
469
+
470
+ By default a column counts as used if its name appears anywhere in scanned
471
+ code, so `status` in `Order` also hides a dead `status` on `users`. With
472
+ `column_scope: model`, a name seen only in an `app/models` file counts for that
473
+ model's table and for the tables the model mentions (associations, class
474
+ names); names in controllers, views, jobs and so on still count everywhere.
475
+ It finds more dead columns, at some risk of flagging one used through
476
+ metaprogramming -- review before acting.
477
+
478
+ ### Custom analyzers
136
479
 
137
480
  ```ruby
138
481
  # lib/schema_reaper/analyzers/my_check.rb
@@ -153,7 +496,11 @@ require:
153
496
 
154
497
  ## Roadmap
155
498
 
156
- - Runtime verdict fusion for index and table findings
499
+ > [!TIP]
500
+ > ✅ Self-hosted scheduled scans + alerts shipped in **v2.0** — see
501
+ > [Production automation](#production-automation).
502
+
503
+ - Runtime verdict fusion for index findings
157
504
  - Orphan-row and `schema.rb`↔DB drift analyzers
158
505
  - Disk/$ reclaim from real `pg_total_relation_size`
159
506
  - Mountable dashboard engine, trend charts
@@ -161,12 +508,12 @@ require:
161
508
 
162
509
  ## Pro (for teams)
163
510
 
164
- The gem is free and complete for a single app. **schema_reaper Pro** adds the
165
- team-scale layer: MySQL adapter, multi-database fan-out, orphan-row and
166
- schema-drift analyzers, Slack/Jira/PR-comment reporters, real
167
- `pg_total_relation_size` + $ estimates, scheduled scans with alerts, and a
168
- mountable dashboard engine. See [PRO.md](PRO.md). Waitlist / early access:
169
- open an issue tagged `pro`.
511
+ The gem is free and complete for a single app — including the scheduled scans
512
+ and alerts above. **schema_reaper Pro** adds the team-scale layer: MySQL
513
+ adapter, multi-database fan-out, orphan-row and schema-drift analyzers, native
514
+ Slack/Jira/PR-comment reporters, real `pg_total_relation_size` + $ estimates, a
515
+ hosted zero-install scan runner, and a mountable dashboard engine. See
516
+ [PRO.md](PRO.md). Waitlist / early access: open an issue tagged `pro`.
170
517
 
171
518
  ## Sponsor
172
519
 
@@ -176,11 +523,14 @@ repo.
176
523
 
177
524
  ## Development
178
525
 
179
- ```
526
+ ```sh
180
527
  bin/setup
181
528
  bundle exec rake # rspec + rubocop
529
+
530
+ # opt-in: live-database introspection specs
531
+ SCHEMA_REAPER_TEST_DATABASE_URL=postgres://localhost/schema_reaper_test bundle exec rspec
182
532
  ```
183
533
 
184
534
  ## License
185
535
 
186
- MIT.
536
+ [MIT](LICENSE.txt).
data/RELEASE_CHECKLIST.md CHANGED
@@ -152,8 +152,19 @@ a throwaway PostgreSQL with a deliberately dirty schema.
152
152
 
153
153
  ## 6. Publish the gem
154
154
 
155
- RubyGems requires account MFA; `gem push` prompts for an OTP that only the
156
- maintainer can enter.
155
+ Pushing the `vX.Y.Z` tag (§5) runs `.github/workflows/release.yml`: it checks
156
+ the tag matches `VERSION`, is on `main`, has a CHANGELOG section and no AI
157
+ trailers in history; runs the full suite against PostgreSQL; builds the gem
158
+ and diffs the packaged `lib/` against `git ls-files`; then (after approval, if
159
+ the `release` environment requires reviewers) pushes to RubyGems via Trusted
160
+ Publishing and creates the GitHub release from the CHANGELOG section.
161
+
162
+ One-time setup: rubygems.org -> schema_reaper -> Trusted publishers -> add
163
+ repository `aksshatt/schema_reaper`, workflow `release.yml`, environment
164
+ `release`.
165
+
166
+ Manual fallback -- RubyGems requires account MFA; `gem push` prompts for an
167
+ OTP that only the maintainer can enter.
157
168
 
158
169
  ```sh
159
170
  gem build schema_reaper.gemspec