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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +264 -0
- data/Rakefile +19 -0
- data/docs/README.md +25 -0
- data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
- data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
- data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
- data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
- data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
- data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
- data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
- data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
- data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
- data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
- data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
- data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
- data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
- data/docs/architecture.md +182 -0
- data/lib/hegel/draw_name.rb +109 -0
- data/lib/hegel/errors.rb +47 -0
- data/lib/hegel/generator.rb +98 -0
- data/lib/hegel/generators.rb +865 -0
- data/lib/hegel/lib_hegel/real.rb +1149 -0
- data/lib/hegel/lib_hegel.rb +269 -0
- data/lib/hegel/libhegel_version.rb +9 -0
- data/lib/hegel/locate.rb +188 -0
- data/lib/hegel/report.rb +87 -0
- data/lib/hegel/runner.rb +464 -0
- data/lib/hegel/settings.rb +164 -0
- data/lib/hegel/state_machine.rb +89 -0
- data/lib/hegel/stateful/pool.rb +111 -0
- data/lib/hegel/stateful.rb +120 -0
- data/lib/hegel/syntax/methods.rb +173 -0
- data/lib/hegel/test_case.rb +523 -0
- data/lib/hegel/version.rb +5 -0
- data/lib/hegel.rb +92 -0
- data/lib/hegeltest.rb +7 -0
- data/lib/tasks/libhegel.rake +112 -0
- data/lib/tasks/platform_gems.rake +111 -0
- data/sig/hegel.rbs +563 -0
- data/skills/hegel-ruby/SKILL.md +30 -0
- data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
- metadata +113 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
`Hegel.test` runs. It drives a property to a verdict, shrinks a failure to
|
|
6
|
+
its minimal counterexample, and re-raises that case's own exception with
|
|
7
|
+
its class intact.
|
|
8
|
+
|
|
9
|
+
What exists: library resolution (`Hegel::Locate`, the pinned
|
|
10
|
+
`Hegel::LIBHEGEL_VERSION`, and the development-only `libhegel:fetch` Rake
|
|
11
|
+
task), the libhegel binding (`Hegel::LibHegel`, with a real implementation
|
|
12
|
+
over `ffi` and a fake for tests), the settings mapping, `Hegel::TestCase`
|
|
13
|
+
wrapping the engine's whole draw surface, and the run loop.
|
|
14
|
+
|
|
15
|
+
What exists beyond that: the failure report, which names drawn values by
|
|
16
|
+
reading the caller's own source, interleaves them with whatever `tc.note`
|
|
17
|
+
recorded, and prints a blob that `Hegel.test(reproduce_failure:)` replays.
|
|
18
|
+
The generator layer covers twenty-five generators composing through `map`
|
|
19
|
+
and `filter`, reachable bare through `Hegel::Syntax::Methods`. A test case
|
|
20
|
+
can discard itself with `tc.assume` or `tc.reject`. A run can be shaped by
|
|
21
|
+
`phases:`, `suppress_health_check:`, `report_multiple_failures:`, and the
|
|
22
|
+
example database, through `database_key:` and `database:`.
|
|
23
|
+
[ADR 0009](adr/0009-turn-the-example-database-on-with-a-key.md) decides the
|
|
24
|
+
database keywords.
|
|
25
|
+
|
|
26
|
+
`tc.target` records an observation for the engine to search toward, and
|
|
27
|
+
`Hegel::Stateful.run` drives a `Hegel::StateMachine`'s rules and invariants,
|
|
28
|
+
drawing values an earlier rule produced back out of a
|
|
29
|
+
`Hegel::Stateful::Pool`. [ADR 0010](adr/0010-declare-stateful-rules-with-a-class-macro.md)
|
|
30
|
+
decides how a machine declares its rules and
|
|
31
|
+
[ADR 0011](adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md) who
|
|
32
|
+
frees a pool.
|
|
33
|
+
|
|
34
|
+
Every feature this binding set out to cover now has a Ruby surface. What
|
|
35
|
+
remains open is not a feature but a measurement, and
|
|
36
|
+
[ADR 0008](adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md)
|
|
37
|
+
schedules it.
|
|
38
|
+
|
|
39
|
+
## Where meaning comes from
|
|
40
|
+
|
|
41
|
+
libhegel is hegel-rust's native engine. hegel-rust's Cargo workspace builds
|
|
42
|
+
it from the `hegel-c` directory, as the crate `hegeltest-c`, whose header is
|
|
43
|
+
`hegel-c/include/hegel.h`. That header defines the ABI this gem calls: what
|
|
44
|
+
each function does, what its error codes mean, and who owns each pointer.
|
|
45
|
+
|
|
46
|
+
The Java, Go, TypeScript, OCaml, and C++ implementations bind the same
|
|
47
|
+
engine. Each works under a constraint hegel-rust does not have: a dynamic
|
|
48
|
+
loader, a garbage collector, or a package manager that ships prebuilt
|
|
49
|
+
binaries. Where this document cites one of them, it cites a binding
|
|
50
|
+
mechanism, not the meaning of a call.
|
|
51
|
+
|
|
52
|
+
## libhegel and the Ruby boundary
|
|
53
|
+
|
|
54
|
+
libhegel runs in the same process as its caller. Nearly every function in
|
|
55
|
+
`hegel.h` takes a `hegel_context_t*` as its first argument and returns a
|
|
56
|
+
`hegel_result_t` status code. `hegel_context_new` returns the context
|
|
57
|
+
itself instead. `hegel_context_last_error` returns a borrowed message
|
|
58
|
+
pointer instead of a status code.
|
|
59
|
+
|
|
60
|
+
Ruby calls this ABI through the `ffi` gem. See
|
|
61
|
+
[ADR 0013](adr/0013-bind-libhegel-through-the-ffi-gem.md), which supersedes
|
|
62
|
+
[ADR 0001](adr/0001-bind-libhegel-through-fiddle.md).
|
|
63
|
+
|
|
64
|
+
## The binding confined to one module
|
|
65
|
+
|
|
66
|
+
Every raw `ffi` call lives in `Hegel::LibHegel`. The rest of the
|
|
67
|
+
library calls that module's wrappers, never `ffi` directly. This gives
|
|
68
|
+
the library one seam where a test can substitute a fake implementation,
|
|
69
|
+
exercising libhegel's error codes without loading the real engine.
|
|
70
|
+
|
|
71
|
+
## Run loop ownership
|
|
72
|
+
|
|
73
|
+
The Ruby side owns the run loop. It starts a run, asks libhegel for
|
|
74
|
+
each test case, calls the caller's test body, and reports the result back.
|
|
75
|
+
A test body's own assertion failure must not cross the C boundary as if it
|
|
76
|
+
were a crash.
|
|
77
|
+
|
|
78
|
+
Ruby's test frameworks complicate this. With minitest 5.27.0, the version
|
|
79
|
+
this gem's `Gemfile.lock` pins, `Minitest::Assertion.superclass` is
|
|
80
|
+
`Exception`. With rspec-expectations 3.13.5,
|
|
81
|
+
`RSpec::Expectations::ExpectationNotMetError.superclass` is `Exception`
|
|
82
|
+
as well. By default, `rescue => e` catches only `StandardError`. A run
|
|
83
|
+
loop written that way would let a failing assertion pass by uncaught,
|
|
84
|
+
instead of being reported as a Hegel failure.
|
|
85
|
+
|
|
86
|
+
Read the superclass rather than the full ancestor list. `ancestors` also
|
|
87
|
+
reports what a given load order mixed into `Object`, so it answers a
|
|
88
|
+
different question on a run that loaded `minitest/spec` than on one that
|
|
89
|
+
did not.
|
|
90
|
+
|
|
91
|
+
The run loop `rescue`s `Exception` and re-raises the library's own control
|
|
92
|
+
exceptions first. Those control exceptions themselves descend from
|
|
93
|
+
`Exception`, not `StandardError`, so a test body's own `rescue => e` cannot
|
|
94
|
+
swallow them mid-run. `Hegel::Error`, which reports ordinary library errors
|
|
95
|
+
to a caller outside a run, stays a `StandardError`.
|
|
96
|
+
|
|
97
|
+
## Native handle lifetime
|
|
98
|
+
|
|
99
|
+
Every `hegel_*_free` function in `hegel.h` takes the handle's owning
|
|
100
|
+
context as an argument, so the context must outlive every handle allocated
|
|
101
|
+
from it. Ruby's garbage collector does not guarantee finalizer ordering
|
|
102
|
+
between a context and its handles.
|
|
103
|
+
|
|
104
|
+
The code that owns a run releases its handles in an `ensure` block,
|
|
105
|
+
freeing every other handle before the context. A finalizer may back up an
|
|
106
|
+
`ensure` block that a raised exception skipped, but it must free nothing a
|
|
107
|
+
prior `ensure` already freed.
|
|
108
|
+
|
|
109
|
+
A pool is the one handle a caller's own code opens, inside a rule, where
|
|
110
|
+
there is no `ensure` of its own to release it. `Hegel::TestCase` records
|
|
111
|
+
every pool opened from it and `Hegel::Runner` releases them alongside the
|
|
112
|
+
test-case handle, in nested `ensure`s so a failure releasing one cannot skip
|
|
113
|
+
the other. See
|
|
114
|
+
[ADR 0011](adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md).
|
|
115
|
+
|
|
116
|
+
## A rule's own control flow
|
|
117
|
+
|
|
118
|
+
Inside a stateful rule, `tc.assume(false)` means something narrower than it
|
|
119
|
+
does in a test body. `Hegel::Stateful` catches it, tells libhegel the rule
|
|
120
|
+
was rejected so the attempt does not spend a step, and draws another rule;
|
|
121
|
+
the test case continues. `Hegel::Runner.classify` never sees it, and no case
|
|
122
|
+
is discarded.
|
|
123
|
+
|
|
124
|
+
A rule is also the one place in this library where a process-ending
|
|
125
|
+
exception can be raised inside an open span, which is why
|
|
126
|
+
`Hegel::FATAL_EXCEPTIONS` lives beside the other control exceptions rather
|
|
127
|
+
than beside the run loop: two `rescue Exception` sites need it now, and both
|
|
128
|
+
have to let those four straight through before anything else.
|
|
129
|
+
|
|
130
|
+
## Generator validation happens at draw time
|
|
131
|
+
|
|
132
|
+
hegel-rust's own contributor documentation states the rule for its
|
|
133
|
+
generators: every invalid combination of builder values must be caught at
|
|
134
|
+
draw time. The check does not run when the generator is built. The same
|
|
135
|
+
documentation treats a validation message as public API, asserted against
|
|
136
|
+
by tests as a stable substring. `integers(min_value: 5, max_value: 1)`
|
|
137
|
+
therefore returns a generator; the error arrives from `tc.draw`.
|
|
138
|
+
|
|
139
|
+
## Passing a struct by value
|
|
140
|
+
|
|
141
|
+
Exactly three ABI functions take a struct by value: `hegel_generate_date`,
|
|
142
|
+
`hegel_generate_time`, and `hegel_generate_datetime`, each receiving two
|
|
143
|
+
bound values.
|
|
144
|
+
|
|
145
|
+
Each struct is declared as an `FFI::Struct` and passed with `.by_value`, so
|
|
146
|
+
libffi classifies the argument from the same C-ABI rules the compiled
|
|
147
|
+
library was built against. Nothing here computes a field offset or a size,
|
|
148
|
+
and nothing here decides how a given ABI passes a struct of a given width.
|
|
149
|
+
That matters most for the sixteen-byte `hegel_datetime_t`, where arm64 and
|
|
150
|
+
System V use two registers and Win64 passes by reference.
|
|
151
|
+
|
|
152
|
+
The layout is still worth a test, but a different one: the declaration is
|
|
153
|
+
transcribed from `hegel.h` by hand, so a degenerate draw (`min == max`)
|
|
154
|
+
against the real engine, with every field set to a distinct value, is what
|
|
155
|
+
catches a transcription that disagrees with the header. Boundary values are
|
|
156
|
+
not enough, since a value like `00:00:00` is symmetric under swapping two
|
|
157
|
+
fields.
|
|
158
|
+
|
|
159
|
+
## Open questions
|
|
160
|
+
|
|
161
|
+
- Whether the vendored `linux` binaries run under musl (Alpine) has not
|
|
162
|
+
been checked. CI runs on Ubuntu, which is glibc.
|
|
163
|
+
- This project has not traced which directories `ffi`'s `dlopen`-based
|
|
164
|
+
loading searches on Windows. The suite loads the engine from the path
|
|
165
|
+
`Hegel::Locate` resolves, on both Windows runners.
|
|
166
|
+
|
|
167
|
+
## Answered
|
|
168
|
+
|
|
169
|
+
A drawn string arrives as a pointer and a length, is not NUL-terminated,
|
|
170
|
+
and can hold interior NUL bytes, because the drawn alphabet can include
|
|
171
|
+
U+0000. Read by length. Measured against 0.32.5: an alphabet pinned to
|
|
172
|
+
U+0000 produces a three-byte draw that reads as `""` when taken up to the
|
|
173
|
+
first NUL and as three NUL bytes when taken by its reported length. Every
|
|
174
|
+
string observed reports `valid_encoding?` after `force_encoding(UTF-8)`.
|
|
175
|
+
|
|
176
|
+
Prism recovers a draw's assignment target through a method chain, an
|
|
177
|
+
instance-variable assignment, and an assignment spanning several lines.
|
|
178
|
+
Where several assignments cover the draw's line, the innermost one names
|
|
179
|
+
it, so wrapping a body in `assert_raises do ... end` or an RSpec
|
|
180
|
+
`expect { ... }` keeps the name. Two assignments side by side on one line
|
|
181
|
+
name nothing, and so does a draw whose value is never assigned; both fall
|
|
182
|
+
back to a number.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hegel
|
|
6
|
+
# Recovers a drawn value's variable name from the caller's own source (see
|
|
7
|
+
# docs/adr/0005), so a failure report can print `n = 501` instead of
|
|
8
|
+
# `draw = 501` when the caller never passed a label:. This module only
|
|
9
|
+
# answers "what name does path:lineno assign a value to"; deciding
|
|
10
|
+
# whether to call it, and what to fall back to when it answers nil, is
|
|
11
|
+
# Hegel::TestCase#name_for's job, not this one's.
|
|
12
|
+
module DrawName
|
|
13
|
+
# Node types #for treats as "this line names a drawn value". An
|
|
14
|
+
# explicit list, not every Prism::Node subclass whose name ends in
|
|
15
|
+
# WriteNode, because a wrong guess here (e.g. matching a constant
|
|
16
|
+
# assignment as if it named a draw) would misname a report entry -- the
|
|
17
|
+
# one outcome this feature must never risk. Local and instance
|
|
18
|
+
# variables both expose the assigned name the same way (a Symbol,
|
|
19
|
+
# including the leading "@" for an ivar), so one code path reads both.
|
|
20
|
+
ASSIGNMENT_NODE_TYPES = [Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# The name +path+:+lineno+ assigns a drawn value to, or nil when that
|
|
25
|
+
# cannot be answered confidently: +path+ cannot be read, Prism cannot
|
|
26
|
+
# parse it, or the assignments covering +lineno+ do not single one out.
|
|
27
|
+
# A wrong name would misdirect a reader of the failure report more than
|
|
28
|
+
# a missing one would, so an ambiguous line returns nil rather than
|
|
29
|
+
# guessing between candidates.
|
|
30
|
+
#
|
|
31
|
+
# Several assignments can cover one line by nesting rather than by
|
|
32
|
+
# ambiguity. `error = assert_raises do ... n = tc.draw_integer(...) ...
|
|
33
|
+
# end` puts the draw inside both, and a reader has no doubt which one
|
|
34
|
+
# names it. So the innermost wins: an enclosing assignment is discarded
|
|
35
|
+
# whenever another candidate sits inside it. Two assignments written
|
|
36
|
+
# side by side on one line contain neither the other, nothing singles
|
|
37
|
+
# one out, and the answer is nil.
|
|
38
|
+
def for(path, lineno)
|
|
39
|
+
program = parse(path)
|
|
40
|
+
return nil unless program
|
|
41
|
+
|
|
42
|
+
matches = assignment_nodes(program).select { |node| covers?(node.location, lineno) }
|
|
43
|
+
innermost = matches.reject { |node| matches.any? { |other| encloses?(node, other) } }
|
|
44
|
+
innermost.one? ? innermost.first.name.to_s : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Drops every cached parse. #for's only state; a fresh process would
|
|
48
|
+
# never need this, but a test process that calls #for many times over
|
|
49
|
+
# the life of the suite does, both to isolate one test's fixture file
|
|
50
|
+
# from another's and to prove #parse only reads a given path once (see
|
|
51
|
+
# #parse).
|
|
52
|
+
def reset_cache
|
|
53
|
+
@cache = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# +path+'s parsed Prism::ProgramNode, or nil if it could not be read or
|
|
57
|
+
# parsed. Cached either way, success or nil, because a single failure
|
|
58
|
+
# report names every draw the final replay recorded against the same
|
|
59
|
+
# one file, and nothing about that file changes between those lookups.
|
|
60
|
+
def parse(path)
|
|
61
|
+
cache.fetch(path) { cache[path] = read_and_parse(path) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def cache
|
|
65
|
+
@cache ||= {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reads and parses +path+. Returns nil, not the ParseResult, for either
|
|
69
|
+
# failure mode #for's caller cares about: +path+ raising SystemCallError
|
|
70
|
+
# (a nonexistent path -- eval, "-e", irb, or a file removed since the
|
|
71
|
+
# caller was compiled) and Prism reporting a syntax error (#success?
|
|
72
|
+
# false) for a path that did exist.
|
|
73
|
+
def read_and_parse(path)
|
|
74
|
+
result = Prism.parse(File.read(path))
|
|
75
|
+
result.success? ? result.value : nil
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Every ASSIGNMENT_NODE_TYPES node under +node+, found by walking the
|
|
81
|
+
# whole tree: a drawn value's assignment can be nested arbitrarily deep
|
|
82
|
+
# (inside a block, a method body, a conditional), and this module has
|
|
83
|
+
# no way to know which nesting level to expect it at.
|
|
84
|
+
def assignment_nodes(node, matches = [])
|
|
85
|
+
matches << node if ASSIGNMENT_NODE_TYPES.include?(node.class)
|
|
86
|
+
node.compact_child_nodes.each { |child| assignment_nodes(child, matches) }
|
|
87
|
+
matches
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Range containment, not a start-line match: Ruby's own caller_locations
|
|
91
|
+
# reports the line a call's own last token is written on, and a
|
|
92
|
+
# multi-line assignment's start line can differ from that -- e.g.
|
|
93
|
+
# `n = tc\n .draw_integer(...)` reports the second line, but the
|
|
94
|
+
# assignment node's own start_line is the first.
|
|
95
|
+
def covers?(location, lineno)
|
|
96
|
+
(location.start_line..location.end_line).cover?(lineno)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether +outer+ strictly contains +inner+, by byte offset rather than
|
|
100
|
+
# by line: two assignments on one line share both line numbers, and only
|
|
101
|
+
# the offsets tell them apart from a genuine nesting.
|
|
102
|
+
def encloses?(outer, inner)
|
|
103
|
+
return false if outer.equal?(inner)
|
|
104
|
+
|
|
105
|
+
outer.location.start_offset <= inner.location.start_offset &&
|
|
106
|
+
inner.location.end_offset <= outer.location.end_offset
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/hegel/errors.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hegel
|
|
4
|
+
# Raised for errors this library detects: a libhegel call that fails, a
|
|
5
|
+
# generator built with arguments the engine rejects, a missing native
|
|
6
|
+
# library. Control flow inside a running test case does not use this class;
|
|
7
|
+
# the control exceptions below descend from Exception instead, so that a
|
|
8
|
+
# `rescue => e` in a test body cannot swallow them.
|
|
9
|
+
#
|
|
10
|
+
# It lives here rather than in hegel.rb so that a file needing only the
|
|
11
|
+
# exception vocabulary can require this one file. Reaching it through
|
|
12
|
+
# hegel.rb would make every such file depend on the whole library, and
|
|
13
|
+
# hegel.rb requires the library back, which is a load-order cycle.
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# standard:disable Lint/InheritException
|
|
17
|
+
|
|
18
|
+
# Raised for HEGEL_E_STOP_TEST (-1): libhegel has exhausted its choice
|
|
19
|
+
# budget for the running test case and the caller must abort the test
|
|
20
|
+
# body immediately.
|
|
21
|
+
#
|
|
22
|
+
# Descends from Exception, not StandardError, so that a `rescue => e` (or
|
|
23
|
+
# a bare `rescue`) written in a user's test body cannot catch it. Only the
|
|
24
|
+
# code driving the test case is meant to catch this; letting a test body
|
|
25
|
+
# swallow it would turn "stop now" into "keep going".
|
|
26
|
+
class StopTest < Exception; end
|
|
27
|
+
|
|
28
|
+
# Raised for HEGEL_E_ASSUME (-2): an `assume` / `reject` precondition
|
|
29
|
+
# failed, so the current test case is invalid and must be discarded.
|
|
30
|
+
#
|
|
31
|
+
# Descends from Exception for the same reason as StopTest above.
|
|
32
|
+
class AssumeFailed < Exception; end
|
|
33
|
+
|
|
34
|
+
# standard:enable Lint/InheritException
|
|
35
|
+
|
|
36
|
+
# Say the process is ending, not that a property failed. Every
|
|
37
|
+
# `rescue Exception` in this library re-raises one of these before doing
|
|
38
|
+
# anything else: catching one as a counterexample would have the engine
|
|
39
|
+
# spend its shrink budget minimising an interrupt instead of letting the
|
|
40
|
+
# process exit, and NoMemoryError in particular must not be answered with
|
|
41
|
+
# another native call.
|
|
42
|
+
#
|
|
43
|
+
# Here rather than beside the run loop because two files now rescue
|
|
44
|
+
# Exception -- Hegel::Runner and Hegel::Stateful -- and this is the
|
|
45
|
+
# exception vocabulary both of them need, which is what this file is for.
|
|
46
|
+
FATAL_EXCEPTIONS = [Interrupt, SignalException, SystemExit, NoMemoryError].freeze
|
|
47
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "lib_hegel"
|
|
5
|
+
|
|
6
|
+
module Hegel
|
|
7
|
+
# Base class for every value generator (Hegel::Generators.integers,
|
|
8
|
+
# .booleans, .arrays, and so on) and the two combinators, #map and
|
|
9
|
+
# #filter, that build a new generator out of an existing one. Mirrors
|
|
10
|
+
# hegel-rust's Generator<T> trait: #do_draw is the one method a concrete
|
|
11
|
+
# generator implements, and #map/#filter come for free from this class
|
|
12
|
+
# rather than from each generator repeating them.
|
|
13
|
+
class Generator
|
|
14
|
+
# Draws one value using +tc+ (a Hegel::TestCase). Every concrete
|
|
15
|
+
# generator implements this; the base class only exists to raise a
|
|
16
|
+
# clear error for a subclass that forgot to.
|
|
17
|
+
def do_draw(_tc)
|
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #do_draw"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns a new Generator whose #do_draw runs +block+ on the value this
|
|
22
|
+
# generator drew, spanned with HEGEL_LABEL_MAPPED so the shrinker
|
|
23
|
+
# treats the source draw and the transform as one unit (see
|
|
24
|
+
# hegel-rust's Mapped, src/generators/generators.rs).
|
|
25
|
+
def map(&block)
|
|
26
|
+
Mapped.new(self, block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a new Generator whose #do_draw retries this generator until
|
|
30
|
+
# +block+ returns true for the drawn value, or discards the test case
|
|
31
|
+
# if it never does. See Filtered for the retry budget.
|
|
32
|
+
def filter(&block)
|
|
33
|
+
Filtered.new(self, block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Generator#map's result. A private implementation detail of #map, not
|
|
37
|
+
# part of this library's public generator vocabulary (unlike the five
|
|
38
|
+
# under Hegel::Generators), so it lives here rather than in
|
|
39
|
+
# generators.rb.
|
|
40
|
+
class Mapped < Generator
|
|
41
|
+
def initialize(source, block)
|
|
42
|
+
super()
|
|
43
|
+
@source = source
|
|
44
|
+
@block = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def do_draw(tc)
|
|
48
|
+
tc.start_span(LibHegel::HEGEL_LABEL_MAPPED)
|
|
49
|
+
@block.call(@source.do_draw(tc))
|
|
50
|
+
ensure
|
|
51
|
+
# discard is always false here: a span is only ever marked
|
|
52
|
+
# rejected to ask libhegel to retry it with different data (see
|
|
53
|
+
# Filtered below), and #map has no such retry -- an exception from
|
|
54
|
+
# +block+ or +source+ propagates past this method instead, and the
|
|
55
|
+
# span still has to close on the way out.
|
|
56
|
+
tc.stop_span(discard: false)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Generator#filter's result. Retries the source generator, each
|
|
61
|
+
# attempt in its own span, up to MAX_ATTEMPTS times before discarding
|
|
62
|
+
# the test case (Hegel::AssumeFailed) -- the same budget hegel-rust's
|
|
63
|
+
# Filtered spends before calling assume(false)
|
|
64
|
+
# (src/generators/generators.rs), ported here rather than reinvented so
|
|
65
|
+
# a predicate that (almost) never holds fails the same way in both
|
|
66
|
+
# bindings.
|
|
67
|
+
#
|
|
68
|
+
# A predicate that never holds does not loop forever: libhegel's own
|
|
69
|
+
# health check aborts a run that discards too many test cases in a
|
|
70
|
+
# row, which Hegel::Runner.finish surfaces as Hegel::Error, the same
|
|
71
|
+
# path an engine-level failure takes for any other reason.
|
|
72
|
+
class Filtered < Generator
|
|
73
|
+
MAX_ATTEMPTS = 3
|
|
74
|
+
|
|
75
|
+
def initialize(source, block)
|
|
76
|
+
super()
|
|
77
|
+
@source = source
|
|
78
|
+
@block = block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def do_draw(tc)
|
|
82
|
+
MAX_ATTEMPTS.times do
|
|
83
|
+
accepted = false
|
|
84
|
+
value = nil
|
|
85
|
+
tc.start_span(LibHegel::HEGEL_LABEL_FILTER)
|
|
86
|
+
begin
|
|
87
|
+
value = @source.do_draw(tc)
|
|
88
|
+
accepted = @block.call(value)
|
|
89
|
+
ensure
|
|
90
|
+
tc.stop_span(discard: !accepted)
|
|
91
|
+
end
|
|
92
|
+
return value if accepted
|
|
93
|
+
end
|
|
94
|
+
raise Hegel::AssumeFailed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|