schema_reaper 2.0.1 → 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 (43) 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 +102 -0
  5. data/README.md +64 -11
  6. data/RELEASE_CHECKLIST.md +13 -2
  7. data/lib/generators/schema_reaper/install/templates/initializer.rb.tt +8 -0
  8. data/lib/schema_reaper/alert_config.rb +21 -1
  9. data/lib/schema_reaper/alert_state.rb +35 -0
  10. data/lib/schema_reaper/analyzers/always_null_column.rb +12 -5
  11. data/lib/schema_reaper/analyzers/base.rb +66 -2
  12. data/lib/schema_reaper/analyzers/dead_column.rb +2 -2
  13. data/lib/schema_reaper/analyzers/dead_table.rb +10 -6
  14. data/lib/schema_reaper/analyzers/duplicate_foreign_key.rb +53 -0
  15. data/lib/schema_reaper/analyzers/duplicate_index.rb +1 -1
  16. data/lib/schema_reaper/analyzers/missing_fk_index.rb +10 -4
  17. data/lib/schema_reaper/analyzers/sequence_exhaustion.rb +55 -0
  18. data/lib/schema_reaper/analyzers/single_value_column.rb +17 -5
  19. data/lib/schema_reaper/analyzers/unused_index.rb +11 -6
  20. data/lib/schema_reaper/baseline.rb +95 -10
  21. data/lib/schema_reaper/cli/baseline_support.rb +45 -0
  22. data/lib/schema_reaper/cli.rb +69 -14
  23. data/lib/schema_reaper/config.rb +28 -0
  24. data/lib/schema_reaper/doctor.rb +151 -0
  25. data/lib/schema_reaper/explain.rb +102 -0
  26. data/lib/schema_reaper/gem_awareness.rb +31 -1
  27. data/lib/schema_reaper/inflect.rb +36 -0
  28. data/lib/schema_reaper/introspect/postgres.rb +236 -51
  29. data/lib/schema_reaper/migration_generator.rb +90 -4
  30. data/lib/schema_reaper/notifier.rb +58 -6
  31. data/lib/schema_reaper/railtie.rb +1 -1
  32. data/lib/schema_reaper/reporters/sarif.rb +15 -6
  33. data/lib/schema_reaper/reporters/slack.rb +67 -0
  34. data/lib/schema_reaper/runner.rb +58 -6
  35. data/lib/schema_reaper/runtime.rb +94 -11
  36. data/lib/schema_reaper/schema.rb +32 -3
  37. data/lib/schema_reaper/schema_locator.rb +75 -0
  38. data/lib/schema_reaper/static/cache.rb +66 -0
  39. data/lib/schema_reaper/static/scanner.rb +174 -6
  40. data/lib/schema_reaper/version.rb +1 -1
  41. data/lib/schema_reaper.rb +7 -0
  42. data/schema_reaper.gemspec +0 -1
  43. metadata +13 -4
@@ -8,9 +8,10 @@ module SchemaReaper
8
8
  class Sarif
9
9
  LEVEL = { high: "error", medium: "warning", low: "note" }.freeze
10
10
 
11
- def initialize(findings, io: $stdout)
11
+ def initialize(findings, io: $stdout, locator: SchemaLocator.new)
12
12
  @findings = findings
13
13
  @io = io
14
+ @locator = locator
14
15
  end
15
16
 
16
17
  def render
@@ -52,15 +53,23 @@ module SchemaReaper
52
53
  "confidence" => f.confidence,
53
54
  "reclaimableBytes" => f.reclaimable_bytes
54
55
  },
55
- "locations" => [{
56
- "physicalLocation" => {
57
- "artifactLocation" => { "uri" => "db/schema.rb" }
58
- }
59
- }]
56
+ "locations" => locations_for(f)
60
57
  }
61
58
  end
62
59
  end
63
60
 
61
+ # GitHub code scanning requires a location on every result, so with no
62
+ # schema file in the repo fall back to the conventional path, unlined.
63
+ def locations_for(finding)
64
+ loc = @locator.locate(finding)
65
+ return [{ "physicalLocation" => { "artifactLocation" => { "uri" => "db/schema.rb" } } }] unless loc
66
+
67
+ [{ "physicalLocation" => {
68
+ "artifactLocation" => { "uri" => loc.path },
69
+ "region" => { "startLine" => loc.line }
70
+ } }]
71
+ end
72
+
64
73
  def camelize(str)
