schema_reaper 1.0.3 → 1.0.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22245618558de174144cf3732fbced500e89726cafdec2ab14bdefe35d535287
4
- data.tar.gz: 95a26a72863e7f603d1d6894aed3f89948f34d85959359863db83e9d01cc5d1b
3
+ metadata.gz: 162d87ba1d2536d8db97d20dd2df6af8675529e2d967a1271d5c25ff9f1f5549
4
+ data.tar.gz: 33fa30c1d35e8f1b8cbcbd5af0e4724389462bf77921470152c9403965f4f574
5
5
  SHA512:
6
- metadata.gz: f98dcbc99cf5a8527ecdfbce06dfce99f6f678971c9d60ee96a030ba0b71eeac731e39f0e8dc87e697a9d74dcfec95d1fb1cfe06549a5b3db55a36580da42a7c
7
- data.tar.gz: dbcb412469b0bb09c2676706b5a11a6fb085649411d9af3c29d8e633fd208a9e83315905886cf9bc065af4d85a148cdab11101b9dc6ff09380887effbee17493
6
+ metadata.gz: 7c4e72d7cd3b050d21261bde44581525aff0e81190610edbff6891a8bed844d40401aa41ae8fb82e4d77781ad68b61dd75c2618d22fba625ef0ebbc0999bd6d9
7
+ data.tar.gz: 610286a554f314bc296fed9ef239a4c78946b4ea5d53ab3e2b9b9eaeb3e8e5d995b33ebd2cffdff587a7e311cbf0fc00e3463f35d776a53e4dcebfc3a49f84ff
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.5] - 2026-09-04
4
+
5
+ Fixes found by running the gem against a live PostgreSQL database.
6
+
7
+ ### Fixed
8
+ - **Duplicate findings for one object.** When several analyzers flagged the
9
+ same physical column (e.g. `dead_column` + `always_null_column`) or index
10
+ (`unused_index` + `duplicate_index`), each was reported separately *and* its
11
+ reclaimable bytes were counted more than once, inflating the run total.
12
+ Findings are now collapsed to the highest-confidence one per target; the
13
+ others are noted as `also flagged by: ...` in its evidence.
14
+ - **`generate-migration` output.** The generated STEP 1 migration contained a
15
+ fragile multi-line string (heredoc line-continuation) that rendered with a
16
+ stray gap. It is now a single clean string. Both files are verified valid
17
+ Ruby.
18
+ - **Migration schema version.** Generated migrations now inherit the host
19
+ app's Rails minor version (`ActiveRecord::Migration[X.Y]`) when ActiveRecord
20
+ is loaded, instead of a hard-coded `7.1`.
21
+
22
+ ### Added
23
+ - Specs covering target collapsing and migration generation, exercised end to
24
+ end through `Runner` with an injected schema.
25
+
26
+ ## [1.0.4] - 2026-09-04
27
+
28
+ ### Changed
29
+ - Rewrote `description` as a single tight paragraph. RubyGems collapses
30
+ description whitespace, so the previous multi-line bulleted text rendered as
31
+ an unreadable blob on the gem page. Full analyzer list and usage stay in the
32
+ README. Link metadata from 1.0.3 unchanged.
33
+
3
34
  ## [1.0.3] - 2026-09-04
4
35
 
5
36
  ### Changed
@@ -19,6 +19,19 @@ module SchemaReaper
19
19
  [type, table, column, index].compact.join("/")
20
20
  end
21
21
 
22
+ # The physical object this finding is about, ignoring which analyzer raised
23
+ # it. Two findings with the same target_key are the same underlying problem
24
+ # (e.g. dead_column + always_null_column on one column) -- keep the strongest.
25
+ def target_key
26
+ if index
27
+ ["index", table, index]
28
+ elsif column
29
+ ["column", table, column]
30
+ else
31
+ ["table", table]
32
+ end
33
+ end
34
+
22
35
  def reclaimable_bytes
23
36
  self[:reclaimable_bytes] || 0
24
37
  end
@@ -19,35 +19,38 @@ module SchemaReaper
19
19
  private
20
20
 
21
21
  def stage_one
22
+ note = "Add `self.ignored_columns += %w[#{@column}]` to the #{model} model, " \
23
+ "then deploy STEP 2 after a soak period."
22
24
  write "ignore_#{@table}_#{@column}", <<~RUBY
23
25
  # frozen_string_literal: true
24
26
 
25
27
  # STEP 1 of 2. Deploy this alone and let it soak. It only tells
26
28
  # ActiveRecord to stop selecting the column; nothing is dropped.
27
- class Ignore#{camel}Column < ActiveRecord::Migration[7.1]
29
+ class Ignore#{camel}Column < ActiveRecord::Migration[#{migration_version}]
28
30
  def up
29
- say "Add `self.ignored_columns += %w[#{@column}]` to the #{model} model, " \
30
- "then deploy STEP 2 after a soak period."
31
+ say #{note.inspect}
31
32
  end
