holdfast 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.
@@ -0,0 +1,136 @@
1
+ # Loaded by holdfast's pin tests inside the app's own suite. It calls one
2
+ # method on one input and turns what it saw into plain JSON: the returned
3
+ # value (a record by its attributes, not its class), the error it raised,
4
+ # and the attributes of each record the call created or changed. A pin test
5
+ # compares that JSON with the JSON the base side produced, and names only the
6
+ # paths that differ.
7
+ require "json"
8
+ require "date"
9
+
10
+ module Holdfast
11
+ module Observe
12
+ # Ids, foreign keys and timestamps differ between two runs of the same
13
+ # input, so they never decide a pin.
14
+ VOLATILE = /\A(id|created_at|updated_at)\z|_id\z/
15
+ RECENT = 86_400
16
+
17
+ module Track
18
+ def _create_record(*)
19
+ super.tap { (Thread.current[:holdfast_writes] ||= []) << [:created, self] if Thread.current[:holdfast_writes] }
20
+ end
21
+
22
+ def _update_record(*)
23
+ super.tap { (Thread.current[:holdfast_writes] ||= []) << [:updated, self] if Thread.current[:holdfast_writes] }
24
+ end
25
+ end
26
+ ActiveRecord::Base.prepend(Track) if defined?(ActiveRecord::Base)
27
+
28
+ module_function
29
+
30
+ def observe(context, input, target)
31
+ const_name, kind, name = target.match(/\A(.+?)([#.])(\w+[?!=]?)\z/).captures
32
+ const = Object.const_get(const_name)
33
+ within_savepoint do
34
+ run_ruby(context, input["before"]) if input["before"]
35
+ receiver = if input.key?("receiver") then value(context, input["receiver"])
36
+ elsif kind == "." then const
37
+ else const.new(*value(context, input.fetch("new", [])))
38
+ end
39
+ args = value(context, Array(input["args"]))
40
+ kwargs = value(context, input.fetch("kwargs", {})).transform_keys(&:to_sym)
41
+ Thread.current[:holdfast_writes] = []
42
+ outcome = begin
43
+ { "returns" => project(receiver.__send__(name, *args, **kwargs)) }
44
+ rescue StandardError => e
45
+ { "raises" => e.class.name, "message" => e.message.gsub(/\d{4,}/, "N").lines.first.to_s.strip }
46
+ end
47
+ writes = Thread.current[:holdfast_writes]
48
+ outcome["writes"] = written(writes) unless writes.empty?
49
+ outcome
50
+ ensure
51
+ Thread.current[:holdfast_writes] = nil
52
+ end
53
+ end
54
+
55
+ # Paths whose values differ, as "path: base X, head Y" lines.
56
+ def differences(expected, actual, path = "")
57
+ return [] if expected == actual
58
+
59
+ if expected.is_a?(Hash) && actual.is_a?(Hash)
60
+ (expected.keys | actual.keys).flat_map { differences(expected[_1], actual[_1], path.empty? ? _1.to_s : "#{path}.#{_1}") }
61
+ elsif expected.is_a?(Array) && actual.is_a?(Array) && expected.size == actual.size
62
+ expected.each_index.flat_map { differences(expected[_1], actual[_1], "#{path}[#{_1}]") }
63
+ else
64
+ ["#{path.empty? ? "value" : path}: base #{short(expected)}, head #{short(actual)}"]
65
+ end
66
+ end
67
+
68
+ def short(v) = (v.nil? ? "(none)" : v.is_a?(String) ? v : JSON.generate(v)).then { _1.size > 90 ? "#{_1[0, 87]}..." : _1 }
69
+
70
+ def within_savepoint
71
+ return yield unless defined?(ActiveRecord::Base)
72
+
73
+ result = nil
74
+ ActiveRecord::Base.transaction(requires_new: true) do
75
+ result = yield
76
+ raise ActiveRecord::Rollback
77
+ end
78
+ result
79
+ end
80
+
81
+ def run_ruby(context, code) = context.instance_eval(code.is_a?(Hash) ? code.fetch("ruby") : code.to_s)
82
+
83
+ def value(context, v)
84
+ case v
85
+ when Hash then v.keys == ["ruby"] ? run_ruby(context, v) : v.to_h { [_1, value(context, _2)] }
86
+ when Array then v.map { value(context, _1) }
87
+ else v
88
+ end
89
+ end
90
+
91
+ def written(writes)
92
+ seen = {}
93
+ writes.each do |how, record|
94
+ key = [how, record.class.name, record.object_id]
95
+ if how == :created
96
+ seen[key] = { "created" => record.class.name, "attributes" => attributes(record) }
97
+ else
98
+ changed = record.saved_changes.keys.grep_v(VOLATILE)
99
+ next if changed.empty?
100
+
101
+ entry = (seen[key] ||= { "updated" => record.class.name, "changed" => {} })
102
+ changed.each { entry["changed"][_1] = project(record.public_send(_1)) }
103
+ end
104
+ end
105
+ seen.values.sort_by { JSON.generate(_1) }
106
+ end
107
+
108
+ def attributes(record) = record.attributes.reject { |k, _| k.match?(VOLATILE) }.sort.to_h.transform_values { project(_1) }
109
+
110
+ def project(v, depth = 0)
111
+ return v.class.name if depth > 3
112
+
113
+ case v
114
+ when nil, true, false, Integer, String then v
115
+ when Float then v.round(6)
116
+ when Symbol then ":#{v}"
117
+ when Date then v.respond_to?(:hour) ? time(v) : "Date #{v.iso8601}"
118
+ when Time then time(v)
119
+ when ->(x) { defined?(ActiveSupport::TimeWithZone) && x.is_a?(ActiveSupport::TimeWithZone) } then time(v)
120
+ when ->(x) { defined?(BigDecimal) && x.is_a?(BigDecimal) } then v.to_s("F")
121
+ when ->(x) { defined?(ActiveRecord::Base) && x.is_a?(ActiveRecord::Base) }
122
+ { "record" => v.class.name, "persisted" => v.persisted? }.merge(attributes(v))
123
+ when ->(x) { defined?(ActiveRecord::Relation) && x.is_a?(ActiveRecord::Relation) } then v.to_a.first(20).map { project(_1, depth + 1) }
124
+ when Array then v.first(50).map { project(_1, depth + 1) }
125
+ when Hash then v.to_h { [_1.is_a?(Symbol) ? ":#{_1}" : _1.to_s, project(_2, depth + 1)] }
126
+ when Data then { "data" => v.class.name }.merge(v.to_h.to_h { [_1.to_s, project(_2, depth + 1)] })
127
+ when Struct then { "struct" => v.class.name }.merge(v.to_h.to_h { [_1.to_s, project(_2, depth + 1)] })
128
+ else { "object" => v.class.name }
129
+ end
130
+ end
131
+
132
+ # A time near now comes from the clock, not the input, so it is pinned
133
+ # as recent only.
134
+ def time(t) = (Time.now.to_f - t.to_f).abs < RECENT ? "Time (now)" : "Time #{t.utc.iso8601}"
135
+ end
136
+ end
@@ -0,0 +1,104 @@
1
+ # Loaded through RUBYOPT into each test process jitcatch starts, before the
2
+ # app: it records which lines of the app each test file ran, so a row can say
3
+ # whether a test reached the changed lines or only named the method. It runs
4
+ # with no holdfast code loaded and writes one JSON file per process.
5
+ require "coverage"
6
+ require "json"
7
+
8
+ # eval: true also counts code compiled from a file name, which is how
9
+ # ActionView runs a template. Branches tell whether the body of a
10
+ # `return x if y` ran, which line counts cannot.
11
+ Coverage.start(lines: true, branches: true, eval: true) unless Coverage.running?
12
+
13
+ # Which of the values the change added or removed (HOLDFAST_VALUES, a JSON
14
+ # list) an input drew: a row written with that value. Reads do not count: a
15
+ # scope that selects by a constant puts every member in each query.
16
+ HOLDFAST_WANTED = (JSON.parse(ENV.fetch("HOLDFAST_VALUES", "[]")) rescue []).map(&:to_s)
17
+ HOLDFAST_DREW = []
18
+ unless HOLDFAST_WANTED.empty?
19
+ attach = TracePoint.new(:end) do |tp|
20
+ next unless defined?(ActiveSupport::Notifications)
21
+
22
+ tp.disable
23
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload|
24
+ sql = payload[:sql].to_s
25
+ next unless sql.match?(/\A\s*(INSERT|UPDATE)/i)
26
+
27
+ binds = payload[:type_casted_binds]
28
+ binds = binds.call if binds.respond_to?(:call)
29
+ seen = Array(binds).map(&:to_s) + sql.scan(/'((?:[^']|'')*)'/).flatten
30
+ HOLDFAST_DREW.concat(HOLDFAST_WANTED & seen)
31
+ HOLDFAST_DREW.uniq!
32
+ end
33
+ end
34
+ attach.enable
35
+ end
36
+
37
+ # What ran since the last take, as { file => lines } and { file => arms },
38
+ # for the app's own files. Each take clears the counts, so the file's total
39
+ # is the union of every take.
40
+ HOLDFAST_ROOT = "#{Dir.pwd}/"
41
+ HOLDFAST_TOTAL = { "hits" => Hash.new { |h, k| h[k] = [] }, "arms" => Hash.new { |h, k| h[k] = [] } }
42
+ def holdfast_take
43
+ hits = {}
44
+ arms = {}
45
+ Coverage.result(stop: false, clear: true).each do |path, counts|
46
+ next unless path.start_with?(HOLDFAST_ROOT)
47
+
48
+ relative = path.delete_prefix(HOLDFAST_ROOT)
49
+ next unless relative.start_with?("app/", "lib/")
50
+
51
+ lines = counts.is_a?(Hash) ? counts[:lines] : counts
52
+ ran = Array(lines).each_with_index.filter_map { |count, i| i + 1 if count.to_i.positive? }
53
+ taken = (counts.is_a?(Hash) && counts[:branches] || {}).values.flat_map { |a| a.filter_map { |(_, _, line, column, last, last_column), n| "#{line}:#{column}-#{last}:#{last_column}" if n.positive? } }
54
+ hits[relative] = ran unless ran.empty?
55
+ arms[relative] = taken unless taken.empty?
56
+ end
57
+ hits.each { |k, v| HOLDFAST_TOTAL["hits"][k] |= v }
58
+ arms.each { |k, v| HOLDFAST_TOTAL["arms"][k] |= v }
59
+ { "hits" => hits, "arms" => arms }
60
+ end
61
+
62
+ # One test file holds examples that agree and examples the change added, so
63
+ # a file's lines cannot say which example drew a new branch. Each example's
64
+ # own lines are kept by the line it is declared on, the line jitcatch
65
+ # reports for it.
66
+ HOLDFAST_EXAMPLES = {}
67
+ module HoldfastRSpecExample
68
+ def run(*)
69
+ holdfast_take
70
+ super
71
+ ensure
72
+ HOLDFAST_EXAMPLES[metadata[:line_number]] = holdfast_take
73
+ end
74
+ end
75
+ module HoldfastMinitestTest
76
+ def run(*)
77
+ holdfast_take
78
+ super
79
+ ensure
80
+ line = (method(name).source_location&.last rescue nil)
81
+ HOLDFAST_EXAMPLES[line] = holdfast_take if line
82
+ end
83
+ end
84
+ holdfast_hook = TracePoint.new(:end) do |tp|
85
+ if defined?(RSpec::Core::Example) && !RSpec::Core::Example.include?(HoldfastRSpecExample)
86
+ RSpec::Core::Example.prepend(HoldfastRSpecExample)
87
+ tp.disable
88
+ elsif defined?(Minitest::Test) && !Minitest::Test.include?(HoldfastMinitestTest)
89
+ Minitest::Test.prepend(HoldfastMinitestTest)
90
+ tp.disable
91
+ end
92
+ end
93
+ holdfast_hook.enable
94
+
95
+ at_exit do
96
+ dir = ENV["HOLDFAST_REACH_DIR"]
97
+ test = ARGV.reverse.find { _1.include?(".jitcatch/") }
98
+ next unless dir && test
99
+
100
+ holdfast_take
101
+ record = { "side" => ENV["TEST_ENV_NUMBER"], "test" => test.sub(%r{\A.*?\.jitcatch/}, ""), "hits" => HOLDFAST_TOTAL["hits"],
102
+ "drew" => HOLDFAST_DREW, "arms" => HOLDFAST_TOTAL["arms"], "examples" => HOLDFAST_EXAMPLES.compact }
103
+ File.write(File.join(dir, "reach-#{ENV["TEST_ENV_NUMBER"]}-#{Process.pid}.json"), JSON.generate(record))
104
+ end