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.
data/lib/verity.rb ADDED
@@ -0,0 +1,614 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verity/version"
4
+ require_relative "verity/fingerprint"
5
+ require_relative "verity/reporter"
6
+ require_relative "verity/reporters/parallel_summary_reporter"
7
+ require_relative "verity/reporters/documentation_reporter"
8
+ require_relative "verity/reporters/dots_reporter"
9
+ require_relative "verity/reporters/colored_dots"
10
+ require_relative "verity/reporters/null_reporter"
11
+ require_relative "verity/reporters/test_reporter"
12
+ require_relative "verity/reporters/composite_reporter"
13
+ require_relative "verity/configuration"
14
+ require_relative "verity/assertions"
15
+ require_relative "verity/manifest"
16
+ require_relative "verity/runner"
17
+
18
+ module Verity
19
+ # Public: Source location of an enclosing `group` block at registration time.
20
+ GroupScope = Data.define(:title, :file, :line)
21
+
22
+ # Public: Immutable value object representing a single registered test case.
23
+ #
24
+ # fingerprint - String content-based identifier for the test body.
25
+ # description - String human-readable name supplied to the `test` DSL.
26
+ # tags - Array of Symbols applied directly to the test.
27
+ # timeout - Numeric seconds (or nil); enforced by Runner with stdlib Timeout.
28
+ # requires - Array of Symbols naming shared preconditions.
29
+ # resources - Hash of keyword resources forwarded from `test`.
30
+ # file - String absolute path of the source file.
31
+ # line - Integer source line number.
32
+ # fn - Proc (block) containing the test body.
33
+ # group_path - Frozen Array of Strings representing nested group titles.
34
+ # inherited_group_tags - Frozen Array of Symbols from enclosing group tags.
35
+ # group_scopes - Frozen Array of GroupScope (enclosing groups, outer first).
36
+ Test = Data.define(
37
+ :fingerprint, :description, :tags, :timeout, :requires, :resources, :file, :line, :fn,
38
+ :group_path, :inherited_group_tags, :group_scopes
39
+ )
40
+
41
+ # Public: Compute all tags that apply to a test, combining enclosing group
42
+ # tags with the test's own tags (outer groups first).
43
+ #
44
+ # test - A Verity::Test instance.
45
+ #
46
+ # Returns an Array of Symbols.
47
+ def self.effective_tags(test)
48
+ Array(test.inherited_group_tags).map(&:to_sym) + Array(test.tags).map(&:to_sym)
49
+ end
50
+
51
+ # Public: Validate a value for Verity::Test#timeout.
52
+ #
53
+ # Allows +nil+ (no limit). Otherwise +timeout+ must be a finite Numeric
54
+ # strictly greater than zero (+Complex+ is rejected).
55
+ #
56
+ # Raises ArgumentError when invalid.
57
+ def self.validate_test_timeout!(timeout)
58
+ return if timeout.nil?
59
+
60
+ unless timeout.is_a?(Numeric) && !timeout.is_a?(Complex)
61
+ raise ArgumentError,
62
+ "test timeout must be nil or a positive finite Numeric (got #{timeout.class}: #{timeout.inspect})"
63
+ end
64
+
65
+ non_finite =
66
+ (timeout.is_a?(Float) && !timeout.finite?) ||
67
+ (timeout.respond_to?(:infinite?) && !timeout.infinite?.nil?)
68
+
69
+ raise ArgumentError, "test timeout must be finite (got #{timeout.inspect})" if non_finite
70
+
71
+ return if timeout > 0
72
+
73
+ raise ArgumentError, "test timeout must be positive (got #{timeout.inspect})"
74
+ end
75
+
76
+ # Public: Check whether a test is tagged with :skip.
77
+ #
78
+ # test - A Verity::Test instance.
79
+ #
80
+ # Returns true if the test should be skipped.
81
+ def self.skipped?(test) = effective_tags(test).include?(:skip)
82
+
83
+ # Public: Check whether a test is tagged with :focus.
84
+ #
85
+ # test - A Verity::Test instance.
86
+ #
87
+ # Returns true if the test has the focus tag.
88
+ def self.focus_tag?(test) = effective_tags(test).include?(:focus)
89
+
90
+ # Public: Collect the tests that should actually execute. Skipped tests are
91
+ # excluded; when any remaining test has :focus, only focused tests are kept.
92
+ #
93
+ # Returns an Array of Verity::Test.
94
+ def self.runnable_tests
95
+ base = Registry.all.reject { skipped?(_1) }
96
+ base = if base.any? { focus_tag?(_1) }
97
+ base.select { focus_tag?(_1) }
98
+ else
99
+ base
100
+ end
101
+ filter_by_location_filters(base)
102
+ end
103
+
104
+ # Public: Detect whether focus filtering narrowed the suite — at least one
105
+ # candidate has :focus and at least one does not.
106
+ #
107
+ # candidates - Array of Verity::Test (already excluding skipped tests).
108
+ #
109
+ # Returns true when the suite is a strict focus-filtered subset.
110
+ def self.focus_filter_active?(candidates)
111
+ return false if candidates.empty?
112
+
113
+ candidates.any? { focus_tag?(_1) } && candidates.any? { !focus_tag?(_1) }
114
+ end
115
+
116
+ # Internal: Keep tests that match any configured location filter (test line
117
+ # or an enclosing group line). Paths compared via File.expand_path.
118
+ #
119
+ # tests - Array of Verity::Test (typically after skip/focus narrowing).
120
+ #
121
+ # Returns a filtered Array.
122
+ def self.filter_by_location_filters(tests)
123
+ filters = configuration.location_filters
124
+ return tests if filters.nil? || filters.empty?
125
+
126
+ matched = tests.select { |t| filters.any? { |pair| location_filter_match?(t, pair[0], pair[1]) } }
127
+ if matched.empty? && !tests.empty?
128
+ hint = filters.map { |p, l| "#{File.expand_path(p)}:#{l}" }.join(", ")
129
+ warn "verity: no tests matched location filter (#{hint})"
130
+ end
131
+ matched
132
+ end
133
+
134
+ # Internal: True if (path, line) is this test's `test` line or a GroupScope line.
135
+ def self.location_filter_match?(test, path, line)
136
+ exp = File.expand_path(path)
137
+ return true if File.expand_path(test.file) == exp && test.line == line
138
+
139
+ test.group_scopes.any? { |g| File.expand_path(g.file) == exp && g.line == line }
140
+ end
141
+
142
+ # Internal: Push a group frame onto the current thread's group stack.
143
+ # Called by DSL#group during test file loading.
144
+ #
145
+ # title - String title for the group.
146
+ # tags - Array of Symbols (default []).
147
+ # file - String absolute path of the `group` call site.
148
+ # line - Integer line of the `group` call.
149
+ #
150
+ # Returns the updated stack Array.
151
+ def self.push_group(title, tags:, file:, line:)
152
+ entry = {
153
+ title: title.to_s,
154
+ tags: Array(tags).map(&:to_sym),
155
+ file: file,
156
+ line: line
157
+ }
158
+ (Thread.current[:verity_group_stack] ||= []) << entry
159
+ end
160
+
161
+ # Internal: Pop the most recent group frame from the current thread's stack.
162
+ #
163
+ # Returns the removed Hash entry, or nil.
164
+ def self.pop_group
165
+ Thread.current[:verity_group_stack]&.pop
166
+ end
167
+
168
+ # Internal: Snapshot of nested group titles for the current thread, used
169
+ # at registration time to capture a test's group ancestry.
170
+ #
171
+ # Returns a frozen Array of Strings.
172
+ def self.group_path_for_registration
173
+ stack = Thread.current[:verity_group_stack]
174
+ return [].freeze if stack.nil? || stack.empty?
175
+
176
+ stack.map { _1[:title] }.freeze
177
+ end
178
+
179
+ # Internal: Enclosing group source scopes for the current thread (outer first).
180
+ #
181
+ # Returns a frozen Array of GroupScope.
182
+ def self.group_scopes_for_registration
183
+ stack = Thread.current[:verity_group_stack]
184
+ return [].freeze if stack.nil? || stack.empty?
185
+
186
+ stack.map { |g| GroupScope.new(g[:title], g[:file], g[:line]) }.freeze
187
+ end
188
+
189
+ # Internal: Collect all tags from enclosing groups for the current thread,
190
+ # flattened in nesting order (outermost first).
191
+ #
192
+ # Returns a frozen Array of Symbols.
193
+ def self.inherited_group_tags_for_registration
194
+ stack = Thread.current[:verity_group_stack]
195
+ return [].freeze if stack.nil? || stack.empty?
196
+
197
+ stack.flat_map { |g| g[:tags] }.freeze
198
+ end
199
+
200
+ # Internal: Reset the current thread's group stack to empty. Called before
201
+ # loading each test file to prevent cross-file leakage.
202
+ #
203
+ # Returns an empty Array.
204
+ def self.clear_group_stack!
205
+ Thread.current[:verity_group_stack] = []
206
+ end
207
+
208
+ # Public: Resolve a reporter instance from a CLI or config string.
209
+ #
210
+ # Built-in names (case-insensitive): "documentation" ("doc"), "colored"
211
+ # ("colored_dots"), "dots", "null" ("none", "silent"). Custom reporters
212
+ # use the form "path/to/reporter.rb:ClassName".
213
+ #
214
+ # spec - String reporter name or "path:ClassName" pair.
215
+ #
216
+ # Examples
217
+ #
218
+ # Verity.build_reporter("dots")
219
+ # # => #<Verity::Reporters::DotsReporter ...>
220
+ #
221
+ # Verity.build_reporter("./my_reporter.rb:MyReporter")
222
+ # # => #<MyReporter ...>
223
+ #
224
+ # Returns an Object that includes Verity::Reporter.
225
+ # Raises ArgumentError if the spec is blank or unrecognised.
226
+ def self.build_reporter(spec)
227
+ raise ArgumentError, "reporter name cannot be blank" if spec.nil? || spec.strip.empty?
228
+
229
+ case spec.strip.downcase
230
+ when "documentation", "doc"
231
+ Reporters::DocumentationReporter.new($stdout)
232
+ when "colored", "colored_dots"
233
+ Reporters::ColoredDotsReporter.new($stdout)
234
+ when "dots"
235
+ Reporters::DotsReporter.new($stdout)
236
+ when "null", "none", "silent"
237
+ Reporters::NullReporter.new
238
+ else
239
+ reporter_from_path_and_class(spec.strip)
240
+ end
241
+ end
242
+
243
+ def self.reporter_from_path_and_class(spec)
244
+ raise ArgumentError, build_reporter_unknown_message(spec) unless spec.include?(":")
245
+
246
+ path, cname = spec.split(":", 2)
247
+ path = path.strip
248
+ cname = cname.strip
249
+ if path.empty? || cname.empty?
250
+ raise ArgumentError, "custom reporter must be path/to.rb:ClassName (got #{spec.inspect})"
251
+ end
252
+
253
+ abs = File.expand_path(path)
254
+ unless File.file?(abs)
255
+ raise ArgumentError, "reporter file not found: #{abs}"
256
+ end
257
+
258
+ load abs
259
+ cls = constantize_reporter_class(cname)
260
+ unless cls.is_a?(Class) && cls.included_modules.include?(Reporter)
261
+ raise ArgumentError, "#{cname} must be a class that includes Verity::Reporter (got #{cls.class})"
262
+ end
263
+
264
+ cls.new
265
+ end
266
+ private_class_method :reporter_from_path_and_class
267
+
268
+ def self.constantize_reporter_class(cname)
269
+ parts = cname.split("::")
270
+ parts.shift if parts.first&.empty?
271
+ raise ArgumentError, "invalid reporter class name #{cname.inspect}" if parts.empty? || parts.any?(&:empty?)
272
+
273
+ parts.reduce(Object) { |mod, part| mod.const_get(part, false) }
274
+ end
275
+ private_class_method :constantize_reporter_class
276
+
277
+ def self.build_reporter_unknown_message(spec)
278
+ "unknown reporter #{spec.inspect}; use documentation, colored, dots, null, or path/to.rb:ClassName"
279
+ end
280
+ private_class_method :build_reporter_unknown_message
281
+
282
+ # Internal: Global test registry. Tests are appended during file loading
283
+ # and queried at run time by the Runner and manifest.
284
+ module Registry
285
+ @tests = []
286
+
287
+ # Internal: Add a test to the global registry.
288
+ #
289
+ # test - A Verity::Test instance.
290
+ #
291
+ # Returns the updated Array.
292
+ def self.register(test) = @tests << test
293
+
294
+ # Internal: Return a shallow copy of all registered tests.
295
+ #
296
+ # Returns an Array of Verity::Test.
297
+ def self.all = @tests.dup
298
+
299
+ # Internal: Remove every registered test. Used before re-loading files.
300
+ #
301
+ # Returns an empty Array.
302
+ def self.clear = @tests.clear
303
+
304
+ # Internal: Look up a test by its fingerprint string.
305
+ #
306
+ # fingerprint - String fingerprint to match.
307
+ #
308
+ # Returns a Verity::Test or nil.
309
+ def self.find(fingerprint) = @tests.find { |t| t.fingerprint == fingerprint }
310
+ end
311
+
312
+ # Public: Methods mixed into Object so that `test` and `group` are
313
+ # available at the top level in test files.
314
+ module DSL
315
+ include Assertions
316
+
317
+ # Public: Define a named group of tests. Groups may be nested and
318
+ # contribute tags that are inherited by every enclosed test.
319
+ #
320
+ # title - String group name shown in reporter output.
321
+ # tags - Array of Symbols applied to all tests in this group (default []).
322
+ # block - Block containing nested `test` and `group` calls.
323
+ #
324
+ # Raises ArgumentError if no block is given.
325
+ def group(title, tags: [], &block)
326
+ raise ArgumentError, "`group` requires a block" unless block
327
+
328
+ loc = caller_locations(1, 1).first
329
+ pushed = false
330
+ Verity.push_group(title, tags: tags, file: loc.path, line: loc.lineno)
331
+ pushed = true
332
+ yield
333
+ ensure
334
+ Verity.pop_group if pushed
335
+ end
336
+
337
+ # Public: Register a single test case. The block is stored and executed
338
+ # later by the Runner.
339
+ #
340
+ # description - String human-readable test name.
341
+ # tags - Array of Symbols (e.g. :focus, :skip) (default []).
342
+ # timeout - Numeric seconds or nil for no timeout (default nil). If set,
343
+ # must be a positive finite Numeric (+Complex+ and strings are rejected).
344
+ # requires - Array of Symbols naming shared preconditions (default []).
345
+ # resources - Hash of keyword arguments forwarded as resource metadata.
346
+ # fn - Block containing assertions and test logic.
347
+ #
348
+ # Returns the newly registered Verity::Test.
349
+ def test(description, tags: [], timeout: nil, requires: [], **resources, &fn)
350
+ Verity.validate_test_timeout!(timeout)
351
+ location = caller_locations(1, 1).first
352
+ file = location.path
353
+ line = location.lineno
354
+ fingerprint = Verity::Fingerprint.lookup(line) || Verity::Fingerprint.fallback_fingerprint(file, line)
355
+
356
+ Verity::Registry.register(
357
+ Verity::Test.new(
358
+ fingerprint:,
359
+ description:,
360
+ tags:,
361
+ timeout:,
362
+ requires:,
363
+ resources:,
364
+ file:,
365
+ line:,
366
+ fn:,
367
+ group_path: Verity.group_path_for_registration,
368
+ inherited_group_tags: Verity.inherited_group_tags_for_registration,
369
+ group_scopes: Verity.group_scopes_for_registration
370
+ )
371
+ )
372
+ end
373
+ end
374
+
375
+ # Public: Register a callback invoked once per worker process before any
376
+ # tests run (useful for DB setup, connection pooling, etc.).
377
+ #
378
+ # block - Proc to execute.
379
+ #
380
+ # Returns the updated callback Array.
381
+ def self.before_worker_start(&block) = hooks[:before_worker_start] << block
382
+
383
+ # Public: Register a callback invoked before each individual test.
384
+ #
385
+ # block - Proc to execute.
386
+ #
387
+ # Returns the updated callback Array.
388
+ def self.before_test(&block) = hooks[:before_test] << block
389
+
390
+ # Public: Register a callback invoked after each individual test.
391
+ #
392
+ # block - Proc to execute.
393
+ #
394
+ # Returns the updated callback Array.
395
+ def self.after_test(&block) = hooks[:after_test] << block
396
+
397
+ # Public: Declare a named resource with conflict rules for parallel
398
+ # scheduling.
399
+ #
400
+ # name - Symbol resource name.
401
+ # conflicts_with - Conflict specification stored for the scheduler.
402
+ #
403
+ # Returns the updated resolvers Hash.
404
+ def self.register_resource(name, conflicts_with:)
405
+ resource_resolvers[name] = conflicts_with
406
+ end
407
+
408
+ # Public: Build the set of test fingerprints that must not be claimed because
409
+ # they conflict with at least one currently-running test's resources.
410
+ #
411
+ # running_resources - Array of resource Hashes (string keys/values) from
412
+ # Manifest#running_resources.
413
+ # tests - Array of Verity::Test to check (default: Registry.all).
414
+ #
415
+ # Returns an Array of fingerprint Strings. Returns [] when no resolvers are
416
+ # registered or running_resources is empty (bypass fast-path).
417
+ def self.conflict_exclusion_list(running_resources, tests: Registry.all)
418
+ return [] if resource_resolvers.empty? || running_resources.empty?
419
+
420
+ tests.select { |t| test_conflicts_with_running?(t.resources, running_resources) }
421
+ .map(&:fingerprint)
422
+ end
423
+
424
+ def self.test_conflicts_with_running?(test_resources, running_resources)
425
+ resource_resolvers.any? do |resource_type, resolver|
426
+ mine = normalize_resource_value(
427
+ test_resources[resource_type] || test_resources[resource_type.to_s]
428
+ )
429
+ next false if mine.nil? || mine.empty?
430
+
431
+ running_resources.any? do |running_res|
432
+ theirs = normalize_resource_value(
433
+ running_res[resource_type.to_s] || running_res[resource_type]
434
+ )
435
+ next false if theirs.nil? || theirs.empty?
436
+
437
+ resolver.call(mine, theirs)
438
+ end
439
+ end
440
+ end
441
+ private_class_method :test_conflicts_with_running?
442
+
443
+ def self.normalize_resource_value(val)
444
+ return nil if val.nil? || val == false
445
+
446
+ Array(val).map(&:to_s)
447
+ end
448
+ private_class_method :normalize_resource_value
449
+
450
+ # Internal: Lazily-initialised Hash of lifecycle hook Arrays keyed by
451
+ # :before_worker_start, :before_test, and :after_test.
452
+ #
453
+ # Returns a Hash.
454
+ def self.hooks
455
+ @hooks ||= { before_worker_start: [], before_test: [], after_test: [] }
456
+ end
457
+
458
+ # Internal: Lazily-initialised Hash mapping resource names to their
459
+ # conflict specifications.
460
+ #
461
+ # Returns a Hash.
462
+ def self.resource_resolvers
463
+ @resource_resolvers ||= {}
464
+ end
465
+
466
+ # Public: Discover and load all test files according to Configuration#test_globs.
467
+ # Clears the registry, installs fingerprint plans, and loads each file.
468
+ #
469
+ # Returns nothing meaningful.
470
+ def self.load_discovery!
471
+ Registry.clear
472
+ configuration.test_files.each do |path|
473
+ clear_group_stack!
474
+ abs = File.expand_path(path)
475
+ Verity::Fingerprint.install_plan!(abs)
476
+ begin
477
+ load abs
478
+ ensure
479
+ Verity::Fingerprint.clear_plan!
480
+ end
481
+ end
482
+ end
483
+
484
+ # Internal: Runnable tests in coordinator dispatch order for the manifest
485
+ # (fingerprint sort or random shuffle). Shuffle is used when test_order is
486
+ # :random or when shuffle_seed is set. Auto-chosen seeds are printed to stderr
487
+ # as a single line (the integer only).
488
+ #
489
+ # Returns Array of Verity::Test.
490
+ def self.ordered_runnable_tests
491
+ list = runnable_tests
492
+ order = configuration.test_order
493
+ unless %i[fingerprint random].include?(order)
494
+ raise ArgumentError,
495
+ "test_order must be :fingerprint or :random (got #{order.inspect})"
496
+ end
497
+
498
+ rng_seed = configuration.shuffle_seed
499
+ use_random = order == :random || !rng_seed.nil?
500
+
501
+ if use_random
502
+ s = rng_seed
503
+ if s.nil?
504
+ s = Random.new_seed
505
+ configuration.shuffle_seed = s
506
+ warn s.to_s
507
+ end
508
+ list.shuffle(random: Random.new(s))
509
+ else
510
+ list.sort_by(&:fingerprint)
511
+ end
512
+ end
513
+ private_class_method :ordered_runnable_tests
514
+
515
+ # Public: Main entry point — discover tests, set up the manifest, and
516
+ # execute. When worker_count > 1 the run forks child processes that each
517
+ # claim work from a shared SQLite manifest.
518
+ #
519
+ # worker_id - Integer base worker id for single-process mode (default 0).
520
+ #
521
+ # Returns true if every test passed, false otherwise.
522
+ # Raises ArgumentError if parallel mode uses a :memory: manifest.
523
+ # Raises NotImplementedError if fork is unavailable for parallel mode.
524
+ def self.run(worker_id: 0)
525
+ load_discovery!
526
+
527
+ workers = configuration.resolved_worker_count
528
+
529
+ path = configuration.manifest_path
530
+ if workers > 1
531
+ if configuration.memory_manifest?
532
+ raise ArgumentError,
533
+ "manifest_path cannot be :memory: when worker_count > 1 (SQLite memory DBs are not shared across processes)"
534
+ end
535
+ unless Process.respond_to?(:fork)
536
+ raise NotImplementedError, "Parallel workers require Kernel#fork (not available on this platform)"
537
+ end
538
+
539
+ sync_manifest!(path)
540
+ pids = workers.times.map do |wid|
541
+ fork do
542
+ Verity.send(:run_manifest_child, path, worker_id: wid)
543
+ end
544
+ end
545
+ ok = pids.all? do |pid|
546
+ _, status = Process.wait2(pid)
547
+ status.success?
548
+ end
549
+
550
+ manifest = Manifest.open(path)
551
+ begin
552
+ abandoned = manifest.reclaim_abandoned_running!
553
+ ok &&= abandoned.zero?
554
+
555
+ rep = configuration.reporter
556
+ rep.on_run_start(total: manifest.example_count, worker_id: 0)
557
+
558
+ manifest.each_parallel_replay_result do |result|
559
+ rep.on_test_complete(result: result, worker_id: 0)
560
+ end
561
+
562
+ Registry.all.select { skipped?(_1) }.sort_by(&:fingerprint).each do |t|
563
+ rep.on_test_complete(
564
+ result: Runner::Result.new(test: t, status: :skip, error: nil),
565
+ worker_id: 0
566
+ )
567
+ end
568
+
569
+ skip_count = Registry.all.count { skipped?(_1) }
570
+ counts = manifest.count_by_status.merge("skipped" => skip_count)
571
+ problem_rows = manifest.failures_for_report
572
+ rep.on_parallel_complete(counts: counts, problem_rows: problem_rows)
573
+ ensure
574
+ manifest.close
575
+ end
576
+
577
+ ok
578
+ else
579
+ manifest = Manifest.open(path)
580
+ begin
581
+ manifest.migrate!
582
+ manifest.replace_tests(ordered_runnable_tests)
583
+ Runner.new.run_manifest(manifest, worker_id:)
584
+ ensure
585
+ manifest.close
586
+ end
587
+ end
588
+ end
589
+
590
+ def self.sync_manifest!(path)
591
+ manifest = Manifest.open(path)
592
+ begin
593
+ manifest.migrate!
594
+ manifest.replace_tests(ordered_runnable_tests)
595
+ ensure
596
+ manifest.close
597
+ end
598
+ end
599
+ private_class_method :sync_manifest!
600
+
601
+ def self.run_manifest_child(path, worker_id:)
602
+ manifest = Manifest.open(path)
603
+ ok = false
604
+ begin
605
+ ok = Runner.new(reporter: Reporters::NullReporter.new).run_manifest(manifest, worker_id:)
606
+ ensure
607
+ manifest.close
608
+ end
609
+ exit(ok ? 0 : 1)
610
+ end
611
+ private_class_method :run_manifest_child
612
+ end
613
+
614
+ Object.include(Verity::DSL)