32
33
 
33
- def down; end
34
+ def down
35
+ end
34
36
  end
35
37
  RUBY
36
38
  end
37
39
 
38
40
  def stage_two
41
+ down_msg = "recreate :#{@column} on :#{@table} manually if you need it back"
39
42
  write "drop_#{@table}_#{@column}", <<~RUBY
40
43
  # frozen_string_literal: true
41
44
 
42
- # STEP 2 of 2. Run only after STEP 1 has been deployed and verified.
43
- class Drop#{camel}Column < ActiveRecord::Migration[7.1]
45
+ # STEP 2 of 2. Run only after STEP 1 has been deployed and verified in
46
+ # production (nothing reads the column, no errors).
47
+ class Drop#{camel}Column < ActiveRecord::Migration[#{migration_version}]
44
48
  def up
45
49
  remove_column :#{@table}, :#{@column}
46
50
  end
47
51
 
48
52
  def down
49
- raise ActiveRecord::IrreversibleMigration,
50
- "recreate :#{@column} on :#{@table} manually if you need it back"
53
+ raise ActiveRecord::IrreversibleMigration, #{down_msg.inspect}
51
54
  end
52
55
  end
53
56
  RUBY
@@ -61,6 +64,14 @@ module SchemaReaper
61
64
  path
62
65
  end
63
66
 
67
+ # Match the host app's Rails minor version when ActiveRecord is loaded,
68
+ # otherwise a sane recent default the developer can edit.
69
+ def migration_version
70
+ return "7.1" unless defined?(ActiveRecord::VERSION::STRING)
71
+
72
+ ActiveRecord::VERSION::STRING.split(".").first(2).join(".")
73
+ end
74
+
64
75
  def camel
65
76
  "#{@table}_#{@column}".split("_").map(&:capitalize).join
66
77
  end
@@ -5,6 +5,8 @@ require "set"
5
5
  module SchemaReaper
6
6
  # Ties introspection + scanning + runtime data + analyzers together.
7
7
  class Runner
8
+ SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
9
+
8
10
  def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil)
9
11
  @config = config
10
12
  @root = root
@@ -29,13 +31,30 @@ module SchemaReaper
29
31
 
30
32
  private
31
33
 
34
+ def dedupe(findings)
35
+ collapse_targets(drop_findings_on_dead_tables(findings))
36
+ end
37
+
32
38
  # When a whole table is dead, its per-column and per-index findings are
33
39
  # noise -- keep only the table-level finding for that table.
34
- def dedupe(findings)
40
+ def drop_findings_on_dead_tables(findings)
35
41
  dead_tables = findings.select { |f| f.type == :dead_table }.to_set(&:table)
36
42
  findings.reject { |f| f.type != :dead_table && dead_tables.include?(f.table) }
37
43
  end
38
44
 
45
+ # Several analyzers can flag the same physical column or index (e.g.
46
+ # dead_column + always_null_column). Keep the highest-confidence finding per
47
+ # target so it is reported -- and its bytes counted -- exactly once, but note
48
+ # the other analyzers that agreed.
49
+ def collapse_targets(findings)
50
+ findings.group_by(&:target_key).map do |_key, group|
51
+ winner = group.max_by { |f| [f.confidence, -SEVERITY_RANK.fetch(f.severity, 9)] }
52
+ also = group.map(&:type).uniq - [winner.type]
53
+ winner.evidence += ["also flagged by: #{also.join(", ")}"] if also.any?
54
+ winner
55
+ end
56
+ end
57
+
39
58
  def schema
40
59
  return @introspector.call if @introspector
41
60
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.3"
4
+ VERSION = "1.0.5"
5
5
  end
@@ -8,39 +8,16 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["aksshatt"]
9
9
  spec.email = ["akshatpegwar5@gmail.com"]
10
10
 
