hegeltest 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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +264 -0
  6. data/Rakefile +19 -0
  7. data/docs/README.md +25 -0
  8. data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
  9. data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
  10. data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
  11. data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
  12. data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
  13. data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
  14. data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
  15. data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
  16. data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
  17. data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
  18. data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
  19. data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
  20. data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
  21. data/docs/architecture.md +182 -0
  22. data/lib/hegel/draw_name.rb +109 -0
  23. data/lib/hegel/errors.rb +47 -0
  24. data/lib/hegel/generator.rb +98 -0
  25. data/lib/hegel/generators.rb +865 -0
  26. data/lib/hegel/lib_hegel/real.rb +1149 -0
  27. data/lib/hegel/lib_hegel.rb +269 -0
  28. data/lib/hegel/libhegel_version.rb +9 -0
  29. data/lib/hegel/locate.rb +188 -0
  30. data/lib/hegel/report.rb +87 -0
  31. data/lib/hegel/runner.rb +464 -0
  32. data/lib/hegel/settings.rb +164 -0
  33. data/lib/hegel/state_machine.rb +89 -0
  34. data/lib/hegel/stateful/pool.rb +111 -0
  35. data/lib/hegel/stateful.rb +120 -0
  36. data/lib/hegel/syntax/methods.rb +173 -0
  37. data/lib/hegel/test_case.rb +523 -0
  38. data/lib/hegel/version.rb +5 -0
  39. data/lib/hegel.rb +92 -0
  40. data/lib/hegeltest.rb +7 -0
  41. data/lib/tasks/libhegel.rake +112 -0
  42. data/lib/tasks/platform_gems.rake +111 -0
  43. data/sig/hegel.rbs +563 -0
  44. data/skills/hegel-ruby/SKILL.md +30 -0
  45. data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
  46. metadata +113 -0
