hegeltest 0.1.0 → 0.2.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 +4 -4
  2. data/CHANGELOG.md +103 -0
  3. data/README.md +72 -26
  4. data/Rakefile +81 -0
  5. data/docs/README.md +10 -0
  6. data/docs/adr/0014-name-a-drawn-value-only-when-the-draw-is-the-whole-assigned-value.md +74 -0
  7. data/docs/adr/0015-follow-the-hegeldev-interface-and-take-changes-upstream-first.md +101 -0
  8. data/docs/adr/0016-run-mutation-testing-with-mutineer.md +87 -0
  9. data/docs/adr/0017-raise-the-ruby-floor-to-3-4.md +43 -0
  10. data/docs/adr/0018-gate-mutation-testing-on-a-committed-baseline.md +74 -0
  11. data/docs/adr/0019-report-failures-from-the-cases-the-engine-stamps.md +70 -0
  12. data/docs/adr/0020-derive-span-labels-from-generator-names.md +64 -0
  13. data/docs/adr/0021-run-a-state-machine-in-rounds-with-its-own-step-count.md +59 -0
  14. data/docs/adr/0022-keep-microsecond-times-over-a-nanosecond-engine.md +45 -0
  15. data/docs/adr/0023-leave-unset-settings-to-the-engines-profile.md +50 -0
  16. data/docs/architecture.md +11 -6
  17. data/lib/hegel/draw_name.rb +24 -8
  18. data/lib/hegel/generator.rb +50 -9
  19. data/lib/hegel/generators.rb +134 -102
  20. data/lib/hegel/lib_hegel/real.rb +193 -94
  21. data/lib/hegel/lib_hegel.rb +30 -56
  22. data/lib/hegel/libhegel_version.rb +1 -1
  23. data/lib/hegel/report.rb +17 -8
  24. data/lib/hegel/runner.rb +153 -201
  25. data/lib/hegel/settings.rb +9 -14
  26. data/lib/hegel/state_machine.rb +15 -8
  27. data/lib/hegel/stateful/pool.rb +0 -2
  28. data/lib/hegel/stateful.rb +64 -36
  29. data/lib/hegel/syntax/methods.rb +3 -2
  30. data/lib/hegel/test_case.rb +29 -9
  31. data/lib/hegel/version.rb +1 -1
  32. data/lib/hegel.rb +11 -17
  33. data/lib/tasks/libhegel.rake +9 -2
  34. data/sig/hegel.rbs +60 -61
  35. data/skills/hegel-ruby/references/ruby/reference.md +147 -52
  36. metadata +12 -2
data/lib/hegel/runner.rb CHANGED
@@ -9,9 +9,15 @@ require_relative "test_case"
9
9
  module Hegel
10
10
  # Drives one Hegel.test run: this is the Ruby side of the per-test-case
11
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.
12
+ # loop; libhegel owns generation, shrinking, database replay, and the final
13
+ # replay of each failure, so a user's test body never has to cross into
14
+ # native code itself.
14
15
  module Runner
16
+ # One failure kept for its origin: the exception the body raised, and
17
+ # the entries the case recorded, or nil when the engine did not stamp
18
+ # the case for capture (see #run_case).
19
+ Capture = Struct.new(:exception, :entries)
20
+
15
21
  # #origin_for's fallback when an exception's backtrace has no first
16
22
  # location to build a real origin from. Named the way hegel-rust names
17
23
  # its own equivalent constant, "Panic at <unknown>", for a panic with no
@@ -62,9 +68,9 @@ module Hegel
62
68
  # many of those were discarded) the first time that origin's exception
63
69
  # was classified INTERESTING. That snapshot is the failure report's own
64
70
  # "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
71
+ # phase's counts, not the shrink phase's. Measured against libhegel
72
+ # 0.45.0, a run that failed after 2 generated cases called the body 60
73
+ # to 75 times in all, and counting those into N would answer a
68
74
  # different question than the report claims to.
69
75
  class GenerationStats
70
76
  def initialize
@@ -73,8 +79,7 @@ module Hegel
73
79
  @snapshots = {}
74
80
  end
75
81
 
76
- # Called once per case #drive receives (or, from #reproduce, exactly
77
- # once for its own single case).
82
+ # Called once per case #drive receives.
78
83
  def record(status, origin)