65
74
  str.split("_").map(&:capitalize).join
66
75
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ module Reporters
5
+ # Slack Block Kit payload for the scheduled report. Slack renders neither
6
+ # Markdown tables nor messages past ~40k characters, so this is a summary
7
+ # with the most important findings, not the whole report.
8
+ class Slack
9
+ MAX_LISTED = 10
10
+ SEVERITY_ICON = { high: ":red_circle:", medium: ":large_orange_circle:", low: ":white_circle:" }.freeze
11
+
12
+ # changes - {new: [Finding], resolved: [String id]} or nil on a first report
13
+ def initialize(findings, changes: nil)
14
+ @findings = findings
15
+ @changes = changes
16
+ end
17
+
18
+ def payload
19
+ { "text" => summary, "blocks" => blocks }
20
+ end
21
+
22
+ # Also the notification text, and what a client without Block Kit shows.
23
+ def summary
24
+ line = "schema_reaper: #{@findings.size} finding#{"s" unless @findings.size == 1}"
25
+ return line unless @changes
26
+
27
+ "#{line} (#{@changes[:new].size} new, #{@changes[:resolved].size} resolved since the last report)"
28
+ end
29
+
30
+ private
31
+
32
+ def blocks
33
+ out = [{ "type" => "section", "text" => mrkdwn("*#{summary}*") }]
34
+ listed = @changes ? @changes[:new] : @findings
35
+ out << list_block(@changes ? "New" : "Top findings", listed) if listed.any?
36
+ out << resolved_block if @changes && @changes[:resolved].any?
37
+ out << { "type" => "context",
38
+ "elements" => [mrkdwn("Full report: `bundle exec schema_reaper scan`")] }
39
+ out
40
+ end
41
+
42
+ def list_block(title, findings)
43
+ lines = findings.first(MAX_LISTED).map do |f|
44
+ "#{SEVERITY_ICON.fetch(f.severity, "")} `#{f.id}` -- #{f.suggested_fix}"
45
+ end
46
+ lines << "_...and #{findings.size - MAX_LISTED} more_" if findings.size > MAX_LISTED
47
+ { "type" => "section", "text" => mrkdwn(truncate("*#{title}*\n#{lines.join("\n")}")) }
48
+ end
49
+
50
+ def resolved_block
51
+ ids = @changes[:resolved]
52
+ text = "*Resolved*\n#{ids.first(MAX_LISTED).map { |id| "~#{id}~" }.join("\n")}"
53
+ text += "\n_...and #{ids.size - MAX_LISTED} more_" if ids.size > MAX_LISTED
54
+ { "type" => "section", "text" => mrkdwn(truncate(text)) }
55
+ end
56
+
57
+ # A section's text is capped at 3000 characters by Slack.
58
+ def truncate(text)
59
+ text.length > 3000 ? "#{text[0, 2990]}..." : text
60
+ end
61
+
62
+ def mrkdwn(text)
63
+ { "type" => "mrkdwn", "text" => text }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -7,32 +7,84 @@ module SchemaReaper
7
7
  class Runner
8
8
  SEVERITY_RANK = { high: 0, medium: 1, low: 2 }.freeze
9
9
 
10
- def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil)
10
+ # only / except - analyzer type names (strings or symbols) to restrict the
11
+ # run to, or to leave out. Unknown names raise Error.
12
+ # min_confidence - drop findings below this; defaults to the config key.
13
+ def initialize(config: Config.load, root: Dir.pwd, introspector: nil, runtime: nil,
14
+ only: nil, except: nil, min_confidence: nil)
11
15
  @config = config
12
16
  @root = root
13
17
  @introspector = introspector
14
18
  @runtime = runtime
19
+ @only = names(only)
20
+ @except = names(except)
21
+ @min_confidence = min_confidence || config.min_confidence
15
22
  load_plugins
16
23
  end
17
24
 
18
25
  def run
26
+ selected = analyzers
19
27
  db = schema
20
- warn_missing_query_history(db)
28
+ warn_missing_query_history(db) if selected.include?(Analyzers::UnusedIndex)
29
+ scan = Static::Scanner.new(@config, root: @root).scan
21
30
  ctx = Analyzers::Context.new(
22
31
  schema: db,
23
- used_tokens: Static::Scanner.new(@config, root: @root).call,
32
+ used_tokens: scan.tokens,
33
+ scan: scan,
24
34
  runtime: runtime_report,
25
35
  gem_columns: gem_columns(db),
26
36
  gem_owned_tables: gem_owned_tables,
27
37
  config: @config
28
38
  )
