verity 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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pp"
4
+
5
+ module Verity
6
+ # Public: Raised when an assertion fails. Distinct from unexpected exceptions
7
+ # so the runner can differentiate assertion failures from errors.
8
+ class AssertionError < StandardError; end
9
+
10
+ # Public: Raised when a test body runs longer than its `timeout:` (see DSL).
11
+ class TestTimeoutError < StandardError; end
12
+
13
+ # Public: Assertion methods mixed into the DSL. Every assertion has a
14
+ # corresponding `refute_*` negation. All accept an optional `message:`
15
+ # keyword that overrides the default failure text.
16
+ module Assertions
17
+ # Public: Assert that a value is truthy.
18
+ #
19
+ # check - The value to test.
20
+ # message - Optional String or Proc failure message.
21
+ #
22
+ # Raises AssertionError if check is falsy.
23
+ def assert(check, message: nil)
24
+ return if check
25
+ fail_assertion(message) { "Expected truthy but got #{check.inspect}" }
26
+ end
27
+
28
+ # Public: Assert that a value is falsy.
29
+ #
30
+ # check - The value to test.
31
+ # message - Optional String or Proc failure message.
32
+ #
33
+ # Raises AssertionError if check is truthy.
34
+ def refute(check, message: nil)
35
+ return unless check
36
+ fail_assertion(message) { "Expected falsy but got #{check.inspect}" }
37
+ end
38
+
39
+ # Public: Assert that a value is nil.
40
+ #
41
+ # actual - The value to test.
42
+ # message - Optional String or Proc failure message.
43
+ #
44
+ # Raises AssertionError when actual is not nil.
45
+ def assert_nil(actual, message: nil)
46
+ return if actual.nil?
47
+ fail_assertion(message) { "Expected nil but got #{actual.inspect}" }
48
+ end
49
+
50
+ # Public: Assert that a value is not nil.
51
+ #
52
+ # actual - The value to test.
53
+ # message - Optional String or Proc failure message.
54
+ #
55
+ # Raises AssertionError when actual is nil.
56
+ def refute_nil(actual, message: nil)
57
+ return unless actual.nil?
58
+ fail_assertion(message) { "Expected non-nil but got nil" }
59
+ end
60
+
61
+ # Public: Assert that two values are equal using `==`.
62
+ #
63
+ # actual - The value produced by the code under test.
64
+ # expected - The reference value.
65
+ # message - Optional String or Proc failure message.
66
+ #
67
+ # Raises AssertionError when actual != expected.
68
+ def assert_equal(actual:, expected:, message: nil)
69
+ return if actual == expected
70
+ fail_assertion(message) { "Expected values to be equal\n#{format_diff(actual, expected)}" }
71
+ end
72
+
73
+ # Public: Assert that two values are NOT equal using `==`.
74
+ #
75
+ # actual - The value produced by the code under test.
76
+ # expected - The reference value that should differ.
77
+ # message - Optional String or Proc failure message.
78
+ #
79
+ # Raises AssertionError when actual == expected.
80
+ def refute_equal(actual:, expected:, message: nil)
81
+ return unless actual == expected
82
+ fail_assertion(message) { "Expected values to differ, but both were #{actual.inspect}" }
83
+ end
84
+
85
+ # Public: Assert that two references point to the same object (identity
86
+ # via `equal?`).
87
+ #
88
+ # actual - The object produced by the code under test.
89
+ # expected - The exact object expected.
90
+ # message - Optional String or Proc failure message.
91
+ #
92
+ # Raises AssertionError when the objects are not identical.
93
+ def assert_same(actual:, expected:, message: nil)
94
+ return if actual.equal?(expected)
95
+ fail_assertion(message) do
96
+ "Expected same object\n" \
97
+ " actual: #{actual.inspect} (object_id: #{actual.object_id})\n" \
98
+ " expected: #{expected.inspect} (object_id: #{expected.object_id})"
99
+ end
100
+ end
101
+
102
+ # Public: Assert that two references are NOT the same object.
103
+ #
104
+ # actual - The object produced by the code under test.
105
+ # expected - The object that should be a different instance.
106
+ # message - Optional String or Proc failure message.
107
+ #
108
+ # Raises AssertionError when the objects are identical.
109
+ def refute_same(actual:, expected:, message: nil)
110
+ return unless actual.equal?(expected)
111
+ fail_assertion(message) do
112
+ "Expected different objects, both were #{actual.inspect} (object_id: #{actual.object_id})"
113
+ end
114
+ end
115
+
116
+ # Public: Assert that the block raises one of the given exception classes.
117
+ # Optionally verify the error message matches a pattern.
118
+ #
119
+ # error_classes - One or more Exception subclasses expected.
120
+ # match - String or Regexp matched against the error message (default nil).
121
+ # message - Optional String or Proc failure message.
122
+ # block - Block expected to raise.
123
+ #
124
+ # Examples
125
+ #
126
+ # assert_raises(ArgumentError) { Integer("nope") }
127
+ # # => #<ArgumentError: ...>
128
+ #
129
+ # Returns the caught exception on success.
130
+ # Raises ArgumentError if no error classes are given.
131
+ # Raises AssertionError if the block does not raise as expected.
132
+ def assert_raises(*error_classes, match: nil, message: nil, &block)
133
+ raise ArgumentError, "assert_raises requires at least one error class" if error_classes.empty?
134
+
135
+ begin
136
+ block.call
137
+ rescue *error_classes => e
138
+ if match
139
+ satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
140
+ unless satisfied
141
+ fail_assertion(message) do
142
+ "#{e.class} raised but message #{e.message.inspect} did not match #{match.inspect}"
143
+ end
144
+ end
145
+ end
146
+ return e
147
+ rescue => e
148
+ fail_assertion(message) do
149
+ "Expected #{error_class_names(error_classes)} but #{e.class} was raised: #{e.message}"
150
+ end
151
+ end
152
+
153
+ fail_assertion(message) { "Expected #{error_class_names(error_classes)} but nothing was raised" }
154
+ end
155
+
156
+ # Public: Assert that the block does NOT raise the specified exceptions.
157
+ # When no error classes are given, asserts that nothing is raised at all.
158
+ # When a match pattern is provided, only matching messages trigger failure.
159
+ #
160
+ # error_classes - Zero or more Exception subclasses that must not be raised.
161
+ # match - String or Regexp; only messages matching this cause failure (default nil).
162
+ # message - Optional String or Proc failure message.
163
+ # block - Block to execute.
164
+ #
165
+ # Raises AssertionError if the block raises a matching exception.
166
+ def refute_raises(*error_classes, match: nil, message: nil, &block)
167
+ if error_classes.empty?
168
+ begin
169
+ block.call
170
+ rescue => e
171
+ if match
172
+ satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
173
+ raise unless satisfied
174
+ fail_assertion(message) do
175
+ "Expected no exception with message matching #{match.inspect}, " \
176
+ "but #{e.class} was raised: #{e.message}"
177
+ end
178
+ else
179
+ fail_assertion(message) { "Expected no exception but #{e.class} was raised: #{e.message}" }
180
+ end
181
+ end
182
+ else
183
+ begin
184
+ block.call
185
+ rescue *error_classes => e
186
+ if match
187
+ satisfied = match.is_a?(Regexp) ? e.message =~ match : e.message.include?(match)
188
+ if satisfied
189
+ fail_assertion(message) do
190
+ "#{e.class} raised with message matching #{match.inspect}: #{e.message}"
191
+ end
192
+ end
193
+ else
194
+ fail_assertion(message) do
195
+ "Expected #{error_class_names(error_classes)} not to be raised, " \
196
+ "but #{e.class} was raised: #{e.message}"
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ # Public: Assert that a numeric value is within delta of the expected value.
204
+ # Internally adjusts for floating-point epsilon scaling.
205
+ #
206
+ # expected - Numeric reference value.
207
+ # actual - Numeric value under test.
208
+ # delta - Numeric maximum allowed difference.
209
+ # message - Optional String or Proc failure message.
210
+ #
211
+ # Raises AssertionError when the difference exceeds delta.
212
+ def assert_in_delta(expected:, actual:, delta:, message: nil)
213
+ return if within_delta?(actual, expected, delta)
214
+ fail_assertion(message) do
215
+ "Expected #{actual.inspect} to be within #{delta} of #{expected.inspect}, " \
216
+ "difference was #{(actual - expected).abs}"
217
+ end
218
+ end
219
+
220
+ # Public: Assert that a numeric value is NOT within delta of the expected value.
221
+ #
222
+ # expected - Numeric reference value.
223
+ # actual - Numeric value under test.
224
+ # delta - Numeric maximum allowed difference.
225
+ # message - Optional String or Proc failure message.
226
+ #
227
+ # Raises AssertionError when the difference is within delta.
228
+ def refute_in_delta(expected:, actual:, delta:, message: nil)
229
+ return unless within_delta?(actual, expected, delta)
230
+ fail_assertion(message) do
231
+ "Expected #{actual.inspect} to be outside #{delta} of #{expected.inspect}, " \
232
+ "but difference was #{(actual - expected).abs}"
233
+ end
234
+ end
235
+
236
+ # Public: Assert that a value matches a Regexp or includes a String.
237
+ #
238
+ # pattern - Regexp or String to match against.
239
+ # actual - The value under test.
240
+ # message - Optional String or Proc failure message.
241
+ #
242
+ # Raises AssertionError when actual does not match pattern.
243
+ def assert_match(pattern:, actual:, message: nil)
244
+ return if match?(pattern, actual)
245
+ fail_assertion(message) { "Expected #{actual.inspect} to match #{pattern.inspect}" }
246
+ end
247
+
248
+ # Public: Assert that a value does NOT match a Regexp or include a String.
249
+ #
250
+ # pattern - Regexp or String to match against.
251
+ # actual - The value under test.
252
+ # message - Optional String or Proc failure message.
253
+ #
254
+ # Raises AssertionError when actual matches pattern.
255
+ def refute_match(pattern:, actual:, message: nil)
256
+ return unless match?(pattern, actual)
257
+ fail_assertion(message) { "Expected #{actual.inspect} not to match #{pattern.inspect}" }
258
+ end
259
+
260
+ # Public: Assert that a collection includes the given item.
261
+ #
262
+ # item - The element to look for.
263
+ # collection - An object responding to `include?`.
264
+ # message - Optional String or Proc failure message.
265
+ #
266
+ # Raises AssertionError when item is not found.
267
+ def assert_includes(item:, collection:, message: nil)
268
+ return if collection.include?(item)
269
+ fail_assertion(message) { "Expected collection to include #{item.inspect}" }
270
+ end
271
+
272
+ # Public: Assert that a collection does NOT include the given item.
273
+ #
274
+ # item - The element to look for.
275
+ # collection - An object responding to `include?`.
276
+ # message - Optional String or Proc failure message.
277
+ #
278
+ # Raises AssertionError when item is found.
279
+ def refute_includes(item:, collection:, message: nil)
280
+ return unless collection.include?(item)
281
+ fail_assertion(message) { "Expected collection not to include #{item.inspect}" }
282
+ end
283
+
284
+ private
285
+
286
+ def fail_assertion(custom_message, &default_message)
287
+ msg = custom_message ? resolve_message(custom_message) : default_message.call
288
+ raise Verity::AssertionError, msg
289
+ end
290
+
291
+ def resolve_message(message)
292
+ message.is_a?(Proc) ? message.call : message
293
+ end
294
+
295
+ def within_delta?(actual, expected, delta)
296
+ # Scale epsilon by the magnitude of the operands to account for floating
297
+ # point representation error (e.g. (1.1 - 1.0).abs > 0.1 in IEEE 754).
298
+ tolerance = delta + Float::EPSILON * [actual.abs, expected.abs].max
299
+ (actual - expected).abs <= tolerance
300
+ end
301
+
302
+ def match?(pattern, actual)
303
+ pattern.is_a?(Regexp) ? actual =~ pattern : actual.include?(pattern)
304
+ end
305
+
306
+ def error_class_names(classes)
307
+ classes.map(&:name).join(", ")
308
+ end
309
+
310
+ def format_diff(actual, expected)
311
+ actual_str = PP.pp(actual, +"").chomp
312
+ expected_str = PP.pp(expected, +"").chomp
313
+ " actual: #{actual_str}\n expected: #{expected_str}"
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Verity
6
+ # Public: Holds all user-configurable settings for a Verity test run.
7
+ # Access via `Verity.configuration` or inside a `Verity.configure` block.
8
+ #
9
+ # Examples
10
+ #
11
+ # Verity.configure do |c|
12
+ # c.test_globs = ["test/**/*_test.rb"]
13
+ # c.worker_count = :cpus
14
+ # c.reporter = Verity::Reporters::DotsReporter.new($stdout)
15
+ # end
16
+ class Configuration
17
+ # Public: String path for the SQLite manifest database. Use ":memory:" for
18
+ # an in-process database (single-worker only; cannot be used with parallel
19
+ # workers). Default: "verity/manifest.db" (relative to the process working
20
+ # directory, typically the project root).
21
+ #
22
+ # Public: Array of glob Strings matched against the working directory to
23
+ # discover test files. Default: ["verity/**/*_test.rb"].
24
+ #
25
+ # Public: Integer worker count, or :cpus / "cpus" to auto-detect from
26
+ # Etc.nprocessors. Default: 1.
27
+ #
28
+ # Public: Object implementing the Verity::Reporter interface that receives
29
+ # lifecycle callbacks. Default: ColoredDotsReporter writing to $stdout.
30
+ #
31
+ # Public: Test dispatch order for manifest runs: :random (default; shuffled
32
+ # once in the coordinator) or :fingerprint (sorted). A non-nil #shuffle_seed
33
+ # always implies a shuffle, even if test_order is :fingerprint.
34
+ #
35
+ # Public: Integer RNG seed for shuffled order. When nil and order is random,
36
+ # a seed is chosen, stored here, and printed to stderr (the number only)
37
+ # before workers start.
38
+ #
39
+ # Public: Optional Array of [absolute_path, Integer line] pairs (from CLI
40
+ # file:line). When non-empty, only tests whose #line matches, or that have
41
+ # an enclosing #group opened on that file:line, are runnable.
42
+ attr_accessor :manifest_path, :test_globs, :worker_count, :reporter,
43
+ :test_order, :shuffle_seed, :location_filters
44
+
45
+ def initialize
46
+ set_defaults!
47
+ end
48
+
49
+ def set_defaults!
50
+ @manifest_path = "verity/manifest.db"
51
+ @test_globs = ["verity/**/*_test.rb"]
52
+ @worker_count = :cpus
53
+ @reporter = Verity::Reporters::ColoredDotsReporter.new($stdout)
54
+ @test_order = :random
55
+ @shuffle_seed = nil
56
+ @location_filters = []
57
+ end
58
+
59
+ # Public: Resolve worker_count to an Integer, expanding :cpus to the
60
+ # number of available processors.
61
+ #
62
+ # Returns a positive Integer.
63
+ # Raises ArgumentError if the value cannot be resolved or is less than 1.
64
+ def resolved_worker_count
65
+ n =
66
+ if self.class.cpus_worker_token?(worker_count)
67
+ [Etc.nprocessors, 1].max
68
+ else
69
+ begin
70
+ Integer(worker_count)
71
+ rescue TypeError, ArgumentError
72
+ raise ArgumentError,
73
+ "worker_count must be a positive Integer or :cpus / \"cpus\" (got #{worker_count.inspect})"
74
+ end
75
+ end
76
+ raise ArgumentError, "worker_count must be >= 1 (got #{n})" if n < 1
77
+
78
+ n
79
+ end
80
+
81
+ # Public: Check whether the manifest is configured as in-memory.
82
+ #
83
+ # Returns true when manifest_path is ":memory:".
84
+ def memory_manifest?
85
+ manifest_path == ":memory:"
86
+ end
87
+
88
+ # Public: Expand test_globs into a sorted, deduplicated list of file paths.
89
+ #
90
+ # Returns a sorted Array of String file paths.
91
+ def test_files
92
+ test_globs.flat_map { |pattern| Dir.glob(pattern) }.uniq.sort
93
+ end
94
+
95
+ class << self
96
+ # Internal: Determine whether a value represents the :cpus worker token.
97
+ # Accepts :cpus, :cpu, "cpus", or "cpu" (case-insensitive).
98
+ #
99
+ # value - Symbol or String to check.
100
+ #
101
+ # Returns true if the value is a cpus token.
102
+ def cpus_worker_token?(value)
103
+ case value
104
+ when :cpus, :cpu
105
+ true
106
+ when String
107
+ s = value.strip.downcase
108
+ s == "cpus" || s == "cpu"
109
+ else
110
+ false
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ class << self
117
+ def configuration
118
+ @configuration ||= Configuration.new
119
+ end
120
+
121
+ def configure
122
+ yield configuration
123
+ end
124
+
125
+ def reset_configuration!
126
+ @configuration = Configuration.new
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+ require "prism"
6
+
7
+ module Verity
8
+ # Public: Content-addressed fingerprinting for test bodies. Parses source
9
+ # files with Prism to produce stable identifiers that survive line-number
10
+ # shifts when unrelated code changes, while disambiguating tests with
11
+ # identical bodies by appending the line number.
12
+ module Fingerprint
13
+ HEX_LENGTH = 16
14
+
15
+ class << self
16
+ THREAD_KEY = :__verity_fp_plan__
17
+
18
+ # Internal: Parse the given source file and install its line-to-fingerprint
19
+ # mapping on the current thread. Must be called before loading a test file.
20
+ #
21
+ # absolute_path - String absolute filesystem path to the source file.
22
+ #
23
+ # Returns the plan Hash (line => fingerprint).
24
+ def install_plan!(absolute_path)
25
+ Thread.current[THREAD_KEY] = plan_file(absolute_path)
26
+ end
27
+
28
+ # Internal: Remove the current thread's fingerprint plan. Called after
29
+ # a test file finishes loading.
30
+ #
31
+ # Returns nil.
32
+ def clear_plan!
33
+ Thread.current[THREAD_KEY] = nil
34
+ end
35
+
36
+ # Internal: Look up the fingerprint for a source line from the current
37
+ # thread's installed plan.
38
+ #
39
+ # line - Integer source line number.
40
+ #
41
+ # Returns a String fingerprint, or nil if no plan is active or the line
42
+ # has no entry.
43
+ def lookup(line)
44
+ Thread.current[THREAD_KEY]&.[](line)
45
+ end
46
+
47
+ # Public: Generate a location-based fingerprint when the Prism plan
48
+ # does not cover a given line (e.g. dynamically generated tests).
49
+ #
50
+ # file - String file path.
51
+ # line - Integer line number.
52
+ #
53
+ # Returns a String in the form "relative/path:hex".
54
+ def fallback_fingerprint(file, line)
55
+ rel = relative_source_path(file)
56
+ sha = Digest::SHA1.hexdigest("#{file}:#{line}")[0, HEX_LENGTH]
57
+ "#{rel}:#{sha}"
58
+ end
59
+
60
+ # Internal: Parse a source file and build a Hash mapping each `test`
61
+ # call's line number to a content-addressed fingerprint string.
62
+ # Duplicate body hashes within the same file are disambiguated by
63
+ # appending the line number.
64
+ #
65
+ # absolute_path - String absolute path to the Ruby source file.
66
+ #
67
+ # Returns a Hash { Integer => String }.
68
+ def plan_file(absolute_path)
69
+ source = File.read(absolute_path, encoding: "UTF-8")
70
+ result = Prism.parse(source, filepath: File.expand_path(absolute_path))
71
+ return {} unless result.success?
72
+
73
+ program = result.value
74
+ rows = []
75
+ each_load_time_test(program) do |call|
76
+ body = call.block.body
77
+ canon = canonical(body)
78
+ body_hex = Digest::SHA1.hexdigest(canon)[0, HEX_LENGTH]
79
+ rows << { line: call.location.start_line, body_hex: body_hex }
80
+ end
81
+
82
+ relative = relative_source_path(absolute_path)
83
+ by_hex = rows.group_by { _1[:body_hex] }
84
+ plan = {}
85
+ rows.each do |row|
86
+ line = row[:line]
87
+ body_hex = row[:body_hex]
88
+ plan[line] =
89
+ if by_hex[body_hex].length > 1
90
+ "#{relative}:#{body_hex}:#{line}"
91
+ else
92
+ "#{relative}:#{body_hex}"
93
+ end
94
+ end
95
+ plan
96
+ end
97
+
98
+ def derive_method_suffix(fingerprint)
99
+ parts = fingerprint.split(":")
100
+ hex =
101
+ if parts.size >= 3 && parts.last.match?(/\A\d+\z/)
102
+ parts[-2]
103
+ else
104
+ parts[-1]
105
+ end
106
+ raise ArgumentError, "invalid fingerprint (expected ...:#{HEX_LENGTH} hex chars): #{fingerprint}" unless /\A[a-f0-9]{#{HEX_LENGTH}}\z/.match?(hex)
107
+
108
+ hex
109
+ end
110
+
111
+ private
112
+
113
+ def relative_source_path(absolute_path)
114
+ abs = File.expand_path(absolute_path)
115
+ Pathname(abs).relative_path_from(Pathname(Dir.pwd)).to_s
116
+ rescue ArgumentError
117
+ File.basename(abs)
118
+ end
119
+
120
+ def each_load_time_test(node, inside_block = false, &block)
121
+ return unless node.is_a?(Prism::Node)
122
+
123
+ if node.is_a?(Prism::CallNode) && !inside_block && verity_test_call?(node)
124
+ block.call(node)
125
+ end
126
+
127
+ node.compact_child_nodes.each do |child|
128
+ deeper = inside_block
129
+ deeper = true if node.is_a?(Prism::CallNode) && child.is_a?(Prism::BlockNode)
130
+ each_load_time_test(child, deeper, &block)
131
+ end
132
+ end
133
+
134
+ def verity_test_call?(node)
135
+ node.is_a?(Prism::CallNode) && node.name == :test && node.receiver.nil? && node.block
136
+ end
137
+
138
+ def canonical(node)
139
+ return "" if node.nil?
140
+
141
+ if node.is_a?(Prism::StatementsNode)
142
+ return node.body.map { canonical(_1) }.join(";")
143
+ end
144
+
145
+ if node.is_a?(Prism::Node)
146
+ label = node.class.name.split("::").last
147
+ inner = node.compact_child_nodes.map { canonical(_1) }.join(" ")
148
+ return "(#{label} #{inner})"
149
+ end
150
+
151
+ raise ArgumentError, "unexpected node: #{node.class}"
152
+ end
153
+ end
154
+ end
155
+ end