79
84
  @test_cases += 1
80
85
  @discarded += 1 if status == LibHegel::HEGEL_STATUS_INVALID
@@ -82,7 +87,7 @@ module Hegel
82
87
  end
83
88
 
84
89
  # [test_cases, discarded] as of +origin+'s first INTERESTING
85
- # appearance. No fallback: every origin #replay_failure asks for here
90
+ # appearance. No fallback: every origin #failure_report asks for here
86
91
  # came from a failure hegel_run_result reported, and that failure
87
92
  # exists only because this same #record already saw it live.
88
93
  def for(origin)
@@ -94,35 +99,29 @@ module Hegel
94
99
 
95
100
  # Runs +block+ as a Hegel property against +impl+, applying +test_cases+,
96
101
  # +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).
102
+ # +phases+, +suppress_health_check+, and +report_multiple_failures+ (see
103
+ # Hegel::Settings) to a fresh settings handle first. Returns nil on a
104
+ # passing run, re-raises the exception the smallest failing case's body
105
+ # raised on a failing run, and raises Hegel::Error for a run-level
106
+ # failure (ERROR status, a blob that no longer reproduces, or more than
107
+ # one distinct failure -- see #report_failures).
104
108
  #
105
109
  # +database+ and +database_key+ follow the table
106
110
  # Hegel::Settings.apply_database documents; docs/adr/0009 has the
107
111
  # decision and the measurements behind it.
108
112
  #
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".
113
+ # +report_multiple_failures+ defaults to false, the default libhegel,
114
+ # hegel-rust, and hegel-java share. It is passed as false rather than
115
+ # left nil, because hegel-java states why false is right: one failure
116
+ # re-raised unaltered keeps the exception class and the stack trace a
117
+ # debugger reads, which a summary replaces with a count. That is this
118
+ # library's own central promise (#classify re-raises a body's exception
119
+ # with its class and backtrace intact), so a hegel.toml profile or a
120
+ # HEGEL_* variable does not get to turn it off by accident.
121
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.
122
+ # +reproduce_failure+, when given, starts the run from that blob instead
123
+ # (see LibHegel::Real#run_start_blob): the engine replays it rather than
124
+ # exploring, and the same loop below drives the replay.
126
125
  #
127
126
  # +output+ (default $stderr) is where a failure report is written,
128
127
  # unless +verbosity+ is :quiet, in which case none is written at all.
@@ -131,84 +130,72 @@ module Hegel
131
130
  # `ensure` by the code that opened it, innermost first: hegel_context_free
132
131
  # requires every other handle taken from the context to be freed first,
133
132
  # 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
133
  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,
134
+ phases: nil, suppress_health_check: nil, report_multiple_failures: false,
143
135
  output: $stderr, reproduce_failure: nil, &block)
144
136
  quiet = verbosity == :quiet
145
137
  LibHegel.with_context(impl) do |ctx|
146
138
  settings = impl.settings_new(ctx)
147
- begin
139
+ run = begin
148
140
  Settings.apply(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
149
141
  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
-
142
+ suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures)
153
143
  if reproduce_failure
154
- reproduce(impl, ctx, settings, reproduce_failure, quiet: quiet, output: output, &block)
144
+ impl.run_start_blob(ctx, settings, reproduce_failure)
155
145
  else
156
- run_and_finish(impl, ctx, settings, quiet: quiet, output: output, &block)
146
+ impl.run_start(ctx, settings)
157
147
  end
158
148
  ensure
149
+ # The header lets a caller free settings once the run has started:
150
+ # hegel_run_start copies them.
159
151
  impl.settings_free(ctx, settings)
160
152
  end
153
+ run_and_finish(impl, ctx, run, reproducing: !reproduce_failure.nil?, quiet: quiet, output: output, &block)
161
154
  end
162
155
  end
163
156
 
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)
157
+ # Drives +run+ and hands its result to #finish.
158
+ def run_and_finish(impl, ctx, run, reproducing:, quiet:, output:, &block)
159
+ stats = GenerationStats.new
160
+ captures = {}
161
+ drive(impl, ctx, run, stats, captures, &block)
172
162
 
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
163
+ result = impl.run_result(ctx, run)
164
+ begin
165
+ finish(impl, ctx, result, stats, captures, reproducing: reproducing, quiet: quiet, output: output)
179
166
  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)
