schema_reaper 1.0.12 → 1.0.14
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 +30 -0
- data/lib/schema_reaper/analyzers/base.rb +18 -6
- data/lib/schema_reaper/analyzers/dead_table.rb +2 -1
- data/lib/schema_reaper/analyzers/duplicate_index.rb +72 -20
- data/lib/schema_reaper/gem_awareness.rb +29 -0
- data/lib/schema_reaper/introspect/postgres.rb +21 -10
- data/lib/schema_reaper/runner.rb +7 -0
- data/lib/schema_reaper/schema.rb +9 -1
- data/lib/schema_reaper/static/scanner.rb +39 -0
- data/lib/schema_reaper/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f169920afef1838471a418e29ad207b6c9d3193ef57caf6678cf68d675a3405
|
|
4
|
+
data.tar.gz: 3e5d507411e727278004def7a752db662620a01371625ce761af4495fd020f2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 739b0cf29def40063c3a182ad5b9d207f7409f9277e134e76bd980bc3db276a333f225a1974bf5c30a3e14af769d0c8128efeb46961d084f1349fc17bddd6eca
|
|
7
|
+
data.tar.gz: 692ed7533e2553ebbc9085001f1d805519149fe988860e23da9902b941de60c292a8918e60f2fe9c2d0e558aaeacd182d31e6376570e7ea9e8551977d5856db8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.13] - 2026-09-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Expression key columns were silently dropped from an index's column
|
|
7
|
+
list.** `indexes_for` joined `pg_index.indkey` entries against
|
|
8
|
+
`pg_attribute` by `attnum`; an expression column's `indkey` entry is
|
|
9
|
+
`0`, which matches no real column, so the join dropped it instead of
|
|
10
|
+
erroring. An index on `((data ->> 'type')), ((data ->> 'id')), user_id,
|
|
11
|
+
created_at` came back as just `user_id,created_at`, which then made a
|
|
12
|
+
plain index on `:user_id` look like a genuine leading-edge duplicate of
|
|
13
|
+
a key it was not actually a prefix of -- `duplicate_index` recommended
|
|
14
|
+
dropping an index that ordinary `WHERE user_id = ?` lookups depended
|
|
15
|
+
on. Rewritten to use `pg_get_indexdef(indexrelid, column_no, pretty)`,
|
|
16
|
+
which is keyed by column position rather than table attnum and renders
|
|
17
|
+
a plain column and an expression the same way. Also switched the
|
|
18
|
+
internal column separator from `,` to a control byte, since an
|
|
19
|
+
expression can legitimately contain a comma (`COALESCE(a, b)`) that a
|
|
20
|
+
comma-delimited split would have cut in half. (#9, mitkush)
|
|
21
|
+
- **A table a gem creates and owns outright could be reported as dead.**
|
|
22
|
+
`dead_table` never consulted `GemAwareness`, so a table only ever
|
|
23
|
+
referenced through a gem's own internal classes -- never named in app
|
|
24
|
+
code -- looked exactly like real dead weight. Confirmed on a real app:
|
|
25
|
+
all of that app's `dead_table` findings were gem-owned tables
|
|
26
|
+
(`active_admin_comments`, `devise_api_tokens`, `friendly_id_slugs`),
|
|
27
|
+
and `drop_table :devise_api_tokens` would have deleted its mobile
|
|
28
|
+
session storage. `GemAwareness.owned_tables` now feeds `dead_table` the
|
|
29
|
+
same way it already fed the `dead_column` exemption. Also added
|
|
30
|
+
`activeadmin` and `devise-api` to the gem map, verified against each
|
|
31
|
+
gem's own migration template. (#10, mitkush)
|
|
32
|
+
|
|
3
33
|
## [1.0.12] - 2026-09-15
|
|
4
34
|
|
|
5
35
|
### Fixed
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
3
5
|
module SchemaReaper
|
|
4
6
|
module Analyzers
|
|
5
7
|
# Context passed to every analyzer's #call.
|
|
6
|
-
# schema
|
|
7
|
-
# used_tokens
|
|
8
|
-
# runtime
|
|
9
|
-
# gem_columns
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# schema - DatabaseSchema
|
|
9
|
+
# used_tokens - Set<String> from the static scan
|
|
10
|
+
# runtime - Runtime::Report (may be empty)
|
|
11
|
+
# gem_columns - Hash{table_name => Set<column_name>} reserved by gems
|
|
12
|
+
# gem_owned_tables - Set<table_name> created and owned outright by a gem
|
|
13
|
+
# config - Config
|
|
14
|
+
Context = Struct.new(:schema, :used_tokens, :runtime, :gem_columns, :gem_owned_tables, :config,
|
|
15
|
+
keyword_init: true) do
|
|
12
16
|
def runtime
|
|
13
17
|
self[:runtime] || Runtime::Report.empty
|
|
14
18
|
end
|
|
@@ -16,6 +20,10 @@ module SchemaReaper
|
|
|
16
20
|
def gem_columns
|
|
17
21
|
self[:gem_columns] || {}
|
|
18
22
|
end
|
|
23
|
+
|
|
24
|
+
def gem_owned_tables
|
|
25
|
+
self[:gem_owned_tables] || Set.new
|
|
26
|
+
end
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
# Shared plumbing for analyzers: schema access and token lookup helpers.
|
|
@@ -57,6 +65,10 @@ module SchemaReaper
|
|
|
57
65
|
ctx.gem_columns.fetch(table, []).include?(column)
|
|
58
66
|
end
|
|
59
67
|
|
|
68
|
+
def gem_owned_table?(table)
|
|
69
|
+
ctx.gem_owned_tables.include?(table)
|
|
70
|
+
end
|
|
71
|
+
|
|
60
72
|
# Builds a Finding, filling in reclaimable_bytes from the row count.
|
|
61
73
|
# An unknown row count leaves reclaimable_bytes nil: "we cannot say" is
|
|
62
74
|
# not the same claim as "zero bytes", and reporters need to tell them
|
|
@@ -24,7 +24,8 @@ module SchemaReaper
|
|
|
24
24
|
|
|
25
25
|
def ignored?(table)
|
|
26
26
|
config.ignore_tables.include?(table.name) ||
|
|
27
|
-
table.name.start_with?("active_storage_", "action_text_", "action_mailbox_")
|
|
27
|
+
table.name.start_with?("active_storage_", "action_text_", "action_mailbox_") ||
|
|
28
|
+
gem_owned_table?(table.name)
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def dead(table)
|
|
@@ -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
|
|
@@ -33,9 +33,38 @@ module SchemaReaper
|
|
|
33
33
|
"ahoy_matey" => {
|
|
34
34
|
"ahoy_visits" => %w[visit_token visitor_token],
|
|
35
35
|
"ahoy_events" => %w[visit_id name properties]
|
|
36
|
+
},
|
|
37
|
+
"activeadmin" => {
|
|
38
|
+
"active_admin_comments" => %w[namespace body resource_type resource_id author_type author_id]
|
|
39
|
+
},
|
|
40
|
+
"devise-api" => {
|
|
41
|
+
"devise_api_tokens" => %w[
|
|
42
|
+
resource_owner_type resource_owner_id access_token refresh_token
|
|
43
|
+
expires_in revoked_at previous_refresh_token
|
|
44
|
+
]
|
|
36
45
|
}
|
|
37
46
|
}.freeze
|
|
38
47
|
|
|
48
|
+
# Table names an installed gem creates and owns outright -- as opposed to
|
|
49
|
+
# columns a gem adds onto a table the app itself defines. DeadTable
|
|
50
|
+
# consults this: the static scanner only reads app code, so a table a
|
|
51
|
+
# gem's own internal classes reference (ActiveAdmin::Comment,
|
|
52
|
+
# Devise::Api::Token, FriendlyId::Slug, ...) looks unreferenced and gets
|
|
53
|
+
# suggested for `drop_table`, which for something like devise_api_tokens
|
|
54
|
+
# -- mobile session storage -- is actively destructive.
|
|
55
|
+
#
|
|
56
|
+
# Only exact table_glob entries qualify: a "*" entry means "this gem adds
|
|
57
|
+
# these columns to whatever table has them", not "this gem owns this
|
|
58
|
+
# table", so it says nothing about whole-table ownership.
|
|
59
|
+
def self.owned_tables(installed:)
|
|
60
|
+
installed = installed.to_set
|
|
61
|
+
MAP.each_with_object(Set.new) do |(gem_name, table_map), owned|
|
|
62
|
+
next unless installed.include?(gem_name)
|
|
63
|
+
|
|
64
|
+
table_map.each_key { |glob| owned << glob unless glob == "*" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
39
68
|
# @param installed [Enumerable<String>] gem names present in the bundle
|
|
40
69
|
# @param tables [Enumerable<#name,#column_names>] or Enumerable<String>
|
|
41
70
|
# @return [Hash{String => Set<String>}]
|
|
@@ -16,6 +16,12 @@ module SchemaReaper
|
|
|
16
16
|
"database_url: in .schema_reaper.yml; the DATABASE_URL env var; " \
|
|
17
17
|
"config/database.yml for RAILS_ENV (default: development, Postgres only)."
|
|
18
18
|
|
|
19
|
+
# A record separator that cannot appear inside a plain identifier and is
|
|
20
|
+
# exceedingly unlikely inside an expression, so splitting the aggregated
|
|
21
|
+
# column list back apart in Ruby cannot be fooled by a comma the
|
|
22
|
+
# expression itself contains: COALESCE(a, b), ROUND(x, 2), etc.
|
|
23
|
+
COLUMN_SEPARATOR = 31.chr
|
|
24
|
+
|
|
19
25
|
def initialize(url)
|
|
20
26
|
require "pg" # load first so the PG::Error rescue below can resolve
|
|
21
27
|
raise Error, NO_URL if url.nil? || url.empty?
|
|
@@ -94,26 +100,31 @@ module SchemaReaper
|
|
|
94
100
|
end
|
|
95
101
|
end
|
|
96
102
|
|
|
97
|
-
# Columns
|
|
98
|
-
#
|
|
99
|
-
#
|
|
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.
|
|
100
109
|
def indexes_for(table)
|
|
101
110
|
exec(<<~SQL, [table]).map do |r|
|
|
102
111
|
SELECT i.relname AS name, ix.indisunique AS "unique", ix.indisprimary AS "primary",
|
|
103
|
-
s.idx_scan AS scans,
|
|
104
|
-
array_to_string(
|
|
112
|
+
(ix.indpred IS NOT NULL) AS partial, s.idx_scan AS scans,
|
|
113
|
+
array_to_string(
|
|
114
|
+
array_agg(pg_get_indexdef(ix.indexrelid, k.ord::int, true) ORDER BY k.ord),
|
|
115
|
+
'#{COLUMN_SEPARATOR}'
|
|
116
|
+
) AS cols
|
|
105
117
|
FROM pg_class t
|
|
106
118
|
JOIN pg_index ix ON t.oid = ix.indrelid
|
|
107
119
|
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
108
|
-
JOIN LATERAL
|
|
109
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
120
|
+
JOIN LATERAL generate_series(1, ix.indnkeyatts) AS k(ord) ON TRUE
|
|
110
121
|
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
|
|
111
122
|
WHERE t.relname = $1
|
|
112
|
-
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
|
|
113
124
|
SQL
|
|
114
125
|
Index.new(
|
|
115
|
-
name: r["name"], columns: r["cols"].split(
|
|
116
|
-
unique: r["unique"] == "t", primary: r["primary"] == "t",
|
|
126
|
+
name: r["name"], columns: r["cols"].split(COLUMN_SEPARATOR),
|
|
127
|
+
unique: r["unique"] == "t", primary: r["primary"] == "t", partial: r["partial"] == "t",
|
|
117
128
|
scans: r["scans"]&.to_i
|
|
118
129
|
)
|
|
119
130
|
end
|
data/lib/schema_reaper/runner.rb
CHANGED
|
@@ -23,6 +23,7 @@ module SchemaReaper
|
|
|
23
23
|
used_tokens: Static::Scanner.new(@config, root: @root).call,
|
|
24
24
|
runtime: runtime_report,
|
|
25
25
|
gem_columns: gem_columns(db),
|
|
26
|
+
gem_owned_tables: gem_owned_tables,
|
|
26
27
|
config: @config
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -89,6 +90,12 @@ module SchemaReaper
|
|
|
89
90
|
)
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
def gem_owned_tables
|
|
94
|
+
return Set.new unless @config.gem_awareness?
|
|
95
|
+
|
|
96
|
+
GemAwareness.owned_tables(installed: GemAwareness.installed_gems)
|
|
97
|
+
end
|
|
98
|
+
|
|
92
99
|
def load_plugins
|
|
93
100
|
@config.require_paths.each do |path|
|
|
94
101
|
require(File.expand_path(path, @root))
|
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,16 @@ module SchemaReaper
|
|
|
12
12
|
RUBY_GLOB = "**/*.rb"
|
|
13
13
|
WORD_RE = /[a-z_][a-z0-9_]*/i.freeze
|
|
14
14
|
|
|
15
|
+
# Macros that generate a real column from a differently-named virtual
|
|
16
|
+
# attribute, so the literal column name never appears in application
|
|
17
|
+
# code. Missing this made dead_column flag has_secure_password's
|
|
18
|
+
# password_digest, and attr_encrypted/Lockbox/KMS ciphertext columns,
|
|
19
|
+
# as unused even though they're the live backing store.
|
|
20
|
+
DIGEST_MACROS = %w[has_secure_password].freeze
|
|
21
|
+
DIGEST_SUFFIXES = %w[_digest].freeze
|
|
22
|
+
ENCRYPTED_MACROS = %w[encrypts attr_encrypted lockbox_encrypts].freeze
|
|
23
|
+
ENCRYPTED_SUFFIXES = %w[_ciphertext _iv _tag _encrypted].freeze
|
|
24
|
+
|
|
15
25
|
# Node classes whose #name (or #unescaped) is a bare identifier we treat
|
|
16
26
|
# as a possible column/table reference.
|
|
17
27
|
NAME_NODES = [
|
|
@@ -61,6 +71,7 @@ module SchemaReaper
|
|
|
61
71
|
return unless node.is_a?(Prism::Node)
|
|
62
72
|
|
|
63
73
|
out.merge(tokens_for(node))
|
|
74
|
+
out.merge(macro_derived_tokens(node)) if node.is_a?(Prism::CallNode)
|
|
64
75
|
node.compact_child_nodes.each { |c| collect_from_node(c, out) }
|
|
65
76
|
end
|
|
66
77
|
|
|
@@ -78,6 +89,34 @@ module SchemaReaper
|
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
|
|
92
|
+
# `has_secure_password` / `encrypts :field` / `attr_encrypted :field`
|
|
93
|
+
# never write their generated column name (password_digest,
|
|
94
|
+
# field_ciphertext, ...) anywhere in source -- only the virtual
|
|
95
|
+
# attribute name. Derive the column names a macro call implies so they
|
|
96
|
+
# count as "used" instead of looking dead.
|
|
97
|
+
def macro_derived_tokens(node)
|
|
98
|
+
call_name = node.name&.to_s
|
|
99
|
+
return [] unless call_name
|
|
100
|
+
|
|
101
|
+
if DIGEST_MACROS.include?(call_name)
|
|
102
|
+
attrs = macro_symbol_args(node)
|
|
103
|
+
attrs = ["password"] if attrs.empty?
|
|
104
|
+
attrs.flat_map { |a| DIGEST_SUFFIXES.map { |s| "#{a}#{s}" } }
|
|
105
|
+
elsif ENCRYPTED_MACROS.include?(call_name)
|
|
106
|
+
macro_symbol_args(node).flat_map { |a| ENCRYPTED_SUFFIXES.map { |s| "#{a}#{s}" } }
|
|
107
|
+
else
|
|
108
|
+
[]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Leading bare symbol arguments of a call, e.g. `encrypts :a, :b, purpose: :x`
|
|
113
|
+
# => ["a", "b"]. Stops at the first non-symbol (keyword args, etc).
|
|
114
|
+
def macro_symbol_args(node)
|
|
115
|
+
args = node.arguments&.arguments || []
|
|
116
|
+
args.take_while { |a| a.is_a?(Prism::SymbolNode) }
|
|
117
|
+
.map { |a| a.unescaped.to_s.downcase }
|
|
118
|
+
end
|
|
119
|
+
|
|
81
120
|
def text_tokens(path)
|
|
82
121
|
File.read(path).scan(WORD_RE).to_set(&:downcase)
|
|
83
122
|
rescue StandardError
|
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.14
|
|
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
|
|
@@ -156,6 +157,7 @@ metadata:
|
|
|
156
157
|
wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
|
|
157
158
|
funding_uri: https://github.com/sponsors/aksshatt
|
|
158
159
|
rubygems_mfa_required: 'true'
|
|
160
|
+
post_install_message:
|
|
159
161
|
rdoc_options: []
|
|
160
162
|
require_paths:
|
|
161
163
|
- lib
|
|
@@ -170,7 +172,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
170
172
|
- !ruby/object:Gem::Version
|
|
171
173
|
version: '0'
|
|
172
174
|
requirements: []
|
|
173
|
-
rubygems_version: 3.
|
|
175
|
+
rubygems_version: 3.4.10
|
|
176
|
+
signing_key:
|
|
174
177
|
specification_version: 4
|
|
175
178
|
summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
|
|
176
179
|
test_files: []
|