11
- spec.summary = "Find dead columns, dead tables, unused indexes and other schema dead-weight " \
12
- "in Rails/ActiveRecord apps -- then remove it safely."
13
- spec.description = <<~DESC
14
- schema_reaper reads your live PostgreSQL schema and planner statistics and
15
- cross-references them against a Prism-AST static scan of your codebase
16
- (models, views, SQL string literals). With an optional runtime signal -- a
17
- sampled log of which columns are actually read in production -- it fuses
18
- static and runtime evidence into a confidence score per finding.
19
-
20
- Analyzers:
21
- * dead_column -- column no code path references
22
- * dead_table -- table with no model/query reference, zero rows
23
- * unused_index -- non-unique index, idx_scan = 0 in pg_stat
24
- * duplicate_index -- index that is a prefix of a wider index
25
- * missing_fk_index -- foreign-key / *_id column with no covering index
26
- * always_null_column -- pg_stats null_frac = 1.0, carries no data
27
- * single_value_column -- one distinct value across a large table
28
-
29
- Every finding carries a reclaimable-bytes estimate (from row counts) and a
30
- concrete fix. `generate-migration` emits a staged, reversible pair:
31
- ignored_columns first, remove_column after a soak. Nothing is dropped
32
- automatically. Columns owned by common gems (devise, paper_trail,
33
- activestorage, actiontext, friendly_id, audited, pg_search, ahoy_matey,
34
- paranoia) are whitelisted when the gem is in your bundle.
35
-
36
- Reporters: table, json, markdown (PR comments), SARIF 2.1.0 (GitHub code
37
- scanning). `scan --ci` gates only findings absent from a committed baseline.
38
- `trend` keeps an append-only history log for cleanup burndown. A Rails
39
- railtie adds rake tasks and an opt-in runtime tracker. Custom analyzers
40
- load through the `require:` config key.
41
-
42
- PostgreSQL only for now (MySQL adapter on the roadmap). Ruby >= 2.7.
43
- DESC
11
+ spec.summary = "Find and safely remove schema dead-weight in Rails + PostgreSQL apps."
12
+ spec.description =
13
+ "schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL apps: " \
14
+ "dead columns and tables, unused, duplicate and missing foreign-key indexes, and " \
15
+ "always-NULL or single-value columns. It cross-references the live schema and pg_stats " \
16
+ "against a static scan of your code, with an optional production runtime signal for " \
17
+ "higher confidence. Each finding is scored, carries a reclaimable-bytes estimate, and " \
18
+ "ships with a staged, reversible migration. Reporters for terminal, JSON, Markdown and " \
19
+ "SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL only for now; " \
20
+ "Ruby >= 2.7. See the README for full usage."
44
21
  spec.homepage = "https://github.com/aksshatt/schema_reaper"
45
22
  spec.license = "MIT"
46
23
  spec.required_ruby_version = ">= 2.7.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_reaper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - aksshatt
@@ -78,36 +78,14 @@ dependencies:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
80
  version: '1.5'
81
- description: |
82
- schema_reaper reads your live PostgreSQL schema and planner statistics and
83
- cross-references them against a Prism-AST static scan of your codebase
84
- (models, views, SQL string literals). With an optional runtime signal -- a
85
- sampled log of which columns are actually read in production -- it fuses
86
- static and runtime evidence into a confidence score per finding.
87
-
88
- Analyzers:
89
- * dead_column -- column no code path references
90
- * dead_table -- table with no model/query reference, zero rows
91
- * unused_index -- non-unique index, idx_scan = 0 in pg_stat
92
- * duplicate_index -- index that is a prefix of a wider index
93
- * missing_fk_index -- foreign-key / *_id column with no covering index
94
- * always_null_column -- pg_stats null_frac = 1.0, carries no data
95
- * single_value_column -- one distinct value across a large table
96
-
97
- Every finding carries a reclaimable-bytes estimate (from row counts) and a
98
- concrete fix. `generate-migration` emits a staged, reversible pair:
99
- ignored_columns first, remove_column after a soak. Nothing is dropped
100
- automatically. Columns owned by common gems (devise, paper_trail,
101
- activestorage, actiontext, friendly_id, audited, pg_search, ahoy_matey,
102
- paranoia) are whitelisted when the gem is in your bundle.
103
-
104
- Reporters: table, json, markdown (PR comments), SARIF 2.1.0 (GitHub code
105
- scanning). `scan --ci` gates only findings absent from a committed baseline.
106
- `trend` keeps an append-only history log for cleanup burndown. A Rails
107
- railtie adds rake tasks and an opt-in runtime tracker. Custom analyzers
108
- load through the `require:` config key.
109
-
110
- PostgreSQL only for now (MySQL adapter on the roadmap). Ruby >= 2.7.
81
+ description: 'schema_reaper finds schema dead-weight in Rails/ActiveRecord + PostgreSQL
82
+ apps: dead columns and tables, unused, duplicate and missing foreign-key indexes,
83
+ and always-NULL or single-value columns. It cross-references the live schema and
84
+ pg_stats against a static scan of your code, with an optional production runtime
85
+ signal for higher confidence. Each finding is scored, carries a reclaimable-bytes
86
+ estimate, and ships with a staged, reversible migration. Reporters for terminal,
87
+ JSON, Markdown and SARIF; a CI baseline gate; a trend log; a Rails railtie. PostgreSQL
88
+ only for now; Ruby >= 2.7. See the README for full usage.'
111
89
  email:
112
90
  - akshatpegwar5@gmail.com
113
91
  executables:
@@ -188,6 +166,5 @@ requirements: []
188
166
  rubygems_version: 3.4.10
189
167
  signing_key:
190
168
  specification_version: 4
191
- summary: Find dead columns, dead tables, unused indexes and other schema dead-weight
192
- in Rails/ActiveRecord apps -- then remove it safely.
169
+ summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
193
170
  test_files: []