schema_reaper 1.0.10 → 1.0.11

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: f8051cffdf927a39467d51f207093a09be0cc89d5641da5f28f1fc3768b47544
4
- data.tar.gz: d79042e789a967b678afdd6808131c16577706fb2726da94cc3b6e95eea5c945
3
+ metadata.gz: 5039780ddae77f5851cbc16afb53bea80df2eb7328253e18b89c2495feb66218
4
+ data.tar.gz: e88e86f0cb4d723256505d9a4d7e89010c7cc532df86b57a1190713d71339a90
5
5
  SHA512:
6
- metadata.gz: a8526fa89f8f9c449ea3638c196c3e9c042a68f27b547b3a471166942e636154205f506ff19504cb45b8aa55070f6d176e5fb0cab1e772ae2202f8d6e6454a7c
7
- data.tar.gz: fb9d147aacb72e4c44a25ab57388005d7d17e7779430ade83e098f6c2ddf4878dafd441b3d211341fd71382ebaafa46a0ef26698cdb0f604d8e6d157ea914f95
6
+ metadata.gz: c4b095e2cc73c98f8415136810c3c00218b741cce186d3ff1d2d4e0063cd1658963aa60d296595bc4a2adc8dffaee982be49b750a8f7280993cc4e68a85fa4e0
7
+ data.tar.gz: d7437c7dbeb2e0c96f4671c89b0e07e89e0e327bdd741cf6b6ee9c128eedf8c2bf96f58c3d925efe742b94de4632c6204ff363e5a23ddc1d6130aa38eeace44c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.11] - 2026-09-15
4
+
5
+ Everything below landed after 1.0.10 was cut, so 1.0.10 on RubyGems contains
6
+ none of it.
7
+
8
+ ### Fixed
9
+ - **Composite index columns were read in table order, not index-key order.**
10
+ `indexes_for` aggregated with `ORDER BY a.attnum` — a column's position in
11
+ the *table* — so `(company_id, email)` came back as `email,company_id`.
12
+ Since `Index#covers?` is a prefix test, this broke `duplicate_index` in both
13
+ directions: it recommended `remove_index` on indexes that are not redundant
14
+ (dropping one would degrade queries on its leading column), and missed
15
+ genuinely redundant ones. `missing_fk_index` read the same corrupted order
16
+ through `indexed?`. Now joins `unnest(indkey) WITH ORDINALITY`. (#1, mitkush)
17
+ - **`primary_key_for` returned one arbitrary column of a composite key.** It
18
+ matched `attnum = ANY(indkey)` with no `ORDER BY` and took `.first`, so the
19
+ other key columns looked like ordinary columns to the analyzers and could be
20
+ reported as `dead_column` or `single_value_column` — suggesting you drop part
21
+ of a primary key. Now returns every key column in order; `Table#primary_key?`
22
+ accepts a name or a list. (#1, mitkush)
23
+ - **Polymorphic associations were reported as unindexed foreign keys.** A
24
+ `*_id` paired with a `*_type` is only ever queried with the type, so the
25
+ index that matters is the composite `(type, id)` — but `indexed?` only asked
26
+ whether the column led *some* index. Rails apps were told to add an index the
27
+ planner would never use. (#1, mitkush)
28
+ - **`dead_table` missed models behind `-ies` tables and route helpers.**
29
+ `referenced?` singularised with `sub(/s\z/, "")`, turning `crm_activities`
30
+ into `crm_activitie`, so `CrmActivity` never matched. Because
31
+ `drop_findings_on_dead_tables` discards every column- and index-level finding
32
+ for a table called dead, one bad `dead_table` silently suppressed everything
33
+ else about that table. Inflection now handles `-ies`/`-sses`/`-xes`, and a
34
+ table name is also matched inside longer identifiers such as
35
+ `admin_crm_activities_path`. (#1, mitkush)
36
+ - **Ruby 2.7 was broken despite being the declared floor.** `Config.load`
37
+ called `YAML.safe_load_file`, which arrived in Psych 3.3 (Ruby 3.0), so every
38
+ command crashed for any 2.7 user with a `.schema_reaper.yml`.
39
+ `Console#title` appended to an interpolated string, which is frozen on
40
+ Ruby <= 2.7. (#2, mitkush)
41
+ - **`config/database.yml` credentials were not URL-escaped.** A password
42
+ containing `@` made libpq read the rest as the host, reporting
43
+ `could not translate host name "ss@localhost"` — a hostname the user never
44
+ configured. Also, when ERB failed to render (commonly
45
+ `Rails.application.credentials` outside a booted Rails), the resolver fell
46
+ back to parsing the file *un-rendered* and built a connection URL out of
47
+ template text; it now resolves nothing so the real error surfaces. (#1,
48
+ mitkush)
49
+ - **An unknown reclaim estimate was printed as `0 B`.** A byte estimate needs a
50
+ row count, and `reltuples = -1` means unknown; `Base#finding` turned that into
51
+ zero, so reports opened with `~0.0 B reclaimable` — "nothing to gain" rather
52
+ than "cannot say". (#4, mitkush)
53
+
54
+ ### Changed
55
+ - **`unused_index` is skipped when the database has no query history.**
56
+ `idx_scan = 0` means either "never used" or "this database has answered no
57
+ queries". On a freshly loaded schema the analyzer reported every non-unique
58
+ index — 197 of 266 findings on one app. It now compares cluster-wide
59
+ `idx_scan` against the index count and explains the skip on stderr. (#2,
60
+ mitkush)
61
+ - **`missing_fk_index` confidence is tiered by evidence.** A declared FK
62
+ constraint scores 0.9; a `*_id` name with a table it plausibly references
63
+ scores 0.7; a `*_id` name with nothing to reference scores 0.5 and drops to
64
+ `:low`. Previously `voter_id` and `upi_id` were reported as confidently as a
65
+ real constraint, which made `--min-confidence` useless for this analyzer.
66
+ Nothing is dropped. (#3, mitkush)
67
+ - **The report says what it contains.** The header now carries the finding
68
+ count, how many tables are affected, and the type breakdown — previously only
69
+ a tally in the footer, 245 lines below on a large report. (#4, mitkush)
70
+ - **Findings that differ only in the names they mention are rolled up.** 37
71
+ missing foreign-key indexes spent 111 lines repeating one sentence. They now
72
+ collapse into a single entry that keeps the confidence bar, states the fix as
73
+ a template, and lists every target. Nothing is summarised away, and findings
74
+ whose evidence genuinely differs — every `duplicate_index` names a different
75
+ index pair — stay itemised. 246 lines to 124 on one app. (#5, mitkush)
76
+
3
77
  ## [1.0.10] - 2026-09-09
4
78
 
5
79
  Supersedes 1.0.9, which was published to RubyGems from an incomplete cut and
data/README.md CHANGED
@@ -47,10 +47,13 @@ bundle exec schema_reaper trend # snapshot + progress delta
47
47
  bundle exec schema_reaper generate-migration users legacy_api_token
48
48
  ```
49
49
 
50
- The `scan` report groups findings by table and sorts by confidence:
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:
51
52
 
52
53
  ```
53
- schema_reaper 5 findings ~93.8 KB reclaimable
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
54
57
 
55
58
  users
56
59
  █████ 90% medium missing_fk_index team_id
@@ -58,12 +58,15 @@ module SchemaReaper
58
58
  end
59
59
 
60
60
  # Builds a Finding, filling in reclaimable_bytes from the row count.
61
+ # An unknown row count leaves reclaimable_bytes nil: "we cannot say" is
62
+ # not the same claim as "zero bytes", and reporters need to tell them
63
+ # apart. Finding#reclaimable_bytes still reads 0 for arithmetic.
61
64
  def finding(table:, bytes_per_row: 0, row_count: nil, **rest)
62
- rows = row_count || schema.table(table)&.row_count || 0
65
+ rows = row_count || schema.table(table)&.row_count
63
66
  Finding.new(
64
67
  table: table,
65
68
  bytes_per_row: bytes_per_row,
66
- reclaimable_bytes: bytes_per_row * rows,
69
+ reclaimable_bytes: rows && (bytes_per_row * rows),
67
70
  **rest
68
71
  )
69
72
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module SchemaReaper
4
6
  module Analyzers
5
7
  # A foreign-key column with no index: every parent delete/update scans the
@@ -12,6 +14,15 @@ module SchemaReaper
12
14
  class MissingFkIndex < Base
13
15
  Registry.register(self)
14
16
 
17
+ # How sure we are that the column really is a foreign key:
18
+ # a declared constraint is proof;
19
+ # a *_id name plus a table it plausibly points at is good evidence;
20
+ # a *_id name alone is a guess -- voter_id and upi_id are not foreign
21
+ # keys, they just end in _id.
22
+ CONSTRAINT_CONFIDENCE = 0.9
23
+ REFERENT_CONFIDENCE = 0.7
24
+ NAME_ONLY_CONFIDENCE = 0.5
25
+
15
26
  def call
16
27
  schema.tables.reject { |t| config.ignore_tables.include?(t.name) }
17
28
  .flat_map { |t| missing_in(t) }
@@ -27,7 +38,7 @@ module SchemaReaper
27
38
  type_col = polymorphic_type_for(table, col)
28
39
  next if type_col && polymorphic_indexed?(table, type_col, col)
29
40
 
30
- finding_for(table, col, type_col)
41
+ finding_for(table, col, type_col, declared: table.foreign_keys.include?(col))
31
42
  end
32
43
  end
33
44
 
@@ -40,23 +51,45 @@ module SchemaReaper
40
51
  table.column(col)&.always_null? || false
41
52
  end
42
53
 
43
- def finding_for(table, col, type_col)
54
+ def finding_for(table, col, type_col, declared:)
55
+ referent = declared ? nil : referent_table(col)
56
+ confidence = confidence_for(declared, referent)
57
+
44
58
  finding(
45
59
  type: :missing_fk_index,
46
60
  table: table.name,
47
61
  column: col,
48
- severity: :medium,
49
- confidence: 0.9,
62
+ severity: confidence >= REFERENT_CONFIDENCE ? :medium : :low,
63
+ confidence: confidence,
50
64
  bytes_per_row: 0,
51
- evidence: [evidence_for(col, type_col)],
65
+ evidence: [evidence_for(col, type_col, declared, referent)],
52
66
  suggested_fix: fix_for(table, col, type_col)
53
67
  )
54
68
  end
55
69
 
56
- def evidence_for(col, type_col)
57
- return "#{col} is a foreign key with no covering index" unless type_col
70
+ def confidence_for(declared, referent)
71
+ return CONSTRAINT_CONFIDENCE if declared
72
+ return REFERENT_CONFIDENCE if referent
73
+
74
+ NAME_ONLY_CONFIDENCE
75
+ end
76
+
77
+ def evidence_for(col, type_col, declared, referent)
78
+ subject =
79
+ if type_col
80
+ "#{col} is a polymorphic association with #{type_col} and has no (#{type_col}, #{col}) index"
81
+ else
82
+ "#{col} has no covering index"
83
+ end
84
+
85
+ "#{subject} (#{provenance(declared, referent)})"
86
+ end
87
+
88
+ def provenance(declared, referent)
89
+ return "declared foreign key constraint" if declared
90
+ return "no FK constraint; named like a reference to `#{referent}`" if referent
58
91
 
59
- "#{col} is a polymorphic association with #{type_col} and has no (#{type_col}, #{col}) index"
92
+ "no FK constraint and no table it plausibly references -- matched on the _id suffix alone"
60
93
  end
61
94
 
62
95
  def fix_for(table, col, type_col)
@@ -69,6 +102,24 @@ module SchemaReaper
69
102
  (table.foreign_keys + table.column_names.grep(/_id\z/)).uniq
70
103
  end
71
104
 
105
+ # A table the column plausibly points at: account_id -> accounts,
106
+ # company_id -> companies, address_id -> addresses. Absence is the signal
107
+ # that a *_id name is not really a foreign key.
108
+ def referent_table(col)
109
+ return nil unless col.end_with?("_id")
110
+
111
+ base = col[0..-4]
112
+ return nil if base.empty?
113
+
114
+ candidates = [base, "#{base}s", "#{base}es"]
115
+ candidates << "#{base[0..-2]}ies" if base.end_with?("y")
116
+ candidates.find { |name| table_names.include?(name) }
117
+ end
118
+
119
+ def table_names
120
+ @table_names ||= schema.tables.to_set(&:name)
121
+ end
122
+
72
123
  def indexed?(table, col)
73
124
  table.indexes.any? { |ix| ix.columns.first == col }
74
125
  end
@@ -28,10 +28,19 @@ module SchemaReaper
28
28
  }.freeze
29
29
 
30
30
  def self.load(path = ".schema_reaper.yml")
31
- raw = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
31
+ raw = File.exist?(path) ? (safe_load_file(path) || {}) : {}
32
32
  new(deep_merge(DEFAULTS, raw), root: File.dirname(File.expand_path(path)))
33
33
  end
34
34
 
35
+ # Psych.safe_load_file arrived in Psych 3.3 (Ruby 3.0), but the gem supports
36
+ # Ruby 2.7. Reading the file ourselves is equivalent -- safe_load_file
37
+ # defaults to aliases: false, which is what safe_load does too.
38
+ def self.safe_load_file(path)
39
+ return YAML.safe_load_file(path) if YAML.respond_to?(:safe_load_file)
40
+
41
+ YAML.safe_load(File.read(path))
42
+ end
43
+
35
44
  def self.deep_merge(base, override)
36
45
  base.merge(override) do |_key, a, b|
37
46
  a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b
@@ -40,7 +40,11 @@ module SchemaReaper
40
40
  YAML.safe_load(rendered, [], [], true)
41
41
  end
42
42
  rescue StandardError
43
- YAML.load_file(file)
43
+ # ERB that reaches into Rails -- Rails.application.credentials is the
44
+ # common one -- cannot render outside a booted Rails process. Resolving
45
+ # nothing yields a clear "no database_url configured"; parsing the file
46
+ # un-rendered would build a connection URL out of template text.
47
+ nil
44
48
  end
45
49
 
46
50
  # Rails 6+ allows `development: { primary: {...}, replica: {...} }`. Pick the
@@ -67,12 +71,19 @@ module SchemaReaper
67
71
  db = section["database"]
68
72
  return nil if db.to_s.empty?
69
73
 
70
- userinfo = [section["username"], section["password"]].compact.join(":")
74
+ userinfo = [section["username"], section["password"]].compact.map { |v| escape(v) }.join(":")
71
75
  host = section["host"].to_s
72
76
  hostport = host.empty? ? "" : "#{host}#{":#{section["port"]}" if section["port"]}"
73
77
  auth = userinfo.empty? ? "" : "#{userinfo}@"
74
78
 
75
79
  "postgresql://#{auth}#{hostport}/#{db}"
76
80
  end
81
+
82
+ # Passwords routinely contain characters that are structural in a URL.
83
+ # An unescaped "@" makes libpq read the rest as the host, so it reports
84
+ # a bogus hostname rather than a credential problem.
85
+ def escape(value)
86
+ ERB::Util.url_encode(value.to_s)
87
+ end
77
88
  end
78
89
  end
@@ -36,6 +36,12 @@ module SchemaReaper
36
36
  self[:reclaimable_bytes] || 0
37
37
  end
38
38
 
39
+ # False when the row count was unavailable, so the byte estimate is
40
+ # unknown rather than zero.
41
+ def reclaim_known?
42
+ !self[:reclaimable_bytes].nil?
43
+ end
44
+
39
45
  # Column and/or index this finding points at, without the table name
40
46
  # (callers that group by table already show it). nil for a whole-table
41
47
  # finding.
@@ -21,7 +21,9 @@ module SchemaReaper
21
21
 
22
22
  # " schema_reaper trend · 3 snapshots"
23
23
  def title(command, meta = nil)
24
- line = " #{@a.paint("schema_reaper", :bold)} #{@a.paint(command, :bold)}"
24
+ # +"" : on Ruby <= 2.7 an interpolated string whose parts are all frozen
25
+ # literals is itself frozen, so the append below raises FrozenError.
26
+ line = +" #{@a.paint("schema_reaper", :bold)} #{@a.paint(command, :bold)}"
25
27
  line << " #{@a.paint("· #{meta}", :dim)}" if meta
26
28
  @out.puts
27
29
  @out.puts line
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Reporters
5
+ # Groups findings that say the same thing about different targets.
6
+ #
7
+ # A report of 37 missing foreign-key indexes repeats one sentence 37 times,
8
+ # varying only the table and column. Collapsing those into a single entry
9
+ # with its targets listed keeps every fact and drops the repetition.
10
+ module Rollup
11
+ # Findings that differ only in which table and column they name carry no
12
+ # new information per line. At or above this many, roll them up.
13
+ THRESHOLD = 4
14
+
15
+ module_function
16
+
17
+ # @return [Array(Array<[key, Array<Finding>]>, Array<Finding>)]
18
+ # the groups worth rolling up (largest first), and the findings to
19
+ # spell out individually
20
+ def partition(findings)
21
+ by_shape = findings.group_by { |f| key_for(f) }
22
+ big, small = by_shape.partition { |_key, group| group.size >= THRESHOLD }
23
+ [big.sort_by { |_key, group| -group.size }, small.flat_map { |_key, group| group }]
24
+ end
25
+
26
+ def key_for(finding)
27
+ [finding.type, shape(evidence_line(finding), finding), shape(finding.suggested_fix, finding)]
28
+ end
29
+
30
+ def evidence_line(finding)
31
+ finding.evidence.join(" · ")
32
+ end
33
+
34
+ # Masks the names a finding is about, each with its own placeholder, so
35
+ # two findings saying the same thing about different targets compare
36
+ # equal -- and so the rolled-up fix still reads as a template.
37
+ #
38
+ # The lookarounds matter: without them, masking
39
+ # index_users_on_team_id would also eat it out of
40
+ # index_users_on_team_id_and_state, making two findings about different
41
+ # covering indexes look identical.
42
+ def shape(text, finding)
43
+ replacements(finding).reduce(text) do |acc, (name, token)|
44
+ acc.gsub(/(?<!\w)#{Regexp.escape(name)}(?!\w)/, token)
45
+ end
46
+ end
47
+
48
+ def replacements(finding)
49
+ [[finding.table, "<table>"], [finding.column, "<column>"], [finding.index, "<index>"]]
50
+ .reject { |name, _| name.nil? || name.empty? }
51
+ .sort_by { |name, _| -name.length }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,15 +2,20 @@
2
2
 
3
3
  require_relative "bytes"
4
4
  require_relative "ansi"
5
+ require_relative "rollup"
5
6
 
6
7
  module SchemaReaper
7
8
  module Reporters
8
- # Human-readable terminal report: a summary line, findings grouped by table
9
- # and sorted by confidence, then a severity/type tally. Colour is used only
10
- # on an interactive terminal (see Ansi).
9
+ # Human-readable terminal report: a summary line, then findings. Findings
10
+ # that say the same thing about different tables are rolled up into one
11
+ # entry; the rest are grouped by table and sorted by confidence. Colour is
12
+ # used only on an interactive terminal (see Ansi).
11
13
  class Table
12
14
  SEV_ORDER = { high: 0, medium: 1, low: 2 }.freeze
13
15
 
16
+ # Width to wrap the target list of a rolled-up entry.
17
+ TARGET_LINE_WIDTH = 92
18
+
14
19
  def initialize(findings, io: $stdout, color: nil)
15
20
  @findings = findings
16
21
  @io = io
@@ -21,7 +26,8 @@ module SchemaReaper
21
26
  return render_clean if @findings.empty?
22
27
 
23
28
  header
24
- grouped.each { |table, group| render_table(table, group) }
29
+ rolled_up.each { |key, group| render_rollup(key, group) }
30
+ grouped(itemised).each { |table, group| render_table(table, group) }
25
31
  footer
26
32
  end
27
33
 
@@ -33,14 +39,81 @@ module SchemaReaper
33
39
 
34
40
  def header
35
41
  @io.puts
36
- @io.puts " #{@a.paint("schema_reaper", :bold)} " \
37
- "#{@a.paint("#{@findings.size} finding#{"s" unless @findings.size == 1}", :bold)} " \
38
- "#{@a.paint("~#{Bytes.human(total_reclaimable)} reclaimable", :dim)}"
42
+ @io.puts " #{@a.paint("schema_reaper", :bold)} #{@a.paint(scope_text, :bold)}"
43
+ @io.puts " #{@a.paint(type_breakdown, :dim)}"
44
+ @io.puts " #{@a.paint(reclaim_text, :dim)}" if reclaim_text
45
+ @io.puts
46
+ end
47
+
48
+ def scope_text
49
+ tables = @findings.map(&:table).uniq.size
50
+ "#{@findings.size} finding#{"s" unless @findings.size == 1} across " \
51
+ "#{tables} table#{"s" unless tables == 1}"
52
+ end
53
+
54
+ def type_breakdown
55
+ @findings.group_by(&:type).transform_values(&:size)
56
+ .sort_by { |_t, n| -n }
57
+ .map { |t, n| "#{t} #{n}" }.join(" · ")
58
+ end
59
+
60
+ # A reclaim estimate needs a row count, and pg reports reltuples = -1 for
61
+ # a table it has never analysed. Printing 0.0 B for an unknown reads as
62
+ # "nothing to gain here", which is a different claim entirely.
63
+ def reclaim_text
64
+ return "~#{Bytes.human(total_reclaimable)} reclaimable" if total_reclaimable.positive?
65
+ return nil unless unmeasured?
66
+
67
+ "reclaim estimate unavailable — run ANALYZE to populate table statistics"
68
+ end
69
+
70
+ def unmeasured?
71
+ @findings.any? { |f| f.bytes_per_row.to_i.positive? && !f.reclaim_known? }
72
+ end
73
+
74
+ def rolled_up
75
+ partitioned.first
76
+ end
77
+
78
+ def itemised
79
+ partitioned.last
80
+ end
81
+
82
+ def partitioned
83
+ @partitioned ||= Rollup.partition(@findings)
84
+ end
85
+
86
+ def render_rollup(key, group)
87
+ type, evidence, fix = key
88
+ first = group.first
89
+ @io.puts rollup_head(type, first, group.size)
90
+ @io.puts " #{@a.paint(evidence, :dim)}"
91
+ @io.puts " #{@a.paint("→ #{fix}", :green)}"
92
+ target_lines(group).each { |line| @io.puts " #{@a.paint(line, :dim)}" }
39
93
  @io.puts
40
94
  end
41
95
 
42
- def grouped
43
- @findings
96
+ def rollup_head(type, first, count)
97
+ pct = (first.confidence * 100).round
98
+ bar = @a.confidence_bar(first.confidence, first.severity)
99
+ sev = @a.severity(first.severity, format("%-6s", first.severity))
100
+ format(" %s %3d%% %s %s %s", bar, pct, sev,
101
+ @a.paint(format("%-19s", type), :bold),
102
+ @a.paint("#{count} targets", :bold, :magenta))
103
+ end
104
+
105
+ # "users.team_id · orders.buyer_id · ..." wrapped to a readable width.
106
+ def target_lines(group)
107
+ labels = group.map { |f| [f.table, f.target_label].compact.join(".") }.sort
108
+ labels.each_with_object([+""]) do |label, lines|
109
+ lines << +"" if !lines.last.empty? && lines.last.length + label.length + 3 > TARGET_LINE_WIDTH
110
+ lines.last << " · " unless lines.last.empty?
111
+ lines.last << label
112
+ end
113
+ end
114
+
115
+ def grouped(findings)
116
+ findings
44
117
  .sort_by { |f| [-f.confidence, SEV_ORDER.fetch(f.severity, 9)] }
45
118
  .group_by(&:table)
46
119
  end
@@ -89,12 +162,8 @@ module SchemaReaper
89
162
  @a.severity(sev, "#{sev} #{n}") if n
90
163
  end.join(" ")
91
164
 
92
- by_type = @findings.group_by(&:type).transform_values(&:size)
93
- .sort_by { |_t, n| -n }
94
- .map { |t, n| "#{t} #{n}" }.join(" · ")
95
-
96
165
  @io.puts " #{tally}"
97
- @io.puts " #{@a.paint(by_type, :dim)}"
166
+ @io.puts " #{@a.paint(type_breakdown, :dim)}"
98
167
  end
99
168
 
100
169
  def total_reclaimable
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaReaper
4
- VERSION = "1.0.10"
4
+ VERSION = "1.0.11"
5
5
  end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_reaper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.10
4
+ version: 1.0.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - aksshatt
8
8
  - mitkush
9
- autorequire:
10
9
  bindir: exe
11
10
  cert_chain: []
12
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: prism
@@ -131,6 +130,7 @@ files:
131
130
  - lib/schema_reaper/reporters/console.rb
132
131
  - lib/schema_reaper/reporters/json.rb
133
132
  - lib/schema_reaper/reporters/markdown.rb
133
+ - lib/schema_reaper/reporters/rollup.rb
134
134
  - lib/schema_reaper/reporters/sarif.rb
135
135
  - lib/schema_reaper/reporters/table.rb
136
136
  - lib/schema_reaper/reporters/trend.rb
@@ -155,7 +155,6 @@ metadata:
155
155
  wiki_uri: https://github.com/aksshatt/schema_reaper/wiki
156
156
  funding_uri: https://github.com/sponsors/aksshatt
157
157
  rubygems_mfa_required: 'true'
158
- post_install_message:
159
158
  rdoc_options: []
160
159
  require_paths:
161
160
  - lib
@@ -170,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
169
  - !ruby/object:Gem::Version
171
170
  version: '0'
172
171
  requirements: []
173
- rubygems_version: 3.4.10
174
- signing_key:
172
+ rubygems_version: 3.6.9
175
173
  specification_version: 4
176
174
  summary: Find and safely remove schema dead-weight in Rails + PostgreSQL apps.
177
175
  test_files: []