29
39
 
30
- findings = Analyzers::Registry.all.flat_map { |klass| klass.new(ctx).call }
31
- dedupe(findings).sort_by { |f| [-f.confidence, f.id] }
40
+ findings = selected.flat_map { |klass| klass.new(ctx).call }
41
+ dedupe(kept_in_code(findings, scan.keeps)).select { |f| f.confidence >= @min_confidence }
42
+ .sort_by do |f|
43
+ [
44
+ -f.confidence, f.id
45
+ ]
46
+ end
47
+ end
48
+
49
+ # Analyzer types this run covers, as strings, or nil when every analyzer
50
+ # runs. Callers that persist results (the baseline) use it to leave the
51
+ # other analyzers' records alone.
52
+ def selected_types
53
+ return nil if @only.nil? && @except.nil?
54
+
55
+ analyzers.map { |k| k.type.to_s }
32
56
  end
33
57
 
34
58
  private
35
59
 
60
+ # `# schema_reaper:keep TARGET` in code silences a finding. TARGET is a
61
+ # column (any table), table.column, a table (every finding on it) or an
62
+ # index name.
63
+ def kept_in_code(findings, keeps)
64
+ return findings if keeps.empty?
65
+
66
+ findings.reject do |f|
67
+ targets = [f.table, f.column, f.index, f.column && "#{f.table}.#{f.column}"].compact.map(&:downcase)
68
+ targets.any? { |t| keeps.key?(t) }
69
+ end
70
+ end
71
+
72
+ def names(list)
73
+ list && Array(list).flat_map { |n| n.to_s.split(",") }.map { |n| n.strip.to_sym }.reject(&:empty?)
74
+ end
75
+
76
+ # Plugins load in #initialize, so the registry is complete by the time
77
+ # the names are checked -- a custom analyzer can be selected like a core one.
78
+ def analyzers
79
+ all = Analyzers::Registry.all
80
+ known = all.map(&:type)
81
+ unknown = ((@only || []) + (@except || [])) - known
82
+ raise Error, "unknown analyzer(s): #{unknown.join(", ")} (known: #{known.join(", ")})" if unknown.any?
83
+
84
+ all.select { |k| @only.nil? || @only.include?(k.type) }
85
+ .reject { |k| @except&.include?(k.type) }
86
+ end
87
+
36
88
  # Silently reporting nothing would look like a clean bill of health, so say
37
89
  # why the unused-index analyzer sat this run out.
38
90
  def warn_missing_query_history(db)
@@ -72,7 +124,7 @@ module SchemaReaper
72
124
  def schema
73
125
  return @introspector.call if @introspector
74
126
 
75
- Introspect::Postgres.new(@config.database_url).call
127
+ Introspect::Postgres.new(@config.database_url, schemas: @config.schemas).call
76
128
  end
77
129
 
78
130
  def runtime_report
@@ -15,19 +15,32 @@ module SchemaReaper
15
15
  new(accessed: Set.new, observed_days: 0)
16
16
  end
17
17
 
18
+ # Reads the rotated generation (path.1) too, so rotation never shortens
19
+ # the observed window. Several processes append to one log, so lines are
20
+ # not in time order -- the span is min..max, not first..last line.
18
21
  def self.load(path)
19
- return empty unless path && File.exist?(path)
22
+ return empty unless path
23
+
24
+ files = [Store.rotated_path(path), path].select { |f| File.exist?(f) }
25
+ return empty if files.empty?
20
26
 
21
27
  seen = Set.new
22
28
  first = last = nil
23
- File.foreach(path) do |line|
24
- row = JSON.parse(line)
25
- seen << row["key"]
26
- ts = row["at"]
27
- first ||= ts
28
- last = ts
29
- rescue JSON::ParserError
30
- next
29
+ files.each do |file|
30
+ File.foreach(file) do |line|
31
+ row = JSON.parse(line)
32
+ seen << row["key"]
33
+ # Store writes UTC ISO8601 ("2026-01-05T00:00:00Z"), which sorts
34
+ # correctly as a string -- far cheaper than parsing every line of
35
+ # a log that can hold millions. Only the extremes are parsed.
36
+ ts = row["at"]
37
+ next unless ts.is_a?(String)
38
+
39
+ first = ts if first.nil? || ts < first
40
+ last = ts if last.nil? || ts > last
41
+ rescue JSON::ParserError
42
+ next
43
+ end
31
44
  end
