rspec-hermetic 0.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +166 -0
  4. data/lib/rspec/hermetic/allowlist.rb +75 -0
  5. data/lib/rspec/hermetic/candidate_report.rb +47 -0
  6. data/lib/rspec/hermetic/change.rb +61 -0
  7. data/lib/rspec/hermetic/configuration.rb +93 -0
  8. data/lib/rspec/hermetic/corpus_evaluation.rb +124 -0
  9. data/lib/rspec/hermetic/diff.rb +63 -0
  10. data/lib/rspec/hermetic/evaluation.rb +184 -0
  11. data/lib/rspec/hermetic/evaluation_task.rb +53 -0
  12. data/lib/rspec/hermetic/forensic.rb +22 -0
  13. data/lib/rspec/hermetic/formatter.rb +93 -0
  14. data/lib/rspec/hermetic/minitest.rb +80 -0
  15. data/lib/rspec/hermetic/probe/base.rb +17 -0
  16. data/lib/rspec/hermetic/probe/constants.rb +87 -0
  17. data/lib/rspec/hermetic/probe/env.rb +15 -0
  18. data/lib/rspec/hermetic/probe/filesystem.rb +109 -0
  19. data/lib/rspec/hermetic/probe/globals.rb +31 -0
  20. data/lib/rspec/hermetic/probe/rails.rb +146 -0
  21. data/lib/rspec/hermetic/probe/randomness.rb +78 -0
  22. data/lib/rspec/hermetic/probe/resources.rb +110 -0
  23. data/lib/rspec/hermetic/probe/ruby_runtime.rb +37 -0
  24. data/lib/rspec/hermetic/probe/time.rb +54 -0
  25. data/lib/rspec/hermetic/probe.rb +38 -0
  26. data/lib/rspec/hermetic/resource_tracker.rb +111 -0
  27. data/lib/rspec/hermetic/restorer.rb +330 -0
  28. data/lib/rspec/hermetic/runner.rb +183 -0
  29. data/lib/rspec/hermetic/snapshot.rb +37 -0
  30. data/lib/rspec/hermetic/stable_value.rb +140 -0
  31. data/lib/rspec/hermetic/verdict.rb +39 -0
  32. data/lib/rspec/hermetic/verify_task.rb +65 -0
  33. data/lib/rspec/hermetic/version.rb +11 -0
  34. data/lib/rspec/hermetic.rb +59 -0
  35. data/sig/rspec/hermetic.rbs +112 -0
  36. metadata +117 -0
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ require_relative "change"
6
+
7
+ module RSpec
8
+ module Hermetic
9
+ class Restorer
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def restore(change_set, before_snapshot, context: nil)
15
+ restore_env(before_snapshot) if reset?(:env)
16
+ restore_globals(before_snapshot) if reset?(:globals)
17
+ restore_ruby_runtime(before_snapshot) if reset?(:ruby_runtime)
18
+ restore_rails(before_snapshot) if reset?(:rails)
19
+ restore_time(context) if reset?(:time)
20
+ restore_randomness(before_snapshot) if reset?(:randomness)
21
+ restore_constants(change_set) if reset?(:constants)
22
+ restore_resources(change_set) if reset?(:resources)
23
+ restore_filesystem(change_set) if reset?(:filesystem)
24
+ end
25
+
26
+ private
27
+
28
+ def reset?(probe)
29
+ @configuration.auto_reset_probe?(probe)
30
+ end
31
+
32
+ def restore_env(before_snapshot)
33
+ before = before_snapshot.data.fetch(:env, {})
34
+ ENV.replace(before)
35
+ end
36
+
37
+ def restore_globals(before_snapshot)
38
+ before = before_snapshot.data.fetch(:globals, {})
39
+ $LOAD_PATH.replace(before["$LOAD_PATH"]) if before["$LOAD_PATH"].is_a?(Array)
40
+ $LOADED_FEATURES.replace(before["$LOADED_FEATURES"]) if before["$LOADED_FEATURES"].is_a?(Array)
41
+ $PROGRAM_NAME = before["$PROGRAM_NAME"] if before.key?("$PROGRAM_NAME")
42
+ $VERBOSE = before["$VERBOSE"] if before.key?("$VERBOSE")
43
+ $DEBUG = before["$DEBUG"] if before.key?("$DEBUG")
44
+ end
45
+
46
+ def restore_ruby_runtime(before_snapshot)
47
+ before = before_snapshot.data.fetch(:ruby_runtime, {})
48
+ $VERBOSE = before["$VERBOSE"] if before.key?("$VERBOSE")
49
+ $DEBUG = before["$DEBUG"] if before.key?("$DEBUG")
50
+ GC.stress = before["GC.stress"] if before.key?("GC.stress")
51
+ Thread.abort_on_exception = before["Thread.abort_on_exception"] if before.key?("Thread.abort_on_exception")
52
+ Thread.report_on_exception = before["Thread.report_on_exception"] if before.key?("Thread.report_on_exception")
53
+ restore_warnings(before)
54
+ end
55
+
56
+ def restore_warnings(before)
57
+ before.each do |key, value|
58
+ next unless key.start_with?("Warning[:")
59
+ next unless defined?(Warning)
60
+
61
+ category = key[/Warning\[:(.+)\]/, 1]&.to_sym
62
+ Warning[category] = value unless category.nil? || value == :unsupported
63
+ rescue ArgumentError
64
+ nil
65
+ end
66
+ end
67
+
68
+ def restore_rails(before_snapshot)
69
+ before = before_snapshot.data.fetch(:rails, {})
70
+ restore_i18n(before)
71
+ restore_time_zone(before)
72
+ restore_rails_config(before)
73
+ restore_action_mailer(before)
74
+ end
75
+
76
+ def restore_i18n(before)
77
+ return unless Object.const_defined?(:I18n)
78
+
79
+ ::I18n.locale = before["I18n.locale"] if before.key?("I18n.locale") && ::I18n.respond_to?(:locale=)
80
+ if before.key?("I18n.default_locale") && ::I18n.respond_to?(:default_locale=)
81
+ ::I18n.default_locale = before["I18n.default_locale"]
82
+ end
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ def restore_time_zone(before)
88
+ return unless Object.const_defined?(:Time) && ::Time.respond_to?(:zone=)
89
+
90
+ ::Time.zone = before["Time.zone"] if before.key?("Time.zone")
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def restore_rails_config(before)
96
+ return unless Object.const_defined?(:Rails) &&
97
+ ::Rails.respond_to?(:application) &&
98
+ ::Rails.application.respond_to?(:config)
99
+
100
+ before.each do |key, value|
101
+ next unless key.start_with?("Rails.application.config.")
102
+
103
+ path = key.delete_prefix("Rails.application.config.")
104
+ write_path(::Rails.application.config, path, value)
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ end
109
+
110
+ def restore_action_mailer(before)
111
+ return unless defined?(::ActionMailer::Base) && ::ActionMailer::Base.respond_to?(:deliveries)
112
+ return unless before.key?("ActionMailer::Base.deliveries.size")
113
+
114
+ ::ActionMailer::Base.deliveries.slice!(before["ActionMailer::Base.deliveries.size"]..)
115
+ rescue StandardError
116
+ nil
117
+ end
118
+
119
+ def restore_time(context)
120
+ context.travel_back if context.respond_to?(:travel_back)
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ def restore_randomness(before_snapshot)
126
+ before = before_snapshot.data.fetch(:randomness, {})
127
+ Kernel.srand(before["Kernel.srand.seed"]) if before.key?("Kernel.srand.seed")
128
+ end
129
+
130
+ def restore_constants(change_set)
131
+ change_set.changes.select { |change| change.probe == :constants }.each do |change|
132
+ if change.addition?
133
+ remove_constant(change.key)
134
+ elsif restorable_value?(change.before)
135
+ set_constant(change.key, deep_dup(change.before))
136
+ end
137
+ end
138
+ end
139
+
140
+ def restore_resources(change_set)
141
+ change_set.changes.select { |change| change.probe == :resources }.each do |change|
142
+ restore_threads(change) if change.key == "threads"
143
+ restore_io(change) if change.key == "io"
144
+ restore_child_processes(change) if change.key == "child_processes"
145
+ end
146
+ end
147
+
148
+ def restore_threads(change)
149
+ before_ids = Array(change.before).map { |entry| entry[:object_id] || entry["object_id"] }
150
+ after_ids = Array(change.after).map { |entry| entry[:object_id] || entry["object_id"] }
151
+ leaked_ids = after_ids - before_ids
152
+ Thread.list.each do |thread|
153
+ next unless leaked_ids.include?(thread.object_id)
154
+ next if thread == Thread.current
155
+
156
+ thread.kill
157
+ thread.join(0.1)
158
+ end
159
+ end
160
+
161
+ def restore_io(change)
162
+ before_fds = Array(change.before).map { |entry| entry[:fileno] || entry["fileno"] }
163
+ after_fds = Array(change.after).map { |entry| entry[:fileno] || entry["fileno"] }
164
+ leaked_fds = after_fds - before_fds
165
+ ObjectSpace.each_object(IO) do |io|
166
+ next if io.closed?
167
+ next unless leaked_fds.include?(io.fileno)
168
+
169
+ io.close
170
+ rescue IOError
171
+ nil
172
+ end
173
+ end
174
+
175
+ def restore_filesystem(change_set)
176
+ changes = change_set.changes.select { |change| change.probe == :filesystem }
177
+ remove_added_paths(changes.select(&:addition?))
178
+ restore_removed_or_modified_paths(changes.reject(&:addition?))
179
+ end
180
+
181
+ def restore_child_processes(change)
182
+ before_pids = Array(change.before).map { |entry| entry[:pid] || entry["pid"] }
183
+ after_pids = Array(change.after).map { |entry| entry[:pid] || entry["pid"] }
184
+ leaked_pids = after_pids - before_pids
185
+ leaked_pids.each { |pid| terminate_child_process(pid) }
186
+ end
187
+
188
+ def terminate_child_process(pid)
189
+ Process.kill("TERM", pid)
190
+ 10.times do
191
+ return if Process.waitpid(pid, Process::WNOHANG)
192
+
193
+ sleep 0.02
194
+ rescue Errno::ECHILD
195
+ return
196
+ end
197
+ Process.kill("KILL", pid)
198
+ Process.waitpid(pid)
199
+ rescue Errno::ESRCH, Errno::ECHILD, Errno::EPERM
200
+ nil
201
+ end
202
+
203
+ def remove_added_paths(changes)
204
+ changes.sort_by { |change| -change.key.to_s.count(File::SEPARATOR) }.each do |change|
205
+ path = safe_filesystem_path(change.key)
206
+ FileUtils.rm_rf(path) if path
207
+ end
208
+ end
209
+
210
+ def restore_removed_or_modified_paths(changes)
211
+ changes.sort_by { |change| change.key.to_s.count(File::SEPARATOR) }.each do |change|
212
+ path = safe_filesystem_path(change.key)
213
+ next unless path
214
+
215
+ if change.removal? || change.mutation?
216
+ restore_filesystem_entry(path, change.before)
217
+ end
218
+ end
219
+ end
220
+
221
+ def restore_filesystem_entry(path, entry)
222
+ return if entry.equal?(Change::MISSING)
223
+
224
+ case entry_value(entry, :type)
225
+ when :directory, "directory"
226
+ FileUtils.mkdir_p(path)
227
+ when :file, "file"
228
+ restore_file(path, entry)
229
+ when :symlink, "symlink"
230
+ restore_symlink(path, entry)
231
+ end
232
+ restore_mtime(path, entry)
233
+ end
234
+
235
+ def restore_file(path, entry)
236
+ FileUtils.mkdir_p(File.dirname(path))
237
+ content = entry_value(entry, :content)
238
+ return unless content
239
+
240
+ File.binwrite(path, content)
241
+ end
242
+
243
+ def restore_symlink(path, entry)
244
+ target = entry_value(entry, :target)
245
+ return unless target
246
+
247
+ FileUtils.mkdir_p(File.dirname(path))
248
+ FileUtils.rm_f(path)
249
+ File.symlink(target, path)
250
+ rescue NotImplementedError
251
+ nil
252
+ end
253
+
254
+ def restore_mtime(path, entry)
255
+ return unless File.exist?(path) || File.symlink?(path)
256
+
257
+ mtime = entry_value(entry, :mtime)
258
+ return unless mtime
259
+
260
+ File.utime(mtime, mtime, path)
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ def safe_filesystem_path(key)
266
+ root = File.expand_path(@configuration.root_path)
267
+ path = File.expand_path(key, root)
268
+ return unless path == root || path.start_with?("#{root}#{File::SEPARATOR}")
269
+
270
+ path
271
+ end
272
+
273
+ def entry_value(entry, key)
274
+ entry[key] || entry[key.to_s]
275
+ end
276
+
277
+ def remove_constant(name)
278
+ parent, const_name = constant_parent(name)
279
+ parent.__send__(:remove_const, const_name) if parent.const_defined?(const_name, false)
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ def set_constant(name, value)
285
+ parent, const_name = constant_parent(name)
286
+ parent.__send__(:remove_const, const_name) if parent.const_defined?(const_name, false)
287
+ parent.const_set(const_name, value)
288
+ rescue StandardError
289
+ nil
290
+ end
291
+
292
+ def constant_parent(name)
293
+ parts = name.to_s.split("::")
294
+ const_name = parts.pop
295
+ parent = parts.reject(&:empty?).reduce(Object) { |mod, part| mod.const_get(part, false) }
296
+ [parent, const_name.to_sym]
297
+ end
298
+
299
+ def restorable_value?(value)
300
+ value.equal?(Change::MISSING) || value.nil? || value == true || value == false ||
301
+ value.is_a?(Symbol) || value.is_a?(Numeric) || value.is_a?(String) ||
302
+ value.is_a?(Array) || value.is_a?(Hash)
303
+ end
304
+
305
+ def deep_dup(value)
306
+ Marshal.load(Marshal.dump(value))
307
+ rescue StandardError
308
+ value
309
+ end
310
+
311
+ def write_path(object, path, value)
312
+ segments = path.split(".")
313
+ target = segments[0...-1].reduce(object) { |current, segment| read_segment(current, segment) }
314
+ last = segments.last
315
+ if target.respond_to?(:"#{last}=")
316
+ target.public_send(:"#{last}=", value)
317
+ elsif target.respond_to?(:[]=)
318
+ target[last.to_sym] = value
319
+ end
320
+ end
321
+
322
+ def read_segment(object, segment)
323
+ return object.public_send(segment) if object.respond_to?(segment)
324
+ return object[segment.to_sym] if object.respond_to?(:[])
325
+
326
+ raise NoMethodError, "undefined segment #{segment.inspect}"
327
+ end
328
+ end
329
+ end
330
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diff"
4
+ require_relative "candidate_report"
5
+ require_relative "formatter"
6
+ require_relative "probe"
7
+ require_relative "resource_tracker"
8
+ require_relative "restorer"
9
+ require_relative "snapshot"
10
+ require_relative "verdict"
11
+
12
+ module RSpec
13
+ module Hermetic
14
+ class Runner
15
+ attr_reader :records
16
+
17
+ def initialize(configuration)
18
+ @configuration = configuration
19
+ @records = []
20
+ @suite_baseline = nil
21
+ @last_writers = {}
22
+ @example_count = 0
23
+ end
24
+
25
+ def call(example, context)
26
+ @example_count += 1
27
+ probes = build_probes
28
+ before = Snapshot.capture(probes, context: context)
29
+ @suite_baseline ||= before
30
+
31
+ ResourceTracker.start! if @configuration.track_resource_origins
32
+ example.run
33
+ ensure
34
+ if before
35
+ after = Snapshot.capture(probes, context: context)
36
+ ResourceTracker.stop! if @configuration.track_resource_origins
37
+ change_set = Diff.between(before, after)
38
+ result = Verdict.new(@configuration).call(
39
+ change_set,
40
+ example_allowances: example_allowances(example)
41
+ )
42
+ record(example, change_set, result)
43
+ report_pollution(example, change_set, result)
44
+ report_probe_errors(example, change_set, result)
45
+ report_forensic_victim(example, before) if @configuration.forensic && failed?(example)
46
+ remember_writers(example, change_set)
47
+ restore_state(change_set, before, context)
48
+ fail_example!(example, result, change_set) if should_fail_for_pollution?(example, result)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def build_probes
55
+ Array(@configuration.probes).filter_map do |name|
56
+ next unless sampled?(name)
57
+
58
+ Probe.build(name, @configuration)
59
+ end
60
+ end
61
+
62
+ def sampled?(name)
63
+ return true if @example_count == 1
64
+
65
+ (@example_count % @configuration.probe_interval(name)).zero?
66
+ end
67
+
68
+ def example_allowances(example)
69
+ metadata = example.respond_to?(:metadata) ? example.metadata : {}
70
+ hermetic = metadata[:hermetic]
71
+ hermetic.is_a?(Hash) ? hermetic[:allow] : nil
72
+ end
73
+
74
+ def record(example, change_set, result)
75
+ return unless result.reportable? || change_set.after_errors.any?
76
+
77
+ @records << {
78
+ example: example,
79
+ changes: change_set,
80
+ verdict: result
81
+ }
82
+ end
83
+
84
+ def report_pollution(example, change_set, result)
85
+ return unless result.reportable?
86
+ return if @configuration.on_pollution == :fail
87
+
88
+ message = Formatter.pollution_message(example, result, change_set)
89
+ reporter_message(example, message)
90
+ end
91
+
92
+ def report_probe_errors(example, change_set, result)
93
+ return unless @configuration.report_probe_errors
94
+ return if result.reportable?
95
+ return unless change_set.errors?
96
+
97
+ reporter_message(example, Formatter.probe_error_message(example, change_set))
98
+ end
99
+
100
+ def report_forensic_victim(example, before_snapshot)
101
+ dirty = Diff.between(@suite_baseline, before_snapshot).changes
102
+ return if dirty.empty?
103
+
104
+ reporter_message(example, Formatter.forensic_message(example, dirty, @last_writers))
105
+ write_candidates(example, dirty)
106
+ end
107
+
108
+ def remember_writers(example, change_set)
109
+ change_set.changes.each do |change|
110
+ @last_writers[[change.probe, change.key]] = example_label(example)
111
+ end
112
+ end
113
+
114
+ def fail_example!(example, result, change_set)
115
+ message = Formatter.pollution_message(example, result, change_set)
116
+ raise Error, message
117
+ end
118
+
119
+ def should_fail_for_pollution?(example, result)
120
+ result.polluted? && @configuration.on_pollution == :fail && !failed?(example)
121
+ end
122
+
123
+ def restore_state(change_set, before_snapshot, context)
124
+ return unless @configuration.auto_reset
125
+
126
+ Restorer.new(@configuration).restore(change_set, before_snapshot, context: context)
127
+ end
128
+
129
+ def write_candidates(example, changes)
130
+ return unless @configuration.candidate_report_path
131
+
132
+ report = CandidateReport.new(File.join(@configuration.root_path, @configuration.candidate_report_path))
133
+ changes.each do |change|
134
+ polluter = @last_writers[[change.probe, change.key]]
135
+ next unless polluter
136
+
137
+ report.append(
138
+ "polluter" => polluter,
139
+ "victim" => example_label(example),
140
+ "probe" => change.probe,
141
+ "key" => change.key,
142
+ "before" => safe_json(change.before),
143
+ "after" => safe_json(change.after)
144
+ )
145
+ end
146
+ rescue StandardError => error
147
+ reporter_message(example, "HERMETIC CANDIDATE REPORT ERROR #{error.class}: #{error.message}")
148
+ end
149
+
150
+ def reporter_message(example, message)
151
+ reporter = if example.respond_to?(:reporter)
152
+ example.reporter
153
+ elsif defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
154
+ ::RSpec.configuration.reporter
155
+ end
156
+
157
+ if reporter&.respond_to?(:message)
158
+ reporter.message(message)
159
+ else
160
+ warn(message)
161
+ end
162
+ end
163
+
164
+ def failed?(example)
165
+ example.respond_to?(:exception) && !example.exception.nil?
166
+ end
167
+
168
+ def example_label(example)
169
+ return example.location if example.respond_to?(:location) && example.location
170
+
171
+ example.respond_to?(:full_description) ? example.full_description : example.object_id.to_s
172
+ end
173
+
174
+ def safe_json(value)
175
+ return nil if value.equal?(Change::MISSING)
176
+
177
+ JSON.parse(JSON.generate(value))
178
+ rescue StandardError
179
+ value.inspect
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hermetic
5
+ class Snapshot
6
+ attr_reader :data, :errors, :timings
7
+
8
+ def initialize(data:, errors: {}, timings: {})
9
+ @data = data
10
+ @errors = errors
11
+ @timings = timings
12
+ end
13
+
14
+ def self.capture(probes, context: nil)
15
+ data = {}
16
+ errors = {}
17
+ timings = {}
18
+
19
+ probes.each do |probe|
20
+ started_at = monotonic_now
21
+ data[probe.name] = probe.capture(context)
22
+ rescue StandardError => error
23
+ errors[probe.name] = "#{error.class}: #{error.message}"
24
+ data[probe.name] = {}
25
+ ensure
26
+ timings[probe.name] = monotonic_now - started_at if started_at
27
+ end
28
+
29
+ new(data: data, errors: errors, timings: timings)
30
+ end
31
+
32
+ def self.monotonic_now
33
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ begin
6
+ require "objspace"
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ module RSpec
12
+ module Hermetic
13
+ module StableValue
14
+ module_function
15
+
16
+ MAX_DEPTH = 3
17
+
18
+ def capture(value, depth: MAX_DEPTH, seen: {})
19
+ case value
20
+ when nil, true, false, Symbol, Numeric
21
+ value
22
+ when String
23
+ value.dup.freeze
24
+ when Array
25
+ return fingerprint(value) if depth <= 0
26
+
27
+ value.map { |item| capture(item, depth: depth - 1, seen: seen) }.freeze
28
+ when Hash
29
+ return fingerprint(value) if depth <= 0
30
+
31
+ value.keys.sort_by(&:to_s).to_h do |key|
32
+ [
33
+ capture(key, depth: depth - 1, seen: seen),
34
+ capture(value[key], depth: depth - 1, seen: seen)
35
+ ]
36
+ end.freeze
37
+ else
38
+ object_fingerprint(value, depth, seen)
39
+ end
40
+ end
41
+
42
+ def fingerprint(value)
43
+ dumped = Marshal.dump(value)
44
+ "#{value.class}:marshal:#{Digest::SHA256.hexdigest(dumped)}"
45
+ rescue StandardError
46
+ normalized = value.inspect.gsub(/0x[0-9a-f]+/i, "0x...")
47
+ "#{value.class}:inspect:#{Digest::SHA256.hexdigest(normalized)}"
48
+ end
49
+
50
+ def reachable_graph_fingerprint(value, max_depth:, max_nodes:)
51
+ return capture(value) unless ObjectSpace.respond_to?(:reachable_objects_from)
52
+
53
+ seen = {}
54
+ queue = [[value, 0]]
55
+ nodes = []
56
+
57
+ until queue.empty? || nodes.length >= max_nodes
58
+ object, depth = queue.shift
59
+ next if seen[object.object_id]
60
+
61
+ begin
62
+ seen[object.object_id] = true
63
+ nodes << graph_node(object, depth)
64
+ next if depth >= max_depth
65
+
66
+ ObjectSpace.reachable_objects_from(object).each do |child|
67
+ next if immediate?(child)
68
+
69
+ queue << [child, depth + 1]
70
+ end
71
+ rescue StandardError
72
+ next
73
+ end
74
+ end
75
+
76
+ {
77
+ graph: Digest::SHA256.hexdigest(Marshal.dump(nodes.sort_by(&:to_s))),
78
+ nodes: nodes.length,
79
+ truncated: !queue.empty?
80
+ }
81
+ end
82
+
83
+ def object_fingerprint(value, depth, seen)
84
+ return fingerprint(value) if depth <= 0
85
+ return "#{value.class}:cycle:#{seen[value.object_id]}" if seen.key?(value.object_id)
86
+
87
+ seen[value.object_id] = seen.length
88
+ dumped = Marshal.dump(value)
89
+ "#{value.class}:marshal:#{Digest::SHA256.hexdigest(dumped)}"
90
+ rescue StandardError
91
+ payload = {
92
+ class: value.class.name,
93
+ inspect: value.inspect.gsub(/0x[0-9a-f]+/i, "0x..."),
94
+ ivars: instance_variables_for(value, depth, seen),
95
+ class_vars: class_variables_for(value, depth, seen)
96
+ }
97
+ "#{value.class}:graph:#{Digest::SHA256.hexdigest(Marshal.dump(payload))}"
98
+ ensure
99
+ seen.delete(value.object_id)
100
+ end
101
+
102
+ def instance_variables_for(value, depth, seen)
103
+ value.instance_variables.sort_by(&:to_s).to_h do |ivar|
104
+ [ivar.to_s, capture(value.instance_variable_get(ivar), depth: depth - 1, seen: seen)]
105
+ rescue StandardError => error
106
+ [ivar.to_s, "#{error.class}: #{error.message}"]
107
+ end
108
+ end
109
+
110
+ def class_variables_for(value, depth, seen)
111
+ return {} unless value.is_a?(Module)
112
+
113
+ value.class_variables.sort_by(&:to_s).to_h do |ivar|
114
+ [ivar.to_s, capture(value.class_variable_get(ivar), depth: depth - 1, seen: seen)]
115
+ rescue StandardError => error
116
+ [ivar.to_s, "#{error.class}: #{error.message}"]
117
+ end
118
+ end
119
+
120
+ def graph_node(object, depth)
121
+ {
122
+ class: object.class.name,
123
+ depth: depth,
124
+ value: immediate?(object) ? object : capture(object, depth: 1)
125
+ }
126
+ rescue StandardError => error
127
+ {
128
+ class: object.class.name,
129
+ depth: depth,
130
+ error: "#{error.class}: #{error.message}"
131
+ }
132
+ end
133
+
134
+ def immediate?(object)
135
+ object.nil? || object == true || object == false ||
136
+ object.is_a?(Symbol) || object.is_a?(Numeric)
137
+ end
138
+ end
139
+ end
140
+ end