167
+ impl.run_result_free(ctx, result)
186
168
  end
169
+ ensure
170
+ # hegel_run_free only marks an in-progress case complete; per the
171
+ # header it does not free the test-case handle itself. That
172
+ # handle is this loop's own to release, which #run_case does via
173
+ # #with_test_case's own `ensure` on every path, including a fatal
174
+ # exception raised from inside #drive.
175
+ impl.run_free(ctx, run)
187
176
  end
188
177
 
189
178
  # Pulls test cases from +run+ until hegel_next_test_case reports none
190
179
  # left (a nil out-parameter, not an error). Never counts iterations
191
180
  # 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)
181
+ # and rejected cases call the body. Measured against libhegel 0.45.0, a
182
+ # run configured for 20 test cases whose body rejected nearly every case
183
+ # took 1040 to 1537 iterations. +stats+ does its own, different counting
184
+ # -- see GenerationStats above.
185
+ def drive(impl, ctx, run, stats, captures, &block)
197
186
  loop do
198
187
  tc = impl.next_test_case(ctx, run)
199
188
  break if tc.nil?
200
189
 
201
- run_case(impl, ctx, tc, stats, &block)
190
+ run_case(impl, ctx, tc, stats, captures, &block)
202
191
  end
203
192
  end
204
193
 
205
194
  # Builds a Hegel::TestCase for +tc+ and yields it, then frees what it
206
195
  # opened: every pool it recorded (Hegel::TestCase#free_pools), then the
207
196
  # 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.
197
+ # raises. docs/adr/0011 decides a test case owns every pool built from
198
+ # it, and that pools free before the handle that owns them goes.
212
199
  #
213
200
  # The nested `ensure` is what keeps those two releases independent. A
214
201
  # raise out of #free_pools would otherwise carry past the handle's own
@@ -216,77 +203,97 @@ module Hegel
216
203
  # context to be freed first, so one skipped release does not stop at one
217
204
  # leak -- it fails the context's release too, at the end of a run that
218
205
  # 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)
206
+ #
207
+ # The case records its draws only when the engine stamped it for
208
+ # capture. The stamp is read in here, inside the `ensure`, so the handle
209
+ # is freed even when reading it raises.
210
+ def with_test_case(impl, ctx, tc)
211
+ test_case = TestCase.new(impl, ctx, tc, record: impl.test_case_should_capture(ctx, tc))
221
212
  yield test_case
222
213
  ensure
223
214
  begin
224
- test_case.free_pools
215
+ test_case&.free_pools
225
216
  ensure
226
217
  impl.test_case_free(ctx, tc)
227
218
  end
228
219
  end
229
220
 
230
221
  # 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)
222
+ # counts it into +stats+, keeps a failure in +captures+, and reports the
223
+ # outcome with hegel_mark_complete. Only a stamped case records its
224
+ # draws (see #with_test_case): a stamped case's failure is what a report
225
+ # shows, and naming a drawn value costs a read of the caller's source,
226
+ # which every shrink probe would otherwise pay for. A fatal
227
+ # exception (#classify re-raises those before returning) skips the rest
228
+ # and still reaches #with_test_case's own `ensure`, so the handle is
229
+ # freed either way; its owner is this loop, not hegel_run_free (see
230
+ # #run_and_finish's comment above).
231
+ def run_case(impl, ctx, tc, stats, captures, &block)
237
232
  with_test_case(impl, ctx, tc) do |test_case|
238
- status, origin = classify(test_case, &block)
233
+ status, origin, exception, entries = classify(test_case, &block)
239
234
  stats.record(status, origin)
235
+ keep_capture(captures, origin, exception, entries) if status == LibHegel::HEGEL_STATUS_INTERESTING
240
236
  impl.mark_complete(ctx, tc, status, origin)
241
237
  end
242
238
  end
243
239
 