32
45
  new(accessed: seen, observed_days: day_span(first, last))
33
46
  end
@@ -36,6 +49,14 @@ module SchemaReaper
36
49
  return 0 unless first && last
37
50
 
38
51
  ((Time.parse(last) - Time.parse(first)) / 86_400).ceil
52
+ rescue ArgumentError
53
+ 0
54
+ end
55
+
56
+ # Raw-SQL access recorded by the tracker's SQL subscriber, keyed
57
+ # "table.*": the table was touched, but by no column we can name.
58
+ def table_accessed?(table)
59
+ accessed.any? { |k| k.start_with?("#{table}.") }
39
60
  end
40
61
 
41
62
  def empty?
@@ -52,10 +73,19 @@ module SchemaReaper
52
73
  end
53
74
 
54
75
  # Buffered writer for the usage log. Thread-safe append of JSON lines.
76
+ #
77
+ # Past max_bytes the log is renamed to `<path>.1` (replacing any older
78
+ # generation) and a fresh one started, so disk use stays under roughly
79
+ # 2 x max_bytes. nil disables rotation.
55
80
  class Store
56
- def initialize(path:, flush_every: 200)
81
+ def self.rotated_path(path)
82
+ "#{path}.1"
83
+ end
84
+
85
+ def initialize(path:, flush_every: 200, max_bytes: Config::DEFAULTS["runtime_log_max_bytes"])
57
86
  @path = path
58
87
  @flush_every = flush_every
88
+ @max_bytes = max_bytes
59
89
  @buffer = []
60
90
  @mutex = Mutex.new
61
91
  FileUtils.mkdir_p(File.dirname(path))
@@ -79,6 +109,23 @@ module SchemaReaper
79
109
 
80
110
  File.open(@path, "a") { |f| f.write(@buffer.join) }
81
111
  @buffer.clear
112
+ rotate_if_needed
113
+ end
114
+
115
+ # Several processes (Puma workers) share one log, so the size check and
116
+ # the rename happen under an exclusive flock and the size is checked
117
+ # again once it's held: otherwise two workers that both saw an
118
+ # oversized log would rotate twice, and the second rename would put a
119
+ # nearly empty log over the full generation in <path>.1.
120
+ def rotate_if_needed
121
+ return unless @max_bytes && File.size?(@path).to_i > @max_bytes
122
+
123
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
124
+ lock.flock(File::LOCK_EX)
125
+ File.rename(@path, self.class.rotated_path(@path)) if File.size?(@path).to_i > @max_bytes
126
+ end
127
+ rescue SystemCallError
128
+ nil
82
129
  end
83
130
  end
84
131
 
@@ -88,16 +135,52 @@ module SchemaReaper
88
135
  class << self
89
136
  attr_accessor :store, :sample_rate
90
137
 
91
- def install!(store:, sample_rate: 0.05)
138
+ # track_sql: also sample the tables named in every executed SQL
139
+ # statement, so a table read only through find_by_sql, a raw
140
+ # connection.execute or a view-backed report still counts as used.
141
+ def install!(store:, sample_rate: 0.05, track_sql: true)
92
142
  return if @installed
93
143
 
94
144
  self.store = store
95
145
  self.sample_rate = sample_rate
96
146
  require "active_record"
97
147
  ActiveRecord::Base.prepend(Hook)
148
+ subscribe_sql if track_sql
98
149
  at_exit { store.flush }
99
150
  @installed = true
100
151
  end
152
+
153
+ private
154
+
155
+ def subscribe_sql
156
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
157
+ Tracker.note_sql(payload[:sql], payload[:name])
158
+ end
159
+ end
160
+ end
161
+
162
+ # The relation after FROM / JOIN / UPDATE / INTO, optionally
163
+ # schema-qualified and double-quoted: "billing"."invoices" -> billing.invoices.
164
+ #
165
+ # INSERT INTO targets are left out on purpose: a table that is only ever
166
+ # written (an audit log nobody reads) is exactly what dead_table should
167
+ # be able to find. INSERT ... SELECT still records its FROM tables.
168
+ SQL_TABLE_RE = /\b(?:FROM|JOIN|UPDATE)\s+((?:"?\w+"?\.)?"?\w+"?)/i.freeze
169
+ SKIP_SQL_NAMES = %w[SCHEMA TRANSACTION].freeze
170
+
171
+ def self.note_sql(sql, name = nil)
172
+ return unless store && sql
173
+ return if SKIP_SQL_NAMES.include?(name)
174
+ return unless rand < sample_rate
175
+
176
+ # "public"."users" is the same table the app (and the schema) calls users
177
+ sql.scan(SQL_TABLE_RE).flatten.map { |t| t.delete('"').delete_prefix("public.") }.uniq.each do |table|
178
+ next if table.start_with?("pg_", "information_schema")
179
+
180
+ store.record(table, "*")
181
+ end
182
+ rescue StandardError
183
+ nil
101
184
  end