@@ -0,0 +1,464 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "lib_hegel"
5
+ require_relative "report"
6
+ require_relative "settings"
7
+ require_relative "test_case"
8
+
9
+ module Hegel
10
+ # Drives one Hegel.test run: this is the Ruby side of the per-test-case
11
+ # lifecycle hegel-rust's src/run_lifecycle.rs calls `drive`. Ruby owns the
12
+ # loop; libhegel owns generation, shrinking, and (on failure) database
13
+ # replay, so a user's test body never has to cross into native code itself.
14
+ module Runner
15
+ # #origin_for's fallback when an exception's backtrace has no first
16
+ # location to build a real origin from. Named the way hegel-rust names
17
+ # its own equivalent constant, "Panic at <unknown>", for a panic with no
18
+ # location.
19
+ UNKNOWN_ORIGIN = "Raised at <unknown>"
20
+
21
+ # #infrastructure? treats a backtrace path as this library's own when it
22
+ # falls under this directory. It is lib/, one above runner.rb's own
23
+ # __dir__, so that hegel.rb and hegeltest.rb count too: Hegel.test lives
24
+ # in hegel.rb, and its frame sits below every frame a property body
25
+ # raises through. A property can be defined inside some other gem, such
26
+ # as a shared test-helper gem calling Hegel.test on its caller's
27
+ # behalf. That would otherwise leave hegel.rb as the first frame
28
+ # belonging to nobody, and name this library as the origin of the
29
+ # caller's own bug.
30
+ #
31
+ # A trailing separator makes the prefix match a directory boundary
32
+ # rather than a string prefix, so a sibling directory that merely
33
+ # starts with the same characters (lib/hegelx/) cannot match.
34
+ LIBRARY_DIR = File.expand_path("..", __dir__) + File::SEPARATOR
35
+
36
+ # Every directory an installed gem's own files live under. Gem.path lists
37
+ # each root RubyGems searches (the default gem home plus $GEM_PATH), and
38
+ # each root keeps installed gems under a "gems/" subdirectory -- the
39
+ # structure a test framework such as minitest or rspec-expectations is
40
+ # installed into. A caller's own code loaded via a Gemfile `path:` or
41
+ # `git:` entry lives in the working copy instead, so this rule cannot
42
+ # mistake it for a framework's own frame (see docs/adr/0012). Read once,
43
+ # when this file is first required: under Bundler, that require happens
44
+ # after Bundler has already set the gem paths this process uses, so the
45
+ # list this constant freezes is the one every later frame is checked
46
+ # against.
47
+ INSTALLED_GEM_DIRS = Gem.path.map { |path| File.join(path, "gems") + File::SEPARATOR }.freeze
48
+
49
+ # Ruby's own standard library, e.g. the minitest release Ruby itself
50
+ # bundles rather than one RubyGems installed -- a second way a test
51
+ # framework's own frame can appear that INSTALLED_GEM_DIRS alone would
52
+ # not catch.
53
+ STDLIB_DIR = RbConfig::CONFIG["rubylibdir"] + File::SEPARATOR
54
+
55
+ # hegel_run_result_error's fallback: the header documents a NULL message
56
+ # as possible for an ERROR run, and Hegel::Error still needs *some*
57
+ # message text in that case.
58
+ UNKNOWN_RUN_ERROR_MESSAGE = "hegel: the run failed without an error message"
59
+
60
+ # Counts test cases as #drive receives them from the live run loop, and
61
+ # snapshots, per distinct origin, how many had been returned (and how
62
+ # many of those were discarded) the first time that origin's exception
63
+ # was classified INTERESTING. That snapshot is the failure report's own
64
+ # "Falsified after N test cases (M discarded)" line: the generation
65
+ # phase's counts, not the shrink phase's -- #drive's own comment
66
+ # measured the shrink phase at roughly 50x more iterations for a
67
+ # similarly sized run, and counting those into N would answer a
68
+ # different question than the report claims to.
69
+ class GenerationStats
70
+ def initialize
71
+ @test_cases = 0
72
+ @discarded = 0
73
+ @snapshots = {}
74
+ end
75
+
76
+ # Called once per case #drive receives (or, from #reproduce, exactly
77
+ # once for its own single case).
78
+ def record(status, origin)
79
+ @test_cases += 1
80
+ @discarded += 1 if status == LibHegel::HEGEL_STATUS_INVALID
81
+ @snapshots[origin] ||= [@test_cases, @discarded] if status == LibHegel::HEGEL_STATUS_INTERESTING
82
+ end
83
+
84
+ # [test_cases, discarded] as of +origin+'s first INTERESTING
85
+ # appearance. No fallback: every origin #replay_failure asks for here
86
+ # came from a failure hegel_run_result reported, and that failure
87
+ # exists only because this same #record already saw it live.
88
+ def for(origin)
89
+ @snapshots.fetch(origin)
90
+ end
91
+ end
92
+
93
+ module_function
94
+
95
+ # Runs +block+ as a Hegel property against +impl+, applying +test_cases+,
96
+ # +seed+, +derandomize+, +verbosity+, +database+, +database_key+,
97
+ # +phases+, +suppress_health_check+, +report_multiple_failures+, and
98
+ # +stateful_step_count+ (see Hegel::Settings) to a fresh settings handle
99
+ # first. Returns nil on a passing run, re-raises the exception the
100
+ # smallest failing case's body raised on a failing run, and raises
101
+ # Hegel::Error for a run-level failure (ERROR status, a replay that
102
+ # could not reproduce the recorded failure, or more than one distinct
103
+ # failure -- see #replay).
104
+ #
105
+ # +database+ and +database_key+ follow the table
106
+ # Hegel::Settings.apply_database documents; docs/adr/0009 has the
107
+ # decision and the measurements behind it.
108
+ #
109
+ # +report_multiple_failures+ defaults to false, not nil: hegel-c/src/
110
+ # settings.rs itself defaults to true, but hegel-rust's own Rust API
111
+ # (src/runner.rs) and hegel-java both default to false, and hegel-java
112
+ # states the reason: one failure re-raised unaltered keeps the exception
113
+ # class and the stack trace a debugger reads, which a summary replaces
114
+ # with a count.
115
+ # That is this library's own central promise (#classify re-raises a
116
+ # body's exception with its class and backtrace intact), so the same
117
+ # reason applies here, and choosing to depart from the engine's own
118
+ # default is itself a decision worth stating explicitly with `false`
119
+ # rather than leaving it to a nil a reader could mistake for "no
120
+ # opinion".
121
+ #
122
+ # +reproduce_failure+, when given, wins over everything above except
123
+ # +verbosity+: it skips the run loop entirely and replays a single case
124
+ # built from that blob (see #reproduce). +test_cases+ has no run to
125
+ # bound in that case.
126
+ #
127
+ # +output+ (default $stderr) is where a failure report is written,
128
+ # unless +verbosity+ is :quiet, in which case none is written at all.
129
+ #
130
+ # Handles nest context > settings > run > result, each freed in an
131
+ # `ensure` by the code that opened it, innermost first: hegel_context_free
132
+ # requires every other handle taken from the context to be freed first,
133
+ # and Ruby's GC gives finalizers no ordering guarantee to rely on instead.
134
+ #
135
+ # +settings+ stays open through the whole run, not just through
136
+ # hegel_run_start. The header says a caller may free settings as soon as
137
+ # hegel_run_start returns, but that is only true for driving the loop: a
138
+ # failing run's replay calls hegel_test_case_from_blob against this same
139
+ # settings handle, so it must outlive the FAILED-status replay below, not
140
+ # just the loop above it.
141
+ def run(impl:, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
142
+ phases: nil, suppress_health_check: nil, report_multiple_failures: false, stateful_step_count: nil,
143
+ output: $stderr, reproduce_failure: nil, &block)
144
+ quiet = verbosity == :quiet
145
+ LibHegel.with_context(impl) do |ctx|
146
+ settings = impl.settings_new(ctx)
147
+ begin
148
+ Settings.apply(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
149
+ verbosity: verbosity, database: database, database_key: database_key, phases: phases,
150
+ suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures,
151
+ stateful_step_count: stateful_step_count)
152
+
153
+ if reproduce_failure
154
+ reproduce(impl, ctx, settings, reproduce_failure, quiet: quiet, output: output, &block)
155
+ else
156
+ run_and_finish(impl, ctx, settings, quiet: quiet, output: output, &block)
157
+ end
158
+ ensure
159
+ impl.settings_free(ctx, settings)
160
+ end
161
+ end
162
+ end
163
+
164
+ # The ordinary (non-reproduce_failure) path: starts a run, drives it,
165
+ # and hands its result to #finish. Split out of #run so that path and
166
+ # #reproduce's are two plain branches there, not one method doing both.
167
+ def run_and_finish(impl, ctx, settings, quiet:, output:, &block)
168
+ run = impl.run_start(ctx, settings)
169
+ begin
170
+ stats = GenerationStats.new
171
+ drive(impl, ctx, run, stats, &block)
172
+
173
+ result = impl.run_result(ctx, run)
174
+ begin
175
+ finish(impl, ctx, settings, result, stats, quiet: quiet, output: output, &block)
176
+ ensure
177
+ impl.run_result_free(ctx, result)
178
+ end
179
+ ensure
180
+ # hegel_run_free only marks an in-progress case complete; per the
181
+ # header it does not free the test-case handle itself. That
182
+ # handle is this loop's own to release, which #run_case does via
183
+ # #with_test_case's own `ensure` on every path, including a fatal
184
+ # exception raised from inside #drive.
185
+ impl.run_free(ctx, run)
186
+ end
187
+ end
188
+
189
+ # Pulls test cases from +run+ until hegel_next_test_case reports none
190
+ # left (a nil out-parameter, not an error). Never counts iterations
191
+ # itself: test_cases bounds generation, not how many times shrinking
192
+ # calls the body afterwards. Measured against libhegel 0.32.5, a run
193
+ # configured for 20 test cases whose body always failed took 1003
194
+ # iterations. +stats+ does its own, different counting -- see
195
+ # GenerationStats above.
196
+ def drive(impl, ctx, run, stats, &block)
197
+ loop do
198
+ tc = impl.next_test_case(ctx, run)
199
+ break if tc.nil?
200
+
201
+ run_case(impl, ctx, tc, stats, &block)
202
+ end
203
+ end
204
+
205
+ # Builds a Hegel::TestCase for +tc+ and yields it, then frees what it
206
+ # opened: every pool it recorded (Hegel::TestCase#free_pools), then the
207
+ # test-case handle itself, in that order, whether the block returns or
208
+ # raises. #run_case, #replay_failure, and #reproduce each need exactly
209
+ # this shape around their own call to #classify -- docs/adr/0011 decides
210
+ # a test case owns every pool built from it, and that pools free before
211
+ # the handle that owns them goes.
212
+ #
213
+ # The nested `ensure` is what keeps those two releases independent. A
214
+ # raise out of #free_pools would otherwise carry past the handle's own
215
+ # release, and hegel_context_free requires every handle taken from the
216
+ # context to be freed first, so one skipped release does not stop at one
217
+ # leak -- it fails the context's release too, at the end of a run that
218
+ # had already gone wrong enough to raise in here.
219
+ def with_test_case(impl, ctx, tc, record: false)
220
+ test_case = TestCase.new(impl, ctx, tc, record: record)
221
+ yield test_case
222
+ ensure
223
+ begin
224
+ test_case.free_pools
225
+ ensure
226
+ impl.test_case_free(ctx, tc)
227
+ end
228
+ end
229
+
230
+ # Runs +block+ against one test-case handle, classifies the outcome,
231
+ # counts it into +stats+, and reports it with hegel_mark_complete. A
232
+ # fatal exception (#classify re-raises those before returning) skips
233
+ # both entirely and still reaches #with_test_case's own `ensure`, so the
234
+ # handle is freed either way; its owner is this loop, not hegel_run_free
235
+ # (see #run_and_finish's comment above).
236
+ def run_case(impl, ctx, tc, stats, &block)
237
+ with_test_case(impl, ctx, tc) do |test_case|
238
+ status, origin = classify(test_case, &block)
239
+ stats.record(status, origin)
240
+ impl.mark_complete(ctx, tc, status, origin)
241
+ end
242
+ end
243
+
244
+ # Reads the finished run's status and acts on it. PASSED returns nil,
245
+ # ERROR raises Hegel::Error from hegel_run_result_error's message, and
246
+ # FAILED hands off to #replay. Any other status would mean this binding
247
+ # does not recognise a hegel_run_status_t value the loaded engine
248
+ # returned, mirroring how LibHegel.check! names an unrecognised result
249
+ # code instead of silently doing nothing with it.
250
+ def finish(impl, ctx, settings, result, stats, quiet:, output:, &block)
251
+ status = impl.run_result_status(ctx, result)
252
+ case status
253
+ when LibHegel::HEGEL_RUN_STATUS_PASSED
254
+ nil
255
+ when LibHegel::HEGEL_RUN_STATUS_ERROR
256
+ raise Hegel::Error, impl.run_result_error(ctx, result) || UNKNOWN_RUN_ERROR_MESSAGE
257
+ when LibHegel::HEGEL_RUN_STATUS_FAILED
258
+ replay(impl, ctx, settings, result, stats, quiet: quiet, output: output, &block)
259
+ else
260
+ raise Hegel::Error, "hegel: run finished with an unrecognized status (#{status})"
261
+ end
262
+ end
263
+
264
+ # Replays every recorded failure by running +block+ again against a test
265
+ # case rebuilt from its reproduction blob, in the same way #run_case
266
+ # classifies and completes a live one. Writes the report for every
267
+ # failure it collected to +output+ (unless +quiet+), then raises: one
268
+ # failure re-raises its own kept exception, unaltered; two or more raise
269
+ # Hegel::Error with #multiple_failures_message instead, since no single
270
+ # one of several kept exceptions is more the run's own verdict than
271
+ # another. hegel-rust's own src/run_lifecycle.rs (the
272
+ # HEGEL_RUN_STATUS_FAILED branch) makes the same choice: one panic
273
+ # re-raised as itself, several replaced by one panic carrying just the
274
+ # count.
275
+ #
276
+ # #finish only reaches here on a FAILED run, and a FAILED run this
277
+ # binding has actually seen always carries at least one failure to
278
+ # iterate below, so neither raise needs a zero-failures guard: that
279
+ # branch would need a run this binding has never observed to reach it.
280
+ # The report is written before either raise, not after: a host
281
+ # framework that catches the re-raised exception would otherwise read
282
+ # its own output before this one, out of order.
283
+ def replay(impl, ctx, settings, result, stats, quiet:, output:, &block)
284
+ kept_exception = nil
285
+ failures = []
286
+ impl.run_result_failure_count(ctx, result).times do |index|
287
+ failure = impl.run_result_failure(ctx, result, index)
288
+ begin
289
+ kept_exception, report = replay_failure(impl, ctx, settings, failure, index, stats, &block)
290
+ failures << report
291
+ ensure
292
+ impl.failure_free(ctx, failure)
293
+ end
294
+ end
295
+ output.puts(Report.render(failures)) unless quiet
296
+ raise Hegel::Error, multiple_failures_message(failures.size) if failures.size > 1
297
+
298
+ raise kept_exception
299
+ end
300
+
301
+ # Rebuilds one failure's test case from its reproduction blob and runs
302
+ # +block+ against it, through the same #classify used by the live loop,
303
+ # recording its entries for the report (see Hegel::TestCase). Returns
304
+ # [exception, Hegel::Report::Failure] on success.
305
+ #
306
+ # Flaky means any outcome other than INTERESTING, which is broader than
307
+ # "the body did not raise": #classify's other two non-INTERESTING
308
+ # outcomes both matter here. A body that raises nothing
309
+ # on replay is the textbook flaky case. A blob whose choices no longer
310
+ # match the caller's generators is the other one: the header puts that
311
+ # at "the draw that overruns", so the body raises Hegel::StopTest and
312
+ # #classify turns it into OVERRUN. Re-raising either as its original
313
+ # class would leak a control exception meant only for code driving a
314
+ # test case into the host test framework. This classify-then-check
315
+ # step exists to prevent exactly that.
316
+ def replay_failure(impl, ctx, settings, failure, index, stats, &block)
317
+ blob = impl.failure_reproduction_blob(ctx, failure)
318
+ raise Hegel::Error, "hegel: failure #{index} has no reproduction blob" if blob.nil?
319
+
320
+ tc = build_replay_case(impl, ctx, settings, blob)
321
+ with_test_case(impl, ctx, tc, record: true) do |test_case|
322
+ status, origin, exception, entries = classify(test_case, &block)
323
+ raise Hegel::Error, flaky_message unless status == LibHegel::HEGEL_STATUS_INTERESTING
324
+
325
+ # The libhegel reference documents blob replay as ended by the
326
+ # caller's own hegel_mark_complete. Skipping it might not crash
327
+ # hegel_test_case_free, but not crashing is not the same as correct.
328
+ impl.mark_complete(ctx, tc, status, origin)
329
+ test_cases, discarded = stats.for(origin)
330
+ [exception, Report::Failure.new(test_cases: test_cases, discarded: discarded, entries: entries, blob: blob)]
331
+ end
332
+ end
333
+
334
+ # Hegel.test(reproduce_failure:)'s own path: starts no run loop, and
335
+ # replays exactly one case built from +blob+, through the same
336
+ # classify-then-check contract #replay_failure uses. That case is
337
+ # already the "final" replay a report needs, so recording is on for it
338
+ # -- unconditionally 1 test case, 0 discarded, since a body that raised
339
+ # Hegel::AssumeFailed here would already have failed the status check
340
+ # below before either count could matter.
341
+ #
342
+ # Builds the replay's test case, converting a control exception raised
343
+ # while building it into an ordinary error.
344
+ #
345
+ # The header attributes HEGEL_E_STOP_TEST to "the draw that overruns"
346
+ # rather than to this call, and replaying a two-draw blob against a
347
+ # five-draw body does build a case and then overrun at a draw, measured
348
+ # against 0.32.5. This guard is therefore defence rather than a
349
+ # documented path: a control exception escaping into the caller's test
350
+ # is the one outcome this whole design exists to prevent, and both
351
+ # replay paths go through here so neither can grow the hole
352
+ # independently.
353
+ def build_replay_case(impl, ctx, settings, blob)
354
+ impl.test_case_from_blob(ctx, settings, blob)
355
+ rescue Hegel::StopTest
356
+ raise Hegel::Error, flaky_message
357
+ end
358
+
359
+ def reproduce(impl, ctx, settings, blob, quiet:, output:, &block)
360
+ tc = build_replay_case(impl, ctx, settings, blob)
361
+ with_test_case(impl, ctx, tc, record: true) do |test_case|
362
+ status, origin, exception, entries = classify(test_case, &block)
363
+ raise Hegel::Error, flaky_message unless status == LibHegel::HEGEL_STATUS_INTERESTING
364
+
365
+ impl.mark_complete(ctx, tc, status, origin)
366
+ report = Report::Failure.new(test_cases: 1, discarded: 0, entries: entries, blob: blob)
367
+ output.puts(Report.render([report])) unless quiet
368
+ raise exception
369
+ end
370
+ end
371
+
372
+ # Runs +block+ against +test_case+ (a Hegel::TestCase #with_test_case
373
+ # already built, at whatever +record:+ it was given) and classifies the
374
+ # outcome into the [hegel_status_t, origin, exception, entries]
375
+ # #run_case, #replay_failure, and #reproduce all need. +entries+ is only
376
+ # ever non-nil when +test_case+ was built to record and the outcome was
377
+ # INTERESTING, since that is the only combination any caller here reads
378
+ # it for. Order matters: a fatal exception must be re-raised before it
379
+ # reaches the library's own control exceptions, which must themselves be
380
+ # told apart from an ordinary exception before the catch-all below.
381
+ #
382
+ # standard:disable Lint/RescueException -- `rescue Exception` is
383
+ # deliberate: Minitest::Assertion and RSpec's ExpectationNotMetError both
384
+ # descend from Exception, not StandardError, so a narrower rescue would
385
+ # let a failing assertion pass through this loop uncaught instead of
386
+ # being reported as a Hegel failure.
387
+ def classify(test_case, &block)
388
+ block.call(test_case)
389
+ [LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
390
+ rescue *Hegel::FATAL_EXCEPTIONS
391
+ raise
392
+ rescue Hegel::AssumeFailed
393
+ [LibHegel::HEGEL_STATUS_INVALID, nil, nil, nil]
394
+ rescue Hegel::StopTest
395
+ [LibHegel::HEGEL_STATUS_OVERRUN, nil, nil, nil]
396
+ rescue Exception => e
397
+ [LibHegel::HEGEL_STATUS_INTERESTING, origin_for(e), e, test_case.entries]
398
+ end
399
+ # standard:enable Lint/RescueException
400
+
401
+ # Builds a stable origin string from where +exception+ was raised, the
402
+ # same information hegel-rust's own "Panic at {location}" origin uses.
403
+ # The exception's class is deliberately left out: two failures raised at
404
+ # the same line are the same bug even if the raised class differs run to
405
+ # run (e.g. a NoMethodError on one nil and a TypeError on another), and
406
+ # hegel_mark_complete's header is explicit that origin is what groups
407
+ # failures for shrinking.
408
+ #
409
+ # The first frame is not always the right one: an assertion library
410
+ # raises from inside itself, so exception.backtrace_locations.first is a
411
+ # line in minitest or rspec-support, identical for every failing
412
+ # assertion in a suite regardless of which line the caller wrote (see
413
+ # docs/adr/0012). #infrastructure? skips such frames in favor of the
414
+ # first one that belongs to the caller's own code. When every frame is
415
+ # infrastructure, the first frame is used after all: a coarse origin
416
+ # still groups failures consistently, where no origin at all would lose
417
+ # the failure's identity entirely.
418
+ def origin_for(exception)
419
+ locations = exception.backtrace_locations
420
+ location = locations&.find { |candidate| !infrastructure?(candidate.path) } || locations&.first
421
+ location ? "Raised at #{location.path}:#{location.lineno}" : UNKNOWN_ORIGIN
422
+ end
423
+
424
+ # True when +path+ belongs to this library, an installed gem, or Ruby's
425
+ # own standard library, rather than to the caller whose test #origin_for
426
+ # is trying to identify. Location, not framework name, is what this
427
+ # checks. A name list needs an entry per framework, and this library is
428
+ # driven from frameworks it does not enumerate; location reaches every
429
+ # one of them with a single rule, because Bundler installs a framework
430
+ # under a gem directory and loads a caller's own path:/git: code from
431
+ # the working copy (docs/adr/0012).
432
+ def infrastructure?(path)
433
+ path.start_with?(LIBRARY_DIR) ||
434
+ INSTALLED_GEM_DIRS.any? { |dir| path.start_with?(dir) } ||
435
+ path.start_with?(STDLIB_DIR)
436
+ end
437
+
438
+ # Mirrors the intent of hegel-rust's FLAKY_DIAGNOSTIC in English, not its
439
+ # exact wording: the same generated data produced a different outcome on
440
+ # replay, which usually means the body depends on something outside
441
+ # libhegel's control.
442
+ def flaky_message
443
+ "hegel: this failure did not reproduce against the same generated data. " \
444
+ "The test body likely depends on state outside libhegel's control, " \
445
+ "such as a global variable, wall-clock time, or an external source of randomness."
446
+ end
447
+
448
+ # #replay's own multi-failure raise, and Report.render's multi-failure
449
+ # heading, must stay the same sentence: a caller who greps the printed
450
+ # report for this text should find the same string in the exception it
451
+ # catches. Report.render builds that heading itself rather than calling
452
+ # this method, since Hegel::Report only ever formats data handed to it
453
+ # and does not know how a run ended (see its own class comment); the
454
+ # sentence is duplicated here as the coupling between the two, not
455
+ # factored into a shared helper neither module already depends on.
456
+ # Deliberately carries no "hegel: " prefix, unlike #flaky_message and
457
+ # the other messages this module composes itself (UNKNOWN_RUN_ERROR_
458
+ # MESSAGE, the unrecognized-status message, the no-reproduction-blob
459
+ # message), to stay that same sentence.
460
+ def multiple_failures_message(count)
461
+ "Property-based test failed with #{count} distinct failures."
462
+ end
463
+ end
464
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "lib_hegel"
5
+
6
+ module Hegel
7
+ # Copies Hegel.test's keyword arguments onto a libhegel settings handle.
8
+ # Most keywords follow one rule: nil means "leave libhegel's own default in
9
+ # place", so a caller of Hegel.test who passes none of them gets exactly
10
+ # the engine's untouched defaults (100 test cases, a random seed, no
11
+ # derandomize, every phase, every health check). +database+ and
12
+ # +database_key+ follow the table #apply_database documents instead, and
13
+ # +report_multiple_failures+ has no nil case at all -- see #apply's own
14
+ # comment for why.
15
+ module Settings
16
+ # Hegel.test's verbosity: values, mapped to hegel.h's hegel_verbosity_t.
17
+ VERBOSITY_CODES = {
18
+ quiet: LibHegel::HEGEL_VERBOSITY_QUIET,
19
+ normal: LibHegel::HEGEL_VERBOSITY_NORMAL,
20
+ verbose: LibHegel::HEGEL_VERBOSITY_VERBOSE,
21
+ debug: LibHegel::HEGEL_VERBOSITY_DEBUG
22
+ }.freeze
23
+
24
+ # Hegel.test's phases: Symbols, mapped to hegel.h's hegel_phase_t. The
25
+ # engine ORs these together itself when every phase is passed; this table
26
+ # only needs one bit per Symbol.
27
+ PHASE_CODES = {
28
+ explicit: LibHegel::HEGEL_PHASE_EXPLICIT,
29
+ reuse: LibHegel::HEGEL_PHASE_REUSE,
30
+ generate: LibHegel::HEGEL_PHASE_GENERATE,
31
+ target: LibHegel::HEGEL_PHASE_TARGET,
32
+ shrink: LibHegel::HEGEL_PHASE_SHRINK
33
+ }.freeze
34
+
35
+ # Hegel.test's suppress_health_check: Symbols, mapped to hegel.h's
36
+ # hegel_health_check_t.
37
+ HEALTH_CHECK_CODES = {
38
+ filter_too_much: LibHegel::HEGEL_HC_FILTER_TOO_MUCH,
39
+ too_slow: LibHegel::HEGEL_HC_TOO_SLOW,
40
+ test_cases_too_large: LibHegel::HEGEL_HC_TEST_CASES_TOO_LARGE,
41
+ large_initial_test_case: LibHegel::HEGEL_HC_LARGE_INITIAL_TEST_CASE
42
+ }.freeze
43
+
44
+ module_function
45
+
46
+ # Applies every one of Hegel.test's settings keywords to +settings+ via
47
+ # +impl+. +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
48
+ # +suppress_health_check+ all skip their setter when left nil.
49
+ # +database+/+database_key+ follow #apply_database's own table, called
50
+ # unconditionally since even the nil/nil case has a setter to call (see
51
+ # its comment). +report_multiple_failures+ is called unconditionally too,
52
+ # with no nil case: Hegel.test defaults it to false rather than leaving
53
+ # it nil, so this method never sees nil for it -- see Hegel::Runner.run's
54
+ # own comment for why that default departs from every other keyword's
55
+ # nil-means-engine-default rule. +stateful_step_count+ follows the same
56
+ # nil-means-engine-default rule as +test_cases+/+seed+/and so on: the
57
+ # header documents the engine's own default (50) and requires the value
58
+ # be at least 1, and, like +tc.target+'s own label, that requirement is
59
+ # left to the engine rather than re-checked here.
60
+ def apply(impl, ctx, settings, test_cases:, seed:, derandomize:, verbosity:, database:, database_key:, phases:,
61
+ suppress_health_check:, report_multiple_failures:, stateful_step_count:)
62
+ impl.settings_set_test_cases(ctx, settings, test_cases) unless test_cases.nil?
63
+ impl.settings_set_seed(ctx, settings, seed, true) unless seed.nil?
64
+ impl.settings_set_derandomize(ctx, settings, derandomize) unless derandomize.nil?
65
+ apply_verbosity(impl, ctx, settings, verbosity) unless verbosity.nil?
66
+ apply_database(impl, ctx, settings, database: database, database_key: database_key)
67
+ apply_phases(impl, ctx, settings, phases) unless phases.nil?
68
+ apply_suppress_health_check(impl, ctx, settings, suppress_health_check) unless suppress_health_check.nil?
69
+ impl.settings_set_report_multiple_failures(ctx, settings, report_multiple_failures)
70
+ impl.settings_set_stateful_step_count(ctx, settings, stateful_step_count) unless stateful_step_count.nil?
71
+ end
72
+
73
+ # Split from #apply so the VERBOSITY_CODES lookup (one of several
74
+ # keywords that can fail) is not buried inside the top-level sequence.
75
+ def apply_verbosity(impl, ctx, settings, verbosity)
76
+ code = VERBOSITY_CODES.fetch(verbosity) do
77
+ raise Hegel::Error,
78
+ "hegel: unknown verbosity #{verbosity.inspect}; expected one of #{VERBOSITY_CODES.keys.inspect}"
79
+ end
80
+ impl.settings_set_verbosity(ctx, settings, code)
81
+ end
82
+
83
+ # docs/adr/0009-turn-the-example-database-on-with-a-key.md decides this
84
+ # table and the reasons behind it; read it before changing this method.
85
+ #
86
+ # database_key: | database: | does
87
+ # nil | nil | settings_set_database(ctx, s, "")
88
+ # nil | String | raises Hegel::Error
89
+ # String | nil | settings_set_database_key(ctx, s, key) only
90
+ # String | String | settings_set_database(ctx, s, database), then settings_set_database_key
91
+ #
92
+ # The nil/nil row calls settings_set_database("") explicitly rather than
93
+ # leaving it uncalled, unlike every other nil-means-default keyword here:
94
+ # the ADR measured that an unkeyed run writes nothing even with the
95
+ # engine's own default path left in place, but that is behaviour this
96
+ # project measured against one libhegel build, not a promise the header
97
+ # makes, and the cost of relying on it being wrong is a directory
98
+ # appearing in a caller's working copy that never asked for one.
99
+ def apply_database(impl, ctx, settings, database:, database_key:)
100
+ if database_key.nil?
101
+ unless database.nil?
102
+ raise Hegel::Error,
103
+ "hegel: database: needs database_key: to scope what it stores and replays; " \
104
+ "pass database_key: too, or drop database: and pass neither."
105
+ end
106
+ impl.settings_set_database(ctx, settings, "")
107
+ else
108
+ impl.settings_set_database(ctx, settings, database) unless database.nil?
109
+ impl.settings_set_database_key(ctx, settings, database_key)
110
+ end
111
+ end
112
+
113
+ # Split from #apply so the PHASE_CODES lookup and the OR-together step
114
+ # are not buried inside the top-level sequence, the same reason
115
+ # #apply_verbosity is split out. Raises Hegel::Error for a Symbol not in
116
+ # PHASE_CODES, or for an empty Array: HEGEL_PHASE_* bits are additive
117
+ # (each one turns a phase on), and mask 0 -- what an empty Array would
118
+ # OR together to -- has not been measured against libhegel, unlike
119
+ # dropping a single named phase (see the class-level phases: keyword
120
+ # documentation this backs). Rejecting it here matches
121
+ # #apply_verbosity's own precedent: refuse at the boundary with a
122
+ # message naming the accepted values, rather than pass through a
123
+ # combination nobody has watched the engine handle.
124
+ def apply_phases(impl, ctx, settings, phases)
125
+ mask = mask_for(phases, PHASE_CODES, "phases")
126
+ impl.settings_set_phases(ctx, settings, mask)
127
+ end
128
+
129
+ # Split from #apply for the same reason #apply_phases is. Raises
130
+ # Hegel::Error for a Symbol not in HEALTH_CHECK_CODES, or for an empty
131
+ # Array, aligned with #apply_phases's own empty-Array rule so the two
132
+ # keywords read the same way. The alignment is deliberate even though
133
+ # the two are not symmetric: 0 here is the well-documented default (no
134
+ # suppression), whereas nil already spells that meaning for this
135
+ # keyword -- "no suppression" is nil, and an empty Array is rejected the
136
+ # same way phases: [] is, rather than accepted as a second spelling of
137
+ # nil.
138
+ def apply_suppress_health_check(impl, ctx, settings, checks)
139
+ mask = mask_for(checks, HEALTH_CHECK_CODES, "suppress_health_check")
140
+ impl.settings_set_suppress_health_check(ctx, settings, mask)
141
+ end
142
+
143
+ # Shared by #apply_phases and #apply_suppress_health_check: looks up
144
+ # every Symbol in +values+ against +codes+ and ORs the results together.
145
+ # +keyword+ is the Hegel.test keyword being applied, named in both
146
+ # raised messages so a caller who passes a bad Symbol to either one is
147
+ # told which they got wrong -- the same reason #apply_verbosity's own
148
+ # message says "verbosity". +codes+.keys appears there the same way
149
+ # VERBOSITY_CODES.keys does in that method's.
150
+ def mask_for(values, codes, keyword)
151
+ if values.empty?
152
+ raise Hegel::Error,
153
+ "hegel: #{keyword} expects one or more of #{codes.keys.inspect}, got an empty Array"
154
+ end
155
+
156
+ values.reduce(0) do |mask, value|
157
+ code = codes.fetch(value) do
158
+ raise Hegel::Error, "hegel: unknown #{keyword} #{value.inspect}; expected one of #{codes.keys.inspect}"
159
+ end
160
+ mask | code
161
+ end
162
+ end
163
+ end
164
+ end