240
+ # Keeps the newest capture for each origin, except that an unstamped
241
+ # failure (+entries+ nil) never replaces a stamped one. The engine runs
242
+ # every failure it reports once more at the end of the run, stamped, so
243
+ # the last stamped capture is that final replay, and an earlier stamped
244
+ # one only stands in when the final replay was dry. An unstamped capture
245
+ # still gives the run an exception to re-raise when nothing was stamped.
246
+ # hegel-rust ranks its captures the same way: a newer capture replaces
247
+ # an older one of the same rank or lower.
248
+ def keep_capture(captures, origin, exception, entries)
249
+ return if entries.nil? && captures[origin]&.entries
250
+
251
+ captures[origin] = Capture.new(exception, entries)
252
+ end
253
+
244
254
  # 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)
255
+ # unless the run was replaying a blob, where it means the blob no longer
256
+ # reproduces. ERROR raises Hegel::Error from hegel_run_result_error's
257
+ # message, and FAILED hands off to #report_failures. Any other status
258
+ # would mean this binding does not recognise a hegel_run_status_t value
259
+ # the loaded engine returned, mirroring how LibHegel.check! names an
260
+ # unrecognised result code instead of silently doing nothing with it.
261
+ def finish(impl, ctx, result, stats, captures, reproducing:, quiet:, output:)
251
262
  status = impl.run_result_status(ctx, result)
252
263
  case status
253
264
  when LibHegel::HEGEL_RUN_STATUS_PASSED
254
- nil
265
+ raise Hegel::Error, not_reproduced_message if reproducing
255
266
  when LibHegel::HEGEL_RUN_STATUS_ERROR
256
267
  raise Hegel::Error, impl.run_result_error(ctx, result) || UNKNOWN_RUN_ERROR_MESSAGE
257
268
  when LibHegel::HEGEL_RUN_STATUS_FAILED
258
- replay(impl, ctx, settings, result, stats, quiet: quiet, output: output, &block)
269
+ report_failures(impl, ctx, result, stats, captures, quiet: quiet, output: output)
259
270
  else
260
271
  raise Hegel::Error, "hegel: run finished with an unrecognized status (#{status})"
261
272
  end
262
273
  end
263
274
 
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
+ # Reports every failure the run found from what #run_case captured for
276
+ # its origin, then raises: one failure re-raises its own kept exception,
277
+ # unaltered; two or more raise Hegel::Error with
278
+ # #multiple_failures_message instead, since no single one of several
279
+ # kept exceptions is more the run's own verdict than another.
280
+ # hegel-rust's own src/run_lifecycle.rs (the HEGEL_RUN_STATUS_FAILED
281
+ # branch) makes the same choice: one panic re-raised as itself, several
282
+ # replaced by one panic carrying just the count.
275
283
  #
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
284
+ # #finish only reaches here on a FAILED run, and a FAILED run always
285
+ # carries at least one failure, so neither raise needs a zero-failures
286
+ # guard. The report is written before either raise, not after: a host
281
287
  # framework that catches the re-raised exception would otherwise read
282
288
  # its own output before this one, out of order.
283
- def replay(impl, ctx, settings, result, stats, quiet:, output:, &block)
284
- kept_exception = nil
289
+ def report_failures(impl, ctx, result, stats, captures, quiet:, output:)
290
+ exceptions = []
285
291
  failures = []
286
292
  impl.run_result_failure_count(ctx, result).times do |index|
287
293
  failure = impl.run_result_failure(ctx, result, index)
288
294
  begin
289
- kept_exception, report = replay_failure(impl, ctx, settings, failure, index, stats, &block)
295
+ exception, report = failure_report(impl, ctx, failure, stats, captures)
296
+ exceptions << exception
290
297
  failures << report
291
298
  ensure
292
299
  impl.failure_free(ctx, failure)
@@ -295,89 +302,34 @@ module Hegel
295
302
  output.puts(Report.render(failures)) unless quiet
296
303
  raise Hegel::Error, multiple_failures_message(failures.size) if failures.size > 1
297
304
 
298
- raise kept_exception
305
+ raise exceptions.first
299
306
  end
300
307
 
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
308
+ # [exception, Hegel::Report::Failure] for one failure. The engine groups
309
+ # a failure under the origin #run_case reported, and #run_case saw every
310
+ # case the run produced, so every failure has a capture under its origin.
311
+ # A capture with no entries came from an unstamped case, whose draws
312
+ # went unrecorded, so its report lists no values.
313
+ def failure_report(impl, ctx, failure, stats, captures)
314
+ origin = impl.failure_origin(ctx, failure)
315
+ capture = captures.fetch(origin)
316
+ test_cases, discarded = stats.for(origin)
317
+ report = Report::Failure.new(
318
+ test_cases: test_cases, discarded: discarded, entries: capture.entries || [],
319
+ blob: impl.failure_reproduction_blob(ctx, failure), caveat: impl.failure_caveat(ctx, failure)
320
+ )
321
+ [capture.exception, report]
370
322
  end
