schema_reaper 1.0.13 → 1.0.15
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 +4 -4
- data/CHANGELOG.md +44 -0
- data/lib/schema_reaper/analyzers/duplicate_index.rb +72 -20
- data/lib/schema_reaper/introspect/postgres.rb +9 -15
- data/lib/schema_reaper/schema.rb +9 -1
- data/lib/schema_reaper/static/scanner.rb +51 -0
- data/lib/schema_reaper/version.rb +1 -1
- data/schema_reaper.gemspec +11 -9
- metadata +16 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b327fade324c6f2e9a871da57ae46814c1b077a1aca3aec64ca6681f270ffe6
|
|
4
|
+
data.tar.gz: d63156d86d7dc44e92204475c3d23ffa7e6b87730d0a436dc9aefd041ea8b843
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb3fe82c18f9f198ec544089305b183f2a413123093edd6f437a04c8d0b32879c123d1e4d1c338a1e03539a8eb16847406bb21f4eab5932ed6b4aba4fcdf461e
|
|
7
|
+
data.tar.gz: 1c1cf99e43ff66383f5b3bb0c42d5cfec209cbc8dab3bb986e734b6dcf75706c5ed1ecbf09d61f116ce2883957630f0ceeff7a102fb49793fe73cd93cd8283ad
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.15] - 2026-09-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`dead_column` still missed the two gems its own macro-detection feature
|
|
7
|
+
named as motivation.** 1.0.14 added recognition for `has_secure_password`,
|
|
8
|
+
`attr_encrypted`, and Lockbox macro columns, but only the first actually
|
|
9
|
+
worked. `attr_encrypted`'s real default column naming is a *prefix*
|
|
10
|
+
(`encrypted_<attr>`), confirmed against the gem's own
|
|
11
|
+
`attr_encrypted_default_options`; the feature generated a suffix guess
|
|
12
|
+
that never matched it. Lockbox's real macro is `has_encrypted`; the
|
|
13
|
+
feature checked for `lockbox_encrypts`, which is not a method that
|
|
14
|
+
exists in the gem, so it never even fired for genuine Lockbox code.
|
|
15
|
+
Rewritten as a macro-name -> column-template table so each macro's real
|
|
16
|
+
naming shape is expressed explicitly, and dropped an unnecessary
|
|
17
|
+
suffix guess for Rails' native `encrypts` (which stores ciphertext in
|
|
18
|
+
the original column and creates no extra one). Eight new specs cover
|
|
19
|
+
all four macros -- this had no test coverage before. (#15, mitkush)
|
|
20
|
+
|
|
21
|
+
## [1.0.14] - 2026-09-18
|
|
22
|
+
|
|
23
|
+
Published directly without a version-bump commit or tag; this entry
|
|
24
|
+
reconciles the record after the fact. Confirmed by diffing the
|
|
25
|
+
published gem against `main` at the time -- the only difference was the
|
|
26
|
+
version string.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- `duplicate_index` respecting uniqueness and partial `WHERE` clauses --
|
|
30
|
+
see 1.0.13's entry for the underlying issue; this release adds the fix.
|
|
31
|
+
(#12, mitkush)
|
|
32
|
+
- `dead_column` missing `has_secure_password`, `encrypts`, and
|
|
33
|
+
`attr_encrypted` macro columns -- a virtual attribute like
|
|
34
|
+
`has_secure_password` never has its generated column name
|
|
35
|
+
(`password_digest`) written anywhere in application code, so it looked
|
|
36
|
+
unreferenced. (aksshatt)
|
|
37
|
+
|
|
38
|
+
### Known issue
|
|
39
|
+
- The `attr_encrypted`/Lockbox part of the macro-column fix above does
|
|
40
|
+
not work as intended: it derives a suffix-based column name
|
|
41
|
+
(`<attr>_encrypted`) that does not match `attr_encrypted`'s actual
|
|
42
|
+
default naming (`encrypted_<attr>`, a prefix), and it references a
|
|
43
|
+
`lockbox_encrypts` macro name that does not exist in Lockbox -- the
|
|
44
|
+
gem's real method is `has_encrypted`. `has_secure_password` and Rails'
|
|
45
|
+
native `encrypts` are unaffected and work correctly. Tracked for 1.0.15.
|
|
46
|
+
|
|
3
47
|
## [1.0.13] - 2026-09-18
|
|
4
48
|
|
|
5
49
|
### Fixed
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module SchemaReaper
|
|
4
4
|
module Analyzers
|
|
5
5
|
# An index whose column list is a leading prefix of another index on the
|
|
6
|
-
# same table is redundant (the wider index serves both)
|
|
6
|
+
# same table is redundant (the wider index serves both), and two indexes
|
|
7
|
+
# with identical column lists are redundant with each other.
|
|
7
8
|
class DuplicateIndex < Base
|
|
8
9
|
Registry.register(self)
|
|
9
10
|
|
|
@@ -14,28 +15,79 @@ module SchemaReaper
|
|
|
14
15
|
|
|
15
16
|
private
|
|
16
17
|
|
|
18
|
+
# A partial index only exists for rows matching its WHERE clause and is
|
|
19
|
+
# usually there on purpose (a smaller, faster index for one condition),
|
|
20
|
+
# so it is excluded entirely rather than reasoned about: not a
|
|
21
|
+
# candidate for removal, and not a stand-in for a full index either.
|
|
22
|
+
# Comparing WHERE clauses for implication is out of scope here, and a
|
|
23
|
+
# wrong guess in either direction is a real index gone from production.
|
|
17
24
|
def dupes_in(table)
|
|
18
|
-
non_pk = table.indexes.reject
|
|
19
|
-
non_pk.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
non_pk = table.indexes.reject { |index| index.primary || index.partial? }
|
|
26
|
+
by_columns = non_pk.group_by(&:columns)
|
|
27
|
+
non_pk.filter_map { |index| finding_for(table, index, non_pk, by_columns) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def finding_for(table, index, non_pk, by_columns)
|
|
31
|
+
covering = covering_for(index, non_pk, by_columns)
|
|
32
|
+
return unless covering
|
|
33
|
+
|
|
34
|
+
finding(
|
|
35
|
+
type: :duplicate_index,
|
|
36
|
+
table: table.name,
|
|
37
|
+
index: index.name,
|
|
38
|
+
column: index.columns.join(","),
|
|
39
|
+
severity: :low,
|
|
40
|
+
confidence: 0.8,
|
|
41
|
+
bytes_per_row: 0,
|
|
42
|
+
evidence: [evidence_for(index, covering)],
|
|
43
|
+
suggested_fix: "remove_index :#{table.name}, name: :#{index.name}"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Two indexes with the same column list have no natural wider/narrower
|
|
48
|
+
# direction, so comparing them pairwise would have each flag the other --
|
|
49
|
+
# applying both suggested fixes would then drop the column pair
|
|
50
|
+
# entirely. Instead, one designated survivor per exact-column group is
|
|
51
|
+
# chosen once; every other member of the group is redundant against it,
|
|
52
|
+
# and the survivor itself is only checked against a genuinely wider
|
|
53
|
+
# prefix elsewhere on the table.
|
|
54
|
+
def covering_for(index, non_pk, by_columns)
|
|
55
|
+
peers = by_columns[index.columns]
|
|
56
|
+
return prefix_covering_for(index, non_pk) if peers.size == 1
|
|
57
|
+
|
|
58
|
+
survivor = survivor_of(peers)
|
|
59
|
+
return survivor unless survivor == index
|
|
60
|
+
|
|
61
|
+
prefix_covering_for(index, non_pk)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Keep a unique index over a non-unique one -- it enforces a guarantee
|
|
65
|
+
# the others don't -- breaking further ties by name for a stable,
|
|
66
|
+
# order-independent choice.
|
|
67
|
+
def survivor_of(peers)
|
|
68
|
+
unique_peers = peers.select(&:unique)
|
|
69
|
+
(unique_peers.empty? ? peers : unique_peers).min_by(&:name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prefix_covering_for(index, non_pk)
|
|
73
|
+
non_pk.find do |o|
|
|
74
|
+
o != index && o.columns != index.columns && o.covers?(index) && safe_to_drop?(index, against: o)
|
|
37
75
|
end
|
|
38
76
|
end
|
|
77
|
+
|
|
78
|
+
# A wider index does not make a narrower prefix unique: unique (a, b)
|
|
79
|
+
# says nothing about whether a alone is unique. So a unique index is
|
|
80
|
+
# only safe to drop in favour of another index that is itself unique on
|
|
81
|
+
# that exact same column list -- never a merely-wider or non-unique one.
|
|
82
|
+
def safe_to_drop?(index, against:)
|
|
83
|
+
!index.unique || (against.unique && against.columns == index.columns)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def evidence_for(index, covering)
|
|
87
|
+
relation = covering.columns == index.columns ? "duplicates" : "is a prefix of"
|
|
88
|
+
"#{index.name} (#{index.columns.join(", ")}) #{relation} " \
|
|
89
|
+
"#{covering.name} (#{covering.columns.join(", ")})"
|
|
90
|
+
end
|
|
39
91
|
end
|
|
40
92
|
end
|
|
41
93
|
end
|
|
@@ -100,22 +100,16 @@ module SchemaReaper
|
|
|
100
100
|
end
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# Columns
|
|
104
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
# joined each indkey entry against pg_attribute by attnum; an expression
|
|
110
|
-
# column's indkey entry is 0, which matches no real column, so the join
|
|
111
|
-
# silently dropped it -- an index on (COALESCE(a, b), c) came back as
|
|
112
|
-
# just "c", which then looked like a genuine duplicate of any ordinary
|
|
113
|
-
# index on :c. indnkeyatts also excludes INCLUDE columns, which are
|
|
114
|
-
# payload only and never participate in Index#covers?'s prefix check.
|
|
103
|
+
# Columns come back in index-key order via pg_get_indexdef(indexrelid,
|
|
104
|
+
# column_no, pretty), keyed by column position rather than table attnum
|
|
105
|
+
# so it renders a plain column and an expression uniformly and never
|
|
106
|
+
# silently drops one. indnkeyatts excludes INCLUDE columns, which are
|
|
107
|
+
# payload only. partial (indpred IS NOT NULL) lets analyzers refuse to
|
|
108
|
+
# treat a conditional index as if it covered every row.
|
|
115
109
|
def indexes_for(table)
|
|
116
110
|
exec(<<~SQL, [table]).map do |r|
|
|
117
111
|
SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
|
|
118
|
-
s.idx_scan AS scans,
|
|
112
|
+
(ix.indpred IS NOT NULL) AS partial, s.idx_scan AS scans,
|
|
119
113
|
array_to_string(
|
|
120
114
|
array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true) ORDER BY k.ord),
|
|
121
115
|
'#{COLUMN_SEPARATOR}'
|
|
@@ -126,11 +120,11 @@ module SchemaReaper
|
|
|
126
120
|
JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
|
|
127
121
|
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
|
|
128
122
|
WHERE t.relname = $1
|
|
129
|
-
GROUP BY i.relname, ix.indisunique, ix.indisprimary, s.idx_scan
|
|
123
|
+
GROUP BY i.relname, ix.indisunique, ix.indisprimary, ix.indpred, s.idx_scan
|
|
130
124
|
SQL
|
|
131
125
|
Index.new(
|
|
132
126
|
name: r["name"], columns: r["cols"].split(COLUMN_SEPARATOR),
|
|
133
|
-
unique: r["unique"] == "t", primary: r["primary"] == "t",
|
|
127
|
+
unique: r["unique"] == "t", primary: r["primary"] == "t", partial: r["partial"] == "t",
|
|
134
128
|
scans: r["scans"]&.to_i
|
|
135
129
|
)
|
|
136
130
|
end
|
data/lib/schema_reaper/schema.rb
CHANGED
|
@@ -19,10 +19,18 @@ module SchemaReaper
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
Index = Struct.new(:name, :columns, :unique, :primary, :scans, keyword_init: true) do
|
|
22
|
+
Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, keyword_init: true) do
|
|
23
23
|
def covers?(other)
|
|
24
24
|
columns.first(other.columns.length) == other.columns
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# A partial index only exists for rows matching its WHERE clause, so it
|
|
28
|
+
# cannot stand in for a full index for rows outside that condition.
|
|
29
|
+
# comparing WHERE clauses for implication is out of scope here, so a
|
|
30
|
+
# partial index is simply never treated as covering another.
|
|
31
|
+
def partial?
|
|
32
|
+
!!partial
|
|
33
|
+
end
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
Table = Struct.new(
|
|
@@ -12,6 +12,38 @@ module SchemaReaper
|
|
|
12
12
|
RUBY_GLOB = "**/*.rb"
|
|
13
13
|
WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
|
|
14
14
|
|
|
15
|
+
# Macros whose generated column name differs from the virtual attribute
|
|
16
|
+
# declared in the call, so the real column name never appears anywhere
|
|
17
|
+
# in application code and would otherwise look dead. %s is replaced with
|
|
18
|
+
# the declared attribute.
|
|
19
|
+
#
|
|
20
|
+
# Several shapes are listed for some macros on purpose: getting the real
|
|
21
|
+
# default right matters most, but an extra guess that never matches a
|
|
22
|
+
# real column is harmless -- over-collecting is this scanner's stated
|
|
23
|
+
# design, and both attr_encrypted and Lockbox let a call override their
|
|
24
|
+
# naming, which a static scan cannot always evaluate.
|
|
25
|
+
#
|
|
26
|
+
# attr_encrypted's own default is a PREFIX (encrypted_<attr>), not a
|
|
27
|
+
# suffix -- confirmed against the gem's own attr_encrypted_default_options
|
|
28
|
+
# (prefix: "encrypted_", suffix: ""). The suffix form is kept as a
|
|
29
|
+
# fallback for calls that override it.
|
|
30
|
+
#
|
|
31
|
+
# Lockbox's real macro is has_encrypted, not lockbox_encrypts.
|
|
32
|
+
#
|
|
33
|
+
# Rails' own `encrypts` has no entry: it stores ciphertext in the
|
|
34
|
+
# original column, so the bare attribute name is what needs to be
|
|
35
|
+
# "used", and that is already picked up as a symbol literal by
|
|
36
|
+
# tokens_for below -- do not add a suffix pattern for it.
|
|
37
|
+
MACRO_COLUMN_TEMPLATES = {
|
|
38
|
+
"has_secure_password" => ["%s_digest"],
|
|
39
|
+
"attr_encrypted" => ["encrypted_%s", "%s_encrypted", "%s_ciphertext"],
|
|
40
|
+
"has_encrypted" => ["%s_ciphertext", "%s_iv", "%s_tag"]
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# The attribute a macro implies when called with no explicit name, e.g.
|
|
44
|
+
# bare `has_secure_password` -> :password.
|
|
45
|
+
MACRO_DEFAULT_ATTR = { "has_secure_password" => "password" }.freeze
|
|
46
|
+
|
|
15
47
|
# Node classes whose #name (or #unescaped) is a bare identifier we treat
|
|
16
48
|
# as a possible column/table reference.
|
|
17
49
|
NAME_NODES = [
|
|
@@ -61,6 +93,7 @@ module SchemaReaper
|
|
|
61
93
|
return unless node.is_a?(Prism::Node)
|
|
62
94
|
|
|
63
95
|
out.merge(tokens_for(node))
|
|
96
|
+
out.merge(macro_derived_tokens(node)) if node.is_a?(Prism::CallNode)
|
|
64
97
|
node.compact_child_nodes.each { |c| collect_from_node(c, out) }
|
|
65
98
|
end
|
|
66
99
|
|
|
@@ -78,6 +111,24 @@ module SchemaReaper
|
|
|
78
111
|
end
|
|
79
112
|
end
|
|
80
113
|
|
|
114
|
+
def macro_derived_tokens(node)
|
|
115
|
+
call_name = node.name&.to_s
|
|
116
|
+
templates = call_name && MACRO_COLUMN_TEMPLATES[call_name]
|
|
117
|
+
return [] unless templates
|
|
118
|
+
|
|
119
|
+
attrs = macro_symbol_args(node)
|
|
120
|
+
attrs = [MACRO_DEFAULT_ATTR[call_name]].compact if attrs.empty?
|
|
121
|
+
attrs.product(templates).map { |attr, template| format(template, attr) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Leading bare symbol arguments of a call, e.g. `encrypts :a, :b, purpose: :x`
|
|
125
|
+
# => ["a", "b"]. Stops at the first non-symbol (keyword args, etc).
|
|
126
|
+
def macro_symbol_args(node)
|
|
127
|
+
args = node.arguments&.arguments || []
|
|
128
|
+
args.take_while { |a| a.is_a?(Prism::SymbolNode) }
|
|
129
|
+
.map { |a| a.unescaped.to_s.downcase }
|
|
130
|
+
end
|
|
131
|
+
|
|
81
132
|
def text_tokens(path)
|
|
82
133
|
File.read(path).scan(WORD_RE).to_set(&:downcase)
|
|
83
134
|
rescue StandardError
|
data/schema_reaper.gemspec
CHANGED
|
@@ -9,15 +9,17 @@ Gem::Specification.new do |spec|
|
|
|
9
9
|
spec.email = ["akshatpegwar5@gmail.com", "mitanshukushwah@gmail.com"]
|
|
10
10
|
|
|
11
11
|
spec.summary = "Find and safely remove schema dead-weight in Rails + PostgreSQL apps."
|
|
12
|
-
spec.description =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
spec.description = <<~DESC.strip
|
|
13
|
+
schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
|
|
14
|
+
|
|
15
|
+
It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
|
|
16
|
+
|
|
17
|
+
Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
|
|
18
|
+
|
|
19
|
+
Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
|
|
20
|
+
|
|
21
|
+
PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
|
|
22
|
+
DESC
|
|
21
23
|
spec.homepage = "https://github.com/aksshatt/schema_reaper"
|
|
22
24
|
spec.license = "MIT"
|
|
23
25
|
spec.required_ruby_version = ">= 2.7.0"
|
metadata
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: schema_reaper
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- aksshatt
|
|
8
8
|
- mitkush
|
|
9
|
+
autorequire:
|
|
9
10
|
bindir: exe
|
|
10
11
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
+
date: 2026-09-18 00:00:00.000000000 Z
|
|
12
13
|
dependencies:
|
|
13
14
|
- !ruby/object:Gem::Dependency
|
|
14
15
|
name: prism
|
|
@@ -78,14 +79,16 @@ dependencies:
|
|
|
78
79
|
- - "~>"
|
|
79
80
|
- !ruby/object:Gem::Version
|
|
80
81
|
version: '1.5'
|
|
81
|
-
description:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
estimate, and
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
description: |-
|
|
83
|
+
schema_reaper scans a Rails + PostgreSQL app for schema debt that's easy to accumulate and hard to find by hand.
|
|
84
|
+
|
|
85
|
+
It checks your live database against your codebase and flags three kinds of problems: things nothing references anymore (dead columns and dead tables), index trouble (indexes nobody queries, indexes made redundant by a wider index that already covers them, and foreign-key columns with no index at all), and degenerate data (columns that are always NULL, or hold the exact same value in every row).
|
|
86
|
+
|
|
87
|
+
Every finding comes with a confidence score, an estimate of the disk space removing it would reclaim, and a concrete fix. For a column, that's a two-step migration: stop reading it first, then drop it once you've confirmed nothing broke. An optional runtime tracker can sample real production traffic to raise confidence further, for cases a static code scan alone can't settle.
|
|
88
|
+
|
|
89
|
+
Reports come as a colored terminal summary, JSON, Markdown for a PR comment, or SARIF for GitHub code scanning -- plus a CI baseline gate and a trend log to track progress release over release.
|
|
90
|
+
|
|
91
|
+
PostgreSQL only for now. Requires Ruby 2.7 or later. Full usage is in the README.
|
|
89
92
|
email:
|
|
90
93
|
- akshatpegwar5@gmail.com
|
|
91
94
|
- mitanshukushwah@gmail.com
|
|
@@ -156,6 +159,7 @@ metadata:
|
|
|
156
159
|
wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
|
|
157
160
|
funding_uri: https://github.com/sponsors/aksshatt
|
|
158
161
|
rubygems_mfa_required: 'true'
|
|
162
|
+
post_install_message:
|
|
159
163
|
rdoc_options: []
|
|
160
164
|
require_paths:
|
|
161
165
|
- lib
|
|
@@ -170,7 +174,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
170
174
|
- !ruby/object:Gem::Version
|
|
171
175
|
version: '0'
|
|
172
176
|
requirements: []
|
|
173
|
-
rubygems_version: 3.
|
|
177
|
+
rubygems_version: 3.3.3
|
|
178
|
+
signing_key:
|
|
174
179
|
specification_version: 4
|
|
175
180
|
summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
|
|
176
181
|
test_files: []
|