102
185
 
103
186
  module Hook
@@ -19,7 +19,9 @@ module SchemaReaper
19
19
  end
20
20
  end
21
21
 
22
- Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, keyword_init: true) do
22
+ # partitioned: an index on a partitioned parent (relkind 'I'). PostgreSQL
23
+ # cannot DROP one CONCURRENTLY, so removal advice differs.
24
+ Index = Struct.new(:name, :columns, :unique, :primary, :scans, :partial, :partitioned, keyword_init: true) do
23
25
  def covers?(other)
24
26
  columns.first(other.columns.length) == other.columns
25
27
  end
@@ -33,10 +35,33 @@ module SchemaReaper
33
35
  end
34
36
  end
35
37
 
38
+ # A declared FOREIGN KEY constraint. columns is in key order; references
39
+ # is the referenced table's display name.
40
+ # behaviour is an opaque signature of referenced key, ON UPDATE/DELETE,
41
+ # MATCH and deferrability: equal only when the constraints act alike.
42
+ ForeignKey = Struct.new(:name, :columns, :references, :behaviour, :validated, keyword_init: true)
43
+
44
+ # A column-owned sequence (serial / IDENTITY). max_value is the effective
45
+ # ceiling: the lower of the sequence's own limit and the column type's.
46
+ Sequence = Struct.new(:name, :table, :column, :column_type, :last_value, :max_value,
47
+ keyword_init: true) do
48
+ def used_fraction
49
+ return 0.0 unless max_value&.positive?
50
+
51
+ last_value.to_f / max_value
52
+ end
53
+ end
54
+
36
55
  Table = Struct.new(
37
- :name, :columns, :indexes, :primary_key, :foreign_keys, :row_count,
56
+ :name, :columns, :indexes, :primary_key, :foreign_keys, :foreign_key_constraints, :row_count,
38
57
  keyword_init: true
39
58
  ) do
59
+ # Introspectors (and hand-built test schemas) that predate constraint
60
+ # details leave this nil.
61
+ def foreign_key_constraints
62
+ self[:foreign_key_constraints] || []
63
+ end
64
+
40
65
  def column_names
41
66
  columns.map(&:name)
42
67
  end
@@ -61,7 +86,11 @@ module SchemaReaper
61
86
  end
62
87
  end
63
88
 
64
- DatabaseSchema = Struct.new(:tables, :index_scan_total, keyword_init: true) do
89
+ DatabaseSchema = Struct.new(:tables, :index_scan_total, :sequences, keyword_init: true) do
90
+ def sequences
91
+ self[:sequences] || []
92
+ end
93
+
65
94
  def table(name)
66
95
  tables.find { |t| t.name == name }
67
96
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaReaper
4
+ # Finds the line in db/schema.rb (or db/structure.sql) that defines a
5
+ # finding's table, column or index, so SARIF annotations land on it instead
6
+ # of line 1 of a file that may not even exist.
7
+ class SchemaLocator
8
+ Location = Struct.new(:path, :line, keyword_init: true)
9
+
10
+ CANDIDATES = %w[db/schema.rb db/structure.sql].freeze
11
+
12
+ def initialize(root: Dir.pwd)
13
+ @root = root
14
+ @path = CANDIDATES.find { |p| File.exist?(File.join(root, p)) }
15
+ @lines = @path ? File.readlines(File.join(root, @path)) : []
16
+ end
17
+
18
+ # @return [Location, nil] nil when there is no schema file at all
19
+ def locate(finding)
20
+ return nil unless @path
21
+
22
+ Location.new(path: @path, line: line_for(finding) || 1)
23
+ end
24
+
25
+ private
26
+
27
+ def line_for(finding)
28
+ start = table_line(finding.table)
29
+ return nil unless start
30
+
31
+ block = block_after(start)
32
+ found = (finding.index && (find_in(block, quoted(finding.index)) || find_in(all_lines, quoted(finding.index)))) ||
33
+ (finding.column && find_in(block, column_pattern(finding.column.split(",").first)))
34
+ found || (start + 1)
35
+ end
36
+
37
+ # schema.rb: create_table "users", ... (schema-qualified: "billing.users")
38
+ # structure.sql: CREATE TABLE public.users (
39
+ def table_line(table)
40
+ bare = table.split(".").last
41
+ ns = table.include?(".") ? table.split(".").first : "public"
42
+ rb = /create_table\s+"#{Regexp.escape(table)}"/
43
+ sql = /CREATE TABLE (?:"?#{Regexp.escape(ns)}"?\.)?"?#{Regexp.escape(bare)}"?\s*\(/
44
+ @lines.index { |l| l.match?(rb) || l.match?(sql) }
45
+ end
46
+
47
+ # Lines from the table's definition to the next table, as [text, index]
48
+ # pairs. Indexes in schema.rb sit inside the block; in structure.sql they
49
+ # come later as CREATE INDEX name ON ..., hence the whole-file fallback
50
+ # for an index (index names are unique per schema, so it can't mismatch).
51
+ def block_after(start)
52
+ rest = all_lines.drop(start + 1)
53
+ stop = rest.index { |l, _| l.match?(/\A\s*create_table\s|\ACREATE TABLE /) }
54
+ stop ? rest.first(stop) : rest
55
+ end
56
+
57
+ def all_lines
58
+ @all_lines ||= @lines.each_with_index.to_a
59
+ end
60
+
61
+ def find_in(pairs, pattern)
62
+ hit = pairs.find { |l, _| l.match?(pattern) }
63
+ hit && (hit[1] + 1)
64
+ end
65
+
66
+ def quoted(name)
67
+ /["\s]#{Regexp.escape(name)}["\s]/
68
+ end
69
+
70
+ # schema.rb: t.string "email" structure.sql: email character varying
71
+ def column_pattern(column)
72
+ /\A\s*(?:t\.\w+\s+"#{Regexp.escape(column)}"|"?#{Regexp.escape(column)}"?\s+\w)/
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "digest"
6
+
7
+ module SchemaReaper
8
+ module Static
9
+ # Per-file scan results keyed by mtime + size, so a re-scan only parses
10
+ # the files that changed. Purely a speed-up: any problem reading or
11
+ # writing it falls back to a full scan, never a wrong one.
12
+ class Cache
13
+ # Bumped whenever what a file contributes changes, so a cache written by
14
+ # an older scanner is discarded instead of trusted.
15
+ FORMAT = 3
16
+
17
+ # path - where to keep it, or nil to disable
18
+ # key - anything else that changes a file's result (gem version, ...)
19
+ def initialize(path, key:)
20
+ @path = path
21
+ # A digest, not #hash: Ruby seeds #hash per process, so a key built
22
+ # with it never matches the one the previous run saved.
23
+ @key = Digest::SHA256.hexdigest(JSON.generate([FORMAT, key]))
24
+ @files = load
25
+ end
26
+
27
+ def lookup(file, kind)
28
+ stamp = stamp_for(file, kind)
29
+ hit = @files[file]
30
+ return hit["entry"].merge("stamp" => stamp) if stamp && hit && hit["stamp"] == stamp
31
+
32
+ yield.merge("stamp" => stamp)
33
+ end
34
+
35
+ def save(entries)
36
+ return unless @path
37
+
38
+ files = entries.to_h do |file, entry|
39
+ [file, { "stamp" => entry["stamp"], "entry" => entry.reject { |k, _| k == "stamp" } }]
40
+ end
41
+ FileUtils.mkdir_p(File.dirname(@path))
42
+ File.write(@path, JSON.generate("key" => @key, "files" => files))
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ def load
50
+ return {} unless @path && File.exist?(@path)
51
+
52
+ data = JSON.parse(File.read(@path))
53
+ data["key"] == @key ? data["files"] : {}
54
+ rescue StandardError
55
+ {}
56
+ end
57
+
58
+ def stamp_for(file, kind)
59
+ st = File.stat(file)
60
+ "#{kind}:#{st.mtime.to_f}:#{st.size}"
61
+ rescue SystemCallError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end