371
323
 
372
324
  # Runs +block+ against +test_case+ (a Hegel::TestCase #with_test_case
373
325
  # 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.
326
+ # outcome into the [hegel_status_t, origin, exception, entries] #run_case
327
+ # needs. +entries+ is only ever non-nil when +test_case+ was built to
328
+ # record and the outcome was INTERESTING, since that is the only
329
+ # combination #run_case reads it for. Order matters: a fatal exception
330
+ # must be re-raised before it reaches the library's own control
331
+ # exceptions, which must themselves be told apart from an ordinary
332
+ # exception before the catch-all below.
381
333
  #
382
334
  # standard:disable Lint/RescueException -- `rescue Exception` is
383
335
  # deliberate: Minitest::Assertion and RSpec's ExpectationNotMetError both
@@ -386,6 +338,9 @@ module Hegel
386
338
  # being reported as a Hegel failure.
387
339
  def classify(test_case, &block)
388
340
  block.call(test_case)
341
+ # No caller reads a VALID, INVALID, or OVERRUN tuple past its first two
342
+ # elements. .mutineer.yml ignores the mutants of the last two by id, since
343
+ # a line marker would also hide the killed mutant of the second.
389
344
  [LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
390
345
  rescue *Hegel::FATAL_EXCEPTIONS
391
346
  raise
@@ -435,17 +390,14 @@ module Hegel
435
390
  path.start_with?(STDLIB_DIR)
436
391
  end
437
392
 
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."
393
+ # A replayed blob that never failed. Like hegel-rust's own message for
394
+ # the same outcome, it names both explanations.
395
+ def not_reproduced_message
396
+ "hegel: the blob passed to reproduce_failure did not reproduce a failure. " \
397
+ "Either the bug is fixed, or the test body is nondeterministic and the failure did not recur."
446
398
  end
447
399
 
448
- # #replay's own multi-failure raise, and Report.render's multi-failure
400
+ # #report_failures' own multi-failure raise, and Report.render's multi-failure
449
401
  # heading, must stay the same sentence: a caller who greps the printed
450
402
  # report for this text should find the same string in the exception it
451
403
  # catches. Report.render builds that heading itself rather than calling
@@ -453,10 +405,10 @@ module Hegel
453
405
  # and does not know how a run ended (see its own class comment); the
454
406
  # sentence is duplicated here as the coupling between the two, not
455
407
  # factored into a shared helper neither module already depends on.
456
- # Deliberately carries no "hegel: " prefix, unlike #flaky_message and
408
+ # Deliberately carries no "hegel: " prefix, unlike #not_reproduced_message and
457
409
  # 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.
410
+ # MESSAGE and the unrecognized-status message), to stay that same
411
+ # sentence.
460
412
  def multiple_failures_message(count)
461
413
  "Property-based test failed with #{count} distinct failures."
462
414
  end
@@ -5,13 +5,13 @@ require_relative "lib_hegel"
5
5
 
6
6
  module Hegel
7
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.
8
+ # Most keywords follow one rule: nil means "call no setter", so a caller of
9
+ # Hegel.test who passes none of them gets the settings profile the engine
10
+ # resolved: libhegel's own defaults, unless a hegel.toml, a HEGEL_*
11
+ # environment variable, or the engine's ci profile on a CI server says
12
+ # otherwise (see docs/adr/0023). +database+ and +database_key+ follow the
13
+ # table #apply_database documents instead, and +report_multiple_failures+
14
+ # has no nil case at all -- see #apply's own comment for why.
15
15
  module Settings
16
16
  # Hegel.test's verbosity: values, mapped to hegel.h's hegel_verbosity_t.
17
17
  VERBOSITY_CODES = {
@@ -52,13 +52,9 @@ module Hegel
52
52
  # with no nil case: Hegel.test defaults it to false rather than leaving
53
53
  # it nil, so this method never sees nil for it -- see Hegel::Runner.run's
54
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.
55
+ # nil-means-engine-default rule.
60
56
  def apply(impl, ctx, settings, test_cases:, seed:, derandomize:, verbosity:, database:, database_key:, phases:,
61
- suppress_health_check:, report_multiple_failures:, stateful_step_count:)
57
+ suppress_health_check:, report_multiple_failures:)
62
58
  impl.settings_set_test_cases(ctx, settings, test_cases) unless test_cases.nil?
63
59
  impl.settings_set_seed(ctx, settings, seed, true) unless seed.nil?
64
60
  impl.settings_set_derandomize(ctx, settings, derandomize) unless derandomize.nil?
@@ -67,7 +63,6 @@ module Hegel
67
63
  apply_phases(impl, ctx, settings, phases) unless phases.nil?
68
64
  apply_suppress_health_check(impl, ctx, settings, suppress_health_check) unless suppress_health_check.nil?
69
65
  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
66
  end
72
67
 
73
68
  # Split from #apply so the VERBOSITY_CODES lookup (one of several
@@ -23,6 +23,10 @@ module Hegel
23
23
  # class's.
24
24
  include Syntax::Methods
25
25
 
26
+ # One declared invariant: its block, and whether it runs after every
27
+ # round rather than when the engine samples it.
28
+ Invariant = Data.define(:block, :always_run)
29
+
26
30
  class << self
27
31
  # Declares a rule named +name+: an action the engine may pick to run
28
32
  # at any step. +block+ runs via #instance_exec against the machine
@@ -33,11 +37,14 @@ module Hegel
33
37
  declare(:@rules, "rule", name, block)
34
38
  end
35
39
 
36
- # Declares an invariant named +name+, checked once before the first
37
- # rule runs and again after every rule that completes without its own
38
- # assumption failing. Same block/argument contract as #rule.
39
- def invariant(name, &block)
40
- declare(:@invariants, "invariant", name, block)
40
+ # Declares an invariant named +name+, checked before the first rule
41
+ # runs, after the last, and in between at the join points where the
42
+ # engine samples it: each one with probability 1 / step_count, so
43
+ # about once per full-length test case. +always_run+ checks it at
44
+ # every join point instead, the name hegel-rust and hegel-java give
45
+ # the same option. Same block/argument contract as #rule.
46
+ def invariant(name, always_run: false, &block)
47
+ declare(:@invariants, "invariant", name, Invariant.new(block: block, always_run: always_run))
41
48
  end
42
49
 
43
50
  # name => block, in declaration order, this class's own declarations
@@ -47,7 +54,7 @@ module Hegel
47
54
  merged_definitions(:@rules, :rule_definitions)
48
55
  end
49
56
 
50
- # The invariant analogue of #rule_definitions.
57
+ # The invariant analogue of #rule_definitions: name => Invariant.
51
58
  def invariant_definitions
52
59
  merged_definitions(:@invariants, :invariant_definitions)
53
60
  end
@@ -62,12 +69,12 @@ module Hegel
62
69
  # reads an ancestor's table, so a subclass re-declaring an inherited
63
70
  # name is the ordinary "redefine a method" case the ADR calls out,
64
71
  # not this one.
65
- def declare(ivar, kind, name, block)
72
+ def declare(ivar, kind, name, definition)
66
73
  table = instance_variable_get(ivar) || instance_variable_set(ivar, {})
67
74
  name = name.to_s
68
75
  raise Hegel::Error, "hegel: #{kind} #{name.inspect} is already declared on #{self}" if table.key?(name)
69
76
 
70
- table[name] = block
77
+ table[name] = definition
71
78
  end
72
79
 
73
80
  # Shared by #rule_definitions/#invariant_definitions: this class's own
@@ -71,7 +71,6 @@ module Hegel
71
71
  # of this library's own public generator vocabulary.
72
72
  class ValuesReusable < Generator
73
73
  def initialize(pool, values)
74
- super()
75
74
  @pool = pool
76
75
  @values = values
77
76
  end
@@ -94,7 +93,6 @@ module Hegel
94
93
  # pre-checking emptiness.
95
94
  class ValuesConsumed < Generator
96
95
  def initialize(pool, values)
97
- super()
98
96
  @pool = pool
99
97
  @values = values
100
98
  end