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,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "locate"
|
|
5
|
+
require_relative "libhegel_version"
|
|
6
|
+
|
|
7
|
+
module Hegel
|
|
8
|
+
# The libhegel binding boundary: the small set of methods a connection to
|
|
9
|
+
# the native engine must answer to, plus the result-code translation and
|
|
10
|
+
# context lifecycle shared by every implementation.
|
|
11
|
+
#
|
|
12
|
+
# Ruby has no interface construct, so this module carries the contract two
|
|
13
|
+
# ways: METHODS names every method an implementation must answer to, and
|
|
14
|
+
# a conformance test (test/hegel/test_lib_hegel.rb) asserts that every
|
|
15
|
+
# implementation responds to all of them. LibHegel::Real drives the C ABI;
|
|
16
|
+
# test/support/fake_lib_hegel.rb (not shipped in the gem) is a second,
|
|
17
|
+
# configurable implementation, so the logic built on top of this boundary
|
|
18
|
+
# is testable without the native engine. hegel-java makes the same split,
|
|
19
|
+
# between its Libhegel interface, RealLibhegel, and FakeLibhegel.
|
|
20
|
+
module LibHegel
|
|
21
|
+
# hegel_result_t, named from hegel-c/include/hegel.h's enum of the same
|
|
22
|
+
# name. HEGEL_OK is success; every other value is a negative error code.
|
|
23
|
+
HEGEL_OK = 0
|
|
24
|
+
HEGEL_E_STOP_TEST = -1
|
|
25
|
+
HEGEL_E_ASSUME = -2
|
|
26
|
+
HEGEL_E_BACKEND = -3
|
|
27
|
+
HEGEL_E_INVALID_HANDLE = -4
|
|
28
|
+
HEGEL_E_INVALID_ARG = -5
|
|
29
|
+
HEGEL_E_ALREADY_COMPLETE = -6
|
|
30
|
+
HEGEL_E_NOT_COMPLETE = -7
|
|
31
|
+
HEGEL_E_INTERNAL = -8
|
|
32
|
+
HEGEL_E_CONCURRENT_USE = -9
|
|
33
|
+
|
|
34
|
+
# The hegel.h name for each code above, so a translated error message
|
|
35
|
+
# names the code instead of leaving the reader to cross-reference the
|
|
36
|
+
# header by number alone.
|
|
37
|
+
CODE_NAMES = {
|
|
38
|
+
HEGEL_OK => "HEGEL_OK",
|
|
39
|
+
HEGEL_E_STOP_TEST => "HEGEL_E_STOP_TEST",
|
|
40
|
+
HEGEL_E_ASSUME => "HEGEL_E_ASSUME",
|
|
41
|
+
HEGEL_E_BACKEND => "HEGEL_E_BACKEND",
|
|
42
|
+
HEGEL_E_INVALID_HANDLE => "HEGEL_E_INVALID_HANDLE",
|
|
43
|
+
HEGEL_E_INVALID_ARG => "HEGEL_E_INVALID_ARG",
|
|
44
|
+
HEGEL_E_ALREADY_COMPLETE => "HEGEL_E_ALREADY_COMPLETE",
|
|
45
|
+
HEGEL_E_NOT_COMPLETE => "HEGEL_E_NOT_COMPLETE",
|
|
46
|
+
HEGEL_E_INTERNAL => "HEGEL_E_INTERNAL",
|
|
47
|
+
HEGEL_E_CONCURRENT_USE => "HEGEL_E_CONCURRENT_USE"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# hegel_status_t, named from hegel.h's enum of the same name. Passed to
|
|
51
|
+
# hegel_mark_complete to describe how a test case ended.
|
|
52
|
+
HEGEL_STATUS_VALID = 0
|
|
53
|
+
HEGEL_STATUS_INVALID = 1
|
|
54
|
+
HEGEL_STATUS_OVERRUN = 2
|
|
55
|
+
HEGEL_STATUS_INTERESTING = 3
|
|
56
|
+
|
|
57
|
+
# hegel_run_status_t, named from hegel.h's enum of the same name. Read
|
|
58
|
+
# via hegel_run_result_status once a run has finished.
|
|
59
|
+
HEGEL_RUN_STATUS_PASSED = 0
|
|
60
|
+
HEGEL_RUN_STATUS_FAILED = 1
|
|
61
|
+
HEGEL_RUN_STATUS_ERROR = 2
|
|
62
|
+
|
|
63
|
+
# hegel_verbosity_t, named from hegel.h's enum of the same name. Passed
|
|
64
|
+
# to hegel_settings_set_verbosity.
|
|
65
|
+
HEGEL_VERBOSITY_QUIET = 0
|
|
66
|
+
HEGEL_VERBOSITY_NORMAL = 1
|
|
67
|
+
HEGEL_VERBOSITY_VERBOSE = 2
|
|
68
|
+
HEGEL_VERBOSITY_DEBUG = 3
|
|
69
|
+
|
|
70
|
+
# hegel_phase_t, named from hegel.h's enum of the same name. A bitwise
|
|
71
|
+
# OR of these is passed to hegel_settings_set_phases; the default is
|
|
72
|
+
# HEGEL_PHASE_ALL.
|
|
73
|
+
HEGEL_PHASE_EXPLICIT = 1
|
|
74
|
+
HEGEL_PHASE_REUSE = 2
|
|
75
|
+
HEGEL_PHASE_GENERATE = 4
|
|
76
|
+
HEGEL_PHASE_TARGET = 8
|
|
77
|
+
HEGEL_PHASE_SHRINK = 16
|
|
78
|
+
HEGEL_PHASE_ALL = 31
|
|
79
|
+
|
|
80
|
+
# hegel_health_check_t, named from hegel.h's enum of the same name. A
|
|
81
|
+
# bitwise OR of these is passed to
|
|
82
|
+
# hegel_settings_set_suppress_health_check; the default is all
|
|
83
|
+
# enabled.
|
|
84
|
+
HEGEL_HC_FILTER_TOO_MUCH = 1
|
|
85
|
+
HEGEL_HC_TOO_SLOW = 2
|
|
86
|
+
HEGEL_HC_TEST_CASES_TOO_LARGE = 4
|
|
87
|
+
HEGEL_HC_LARGE_INITIAL_TEST_CASE = 8
|
|
88
|
+
|
|
89
|
+
# hegel_label_t, named from hegel.h's enum of the same name. Passed to
|
|
90
|
+
# hegel_start_span to identify what kind of structure a span groups.
|
|
91
|
+
# Copied through HEGEL_LABEL_SET_CHOICE (value 33). The header
|
|
92
|
+
# describes the last two, HEGEL_LABEL_FRESH_ID and
|
|
93
|
+
# HEGEL_LABEL_SET_CHOICE, as spans the engine opens itself around a
|
|
94
|
+
# hegel_pool_add / hegel_pool_generate call; a caller never passes
|
|
95
|
+
# either to hegel_start_span.
|
|
96
|
+
#
|
|
97
|
+
# The header documents that "Libraries may use any stable u64 to
|
|
98
|
+
# define their own spans." A caller building its own compound
|
|
99
|
+
# generator on top of this boundary can pick any u64 that does not
|
|
100
|
+
# collide with the reserved values below.
|
|
101
|
+
HEGEL_LABEL_LIST = 1
|
|
102
|
+
HEGEL_LABEL_LIST_ELEMENT = 2
|
|
103
|
+
HEGEL_LABEL_SET = 3
|
|
104
|
+
HEGEL_LABEL_SET_ELEMENT = 4
|
|
105
|
+
HEGEL_LABEL_MAP = 5
|
|
106
|
+
HEGEL_LABEL_MAP_ENTRY = 6
|
|
107
|
+
HEGEL_LABEL_TUPLE = 7
|
|
108
|
+
HEGEL_LABEL_ONE_OF = 8
|
|
109
|
+
HEGEL_LABEL_OPTIONAL = 9
|
|
110
|
+
HEGEL_LABEL_FIXED_DICT = 10
|
|
111
|
+
HEGEL_LABEL_FLAT_MAP = 11
|
|
112
|
+
HEGEL_LABEL_FILTER = 12
|
|
113
|
+
HEGEL_LABEL_MAPPED = 13
|
|
114
|
+
HEGEL_LABEL_SAMPLED_FROM = 14
|
|
115
|
+
HEGEL_LABEL_ENUM_VARIANT = 15
|
|
116
|
+
HEGEL_LABEL_FEATURE_FLAG = 16
|
|
117
|
+
HEGEL_LABEL_REGEX = 17
|
|
118
|
+
HEGEL_LABEL_EMAIL = 18
|
|
119
|
+
HEGEL_LABEL_URL = 19
|
|
120
|
+
HEGEL_LABEL_DOMAIN = 20
|
|
121
|
+
HEGEL_LABEL_DATE = 21
|
|
122
|
+
HEGEL_LABEL_TIME = 22
|
|
123
|
+
HEGEL_LABEL_DATETIME = 23
|
|
124
|
+
HEGEL_LABEL_UUID = 24
|
|
125
|
+
HEGEL_LABEL_IP_ADDRESS = 25
|
|
126
|
+
HEGEL_LABEL_INTEGER = 26
|
|
127
|
+
HEGEL_LABEL_FLOAT = 27
|
|
128
|
+
HEGEL_LABEL_BOOLEAN = 28
|
|
129
|
+
HEGEL_LABEL_BYTES = 29
|
|
130
|
+
HEGEL_LABEL_STRING = 30
|
|
131
|
+
HEGEL_LABEL_STATEFUL_RULE = 31
|
|
132
|
+
HEGEL_LABEL_FRESH_ID = 32
|
|
133
|
+
HEGEL_LABEL_SET_CHOICE = 33
|
|
134
|
+
|
|
135
|
+
# HEGEL_STATE_MACHINE_DONE, named from hegel.h's #define of the same
|
|
136
|
+
# name. hegel_state_machine_next_rule writes this to its
|
|
137
|
+
# out_rule_index parameter once the current test case's step budget
|
|
138
|
+
# is exhausted; see LibHegel::Real#state_machine_next_rule for why
|
|
139
|
+
# that raw sentinel is returned rather than translated to nil.
|
|
140
|
+
HEGEL_STATE_MACHINE_DONE = -1
|
|
141
|
+
|
|
142
|
+
# hegel_new_collection's max_size accepts UINT64_MAX to mean "no upper
|
|
143
|
+
# bound", in the header's own words. Ruby has no fixed-width integer
|
|
144
|
+
# type to read that constant off, so it is spelled out here as the
|
|
145
|
+
# value a 64-bit unsigned integer maxes out at.
|
|
146
|
+
HEGEL_COLLECTION_MAX_SIZE_UNBOUNDED = (2**64) - 1
|
|
147
|
+
|
|
148
|
+
# hegel_generate_float's smallest_nonzero_magnitude must be positive
|
|
149
|
+
# and finite. The header names 5e-324 as the width-64 value that
|
|
150
|
+
# places no restriction on which nonzero magnitudes get drawn.
|
|
151
|
+
HEGEL_FLOAT64_SMALLEST_NONZERO_MAGNITUDE_UNRESTRICTED = 5e-324
|
|
152
|
+
|
|
153
|
+
# The methods every implementation of this boundary (Real, Fake) must
|
|
154
|
+
# answer to. Held as data, not a Ruby interface/protocol, because Ruby
|
|
155
|
+
# has none; test/hegel/test_lib_hegel.rb asserts every implementation
|
|
156
|
+
# responds to each name here.
|
|
157
|
+
METHODS = %i[
|
|
158
|
+
context_new context_free context_last_error version
|
|
159
|
+
settings_new settings_free settings_set_test_cases settings_set_verbosity
|
|
160
|
+
settings_set_seed settings_set_derandomize settings_set_database
|
|
161
|
+
run_start next_test_case run_free test_case_free mark_complete
|
|
162
|
+
generate_boolean generate_integer generate_integer_big
|
|
163
|
+
run_result run_result_free run_result_status run_result_error
|
|
164
|
+
run_result_failure_count run_result_failure failure_free failure_origin
|
|
165
|
+
failure_reproduction_blob test_case_from_blob
|
|
166
|
+
start_span stop_span
|
|
167
|
+
new_collection collection_more collection_reject collection_free
|
|
168
|
+
generate_float
|
|
169
|
+
string_generator_text string_generator_free generate_string generate_string_result_free
|
|
170
|
+
generate_bytes generate_bytes_result_free
|
|
171
|
+
string_generator_regex string_generator_email string_generator_url string_generator_domain
|
|
172
|
+
generate_ipv4 generate_ipv6 generate_uuid
|
|
173
|
+
generate_date generate_time generate_datetime
|
|
174
|
+
settings_set_phases settings_set_suppress_health_check settings_set_report_multiple_failures
|
|
175
|
+
settings_set_database_key settings_set_stateful_step_count
|
|
176
|
+
target
|
|
177
|
+
new_pool pool_add pool_generate pool_free
|
|
178
|
+
new_state_machine state_machine_next_rule state_machine_rule_rejected state_machine_free
|
|
179
|
+
].freeze
|
|
180
|
+
|
|
181
|
+
module_function
|
|
182
|
+
|
|
183
|
+
# Runs the block with a context obtained from +impl.context_new+,
|
|
184
|
+
# freeing it via +impl.context_free+ whether the block returns or
|
|
185
|
+
# raises.
|
|
186
|
+
#
|
|
187
|
+
# A block is the only construct used here: hegel_context_free requires
|
|
188
|
+
# every other handle taking this context to be freed first, so the
|
|
189
|
+
# context must outlive them all, and Ruby's GC gives finalizers no
|
|
190
|
+
# ordering guarantee to rely on instead. The block's caller is the
|
|
191
|
+
# context's owner and holds it for exactly as long as the block runs.
|
|
192
|
+
def with_context(impl)
|
|
193
|
+
ctx = impl.context_new
|
|
194
|
+
yield ctx
|
|
195
|
+
ensure
|
|
196
|
+
impl.context_free(ctx)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Raises the exception +code+ translates to, or returns without effect
|
|
200
|
+
# for HEGEL_OK. The message is read from +impl.context_last_error(ctx)+
|
|
201
|
+
# immediately, since libhegel's own buffer for it is invalidated by the
|
|
202
|
+
# next call taking the same context. By the time a caller further up
|
|
203
|
+
# the stack could read it, the buffer might already describe a
|
|
204
|
+
# different call.
|
|
205
|
+
def check!(impl, ctx, code)
|
|
206
|
+
return if code == HEGEL_OK
|
|
207
|
+
|
|
208
|
+
# An engine newer than the pinned one can return a code these bindings
|
|
209
|
+
# have no name for, which is the situation the version warning exists
|
|
210
|
+
# to announce. Naming it plainly beats failing to look it up, because a
|
|
211
|
+
# KeyError here would replace the engine's own diagnostic with a
|
|
212
|
+
# message about a missing hash key.
|
|
213
|
+
name = CODE_NAMES[code] || "unknown result code"
|
|
214
|
+
message = "#{name} (#{code}): #{impl.context_last_error(ctx)}"
|
|
215
|
+
case code
|
|
216
|
+
when HEGEL_E_STOP_TEST then raise StopTest, message
|
|
217
|
+
when HEGEL_E_ASSUME then raise AssumeFailed, message
|
|
218
|
+
else raise Hegel::Error, message
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Warns on +io+ when +impl.version(ctx)+ differs from the engine
|
|
223
|
+
# version these bindings were built for (Hegel::LIBHEGEL_VERSION), and
|
|
224
|
+
# does nothing when they match. Never raises: a mismatched engine is
|
|
225
|
+
# still usable, only untested against, so this is a warning rather than
|
|
226
|
+
# a load failure. +io+ defaults to $stderr and is overridable so a
|
|
227
|
+
# caller (and a test) can capture the warning instead.
|
|
228
|
+
def warn_on_version_mismatch(impl, ctx, io: $stderr)
|
|
229
|
+
loaded = impl.version(ctx)
|
|
230
|
+
return if loaded == Hegel::LIBHEGEL_VERSION
|
|
231
|
+
|
|
232
|
+
io.puts(<<~MESSAGE.chomp)
|
|
233
|
+
hegel: loaded libhegel #{loaded} but these bindings were built for #{Hegel::LIBHEGEL_VERSION}; behaviour may differ. Unset HEGEL_LIBHEGEL_PATH to use the bundled engine, or point it at a matching build.
|
|
234
|
+
MESSAGE
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# hegel_generate_integer_big's own documented convention for
|
|
238
|
+
# min_value/max_value/out_value: two's-complement little-endian signed
|
|
239
|
+
# byte buffers. Pure Ruby arithmetic, no native marshalling, so this is
|
|
240
|
+
# unit-testable directly (test/hegel/test_lib_hegel.rb round-trips it)
|
|
241
|
+
# and shared unchanged between LibHegel::Real's bounds-encode and
|
|
242
|
+
# result-decode, kept out of real.rb so that direct testing stays
|
|
243
|
+
# possible.
|
|
244
|
+
#
|
|
245
|
+
# +n+'s minimal two's-complement byte length is n.bit_length / 8 + 1.
|
|
246
|
+
# Integer#bit_length reports one bit fewer at a negative power of two
|
|
247
|
+
# (-128 is 7 bits, 128 is 8), so this formula needs no separate
|
|
248
|
+
# sign-bit adjustment there; verified against a brute-force
|
|
249
|
+
# minimal-length search over both signs and the int32/int64/2**100
|
|
250
|
+
# boundaries.
|
|
251
|
+
def encode_integer_le(n)
|
|
252
|
+
byte_length = (n.bit_length / 8) + 1
|
|
253
|
+
byte_length.times.map { |i| (n >> (8 * i)) & 0xFF }.pack("C*")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The inverse of .encode_integer_le. +bytes+ may be longer than the
|
|
257
|
+
# value's own minimal encoding -- the header documents hegel_generate_
|
|
258
|
+
# integer_big's out_value as sign-filled past that length, so a caller
|
|
259
|
+
# may decode the whole out_value_cap-sized buffer and still get the
|
|
260
|
+
# right answer -- so length here is read from +bytes+ itself, not
|
|
261
|
+
# assumed minimal.
|
|
262
|
+
def decode_integer_le(bytes)
|
|
263
|
+
length = bytes.bytesize
|
|
264
|
+
unsigned = bytes.each_byte.with_index.sum { |byte, i| byte << (8 * i) }
|
|
265
|
+
negative = (bytes.getbyte(length - 1) & 0x80) != 0
|
|
266
|
+
negative ? unsigned - (1 << (8 * length)) : unsigned
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hegel
|
|
4
|
+
# The libhegel engine release these bindings target. This is independent of
|
|
5
|
+
# Hegel::VERSION (the gem's own release): hegel-go and hegel-typescript keep
|
|
6
|
+
# the two separate too, so bumping the pinned engine can land as its own
|
|
7
|
+
# commit without also releasing a new gem version.
|
|
8
|
+
LIBHEGEL_VERSION = "0.32.5"
|
|
9
|
+
end
|
data/lib/hegel/locate.rb
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Hegel
|
|
7
|
+
# Resolves the path to the native libhegel shared library.
|
|
8
|
+
#
|
|
9
|
+
# Resolution order:
|
|
10
|
+
#
|
|
11
|
+
# 1. +HEGEL_LIBHEGEL_PATH+: an explicit override, given as a file path or
|
|
12
|
+
# a directory that contains the library under one of the three
|
|
13
|
+
# basenames it is known to ship under (see find_override). This is
|
|
14
|
+
# checked first because hegel-go, hegel-typescript, hegel-java, and
|
|
15
|
+
# hegel-ocaml all give this same variable name priority over their own
|
|
16
|
+
# fallback.
|
|
17
|
+
# 2. The copy bundled into the gem under +lib/hegel/libhegel/+.
|
|
18
|
+
#
|
|
19
|
+
# A sibling `../hegel-rust` checkout is deliberately not searched. The env
|
|
20
|
+
# override already covers local-engine-build workflows without depending
|
|
21
|
+
# on any particular checkout layout, and `rake libhegel:fetch` covers the
|
|
22
|
+
# rest; a rule that depends on a layout this repo's own checkout does not
|
|
23
|
+
# have would be an untested, unreachable branch under 100% coverage.
|
|
24
|
+
#
|
|
25
|
+
# Resolution knowledge is confined to this file. Every other file calls
|
|
26
|
+
# only Locate.resolve. A future change to the distribution method
|
|
27
|
+
# (bundled copy vs. runtime download) therefore has one place to change.
|
|
28
|
+
# This module never touches the network; only the development-only
|
|
29
|
+
# `libhegel:fetch` rake task downloads anything.
|
|
30
|
+
module Locate
|
|
31
|
+
# Env var that overrides resolution with an explicit path.
|
|
32
|
+
LIBRARY_PATH_ENV = "HEGEL_LIBHEGEL_PATH"
|
|
33
|
+
|
|
34
|
+
# Published release asset name for each supported "<host_cpu>-<host_os>"
|
|
35
|
+
# pair, mirroring the artifacts hegel-rust publishes for the pinned
|
|
36
|
+
# LIBHEGEL_VERSION. Hosts absent from this table (e.g. x86_64-darwin,
|
|
37
|
+
# Intel Mac) are unsupported because hegel-rust does not publish a build
|
|
38
|
+
# for them.
|
|
39
|
+
ASSET_NAMES = {
|
|
40
|
+
"arm64-darwin" => "libhegel-darwin-arm64.dylib",
|
|
41
|
+
"x86_64-linux" => "libhegel-linux-amd64.so",
|
|
42
|
+
"aarch64-linux" => "libhegel-linux-arm64.so",
|
|
43
|
+
"x64-mingw-ucrt" => "libhegel-windows-amd64.dll",
|
|
44
|
+
"aarch64-mingw-ucrt" => "libhegel-windows-arm64.dll"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
# Directory inside the gem where a platform build may be bundled.
|
|
48
|
+
GEM_LIBHEGEL_DIR = File.expand_path("libhegel", __dir__)
|
|
49
|
+
|
|
50
|
+
# Default lookup for the gem-bundled step: the asset file directly under
|
|
51
|
+
# +dir+, or nil if it is not there.
|
|
52
|
+
DEFAULT_FINDER = lambda do |dir, asset|
|
|
53
|
+
path = File.join(dir, asset)
|
|
54
|
+
File.file?(path) ? path : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# Resolves a usable libhegel path. Env, host, the gem-bundled directory,
|
|
60
|
+
# and the gem-bundled finder are all injectable so this is testable
|
|
61
|
+
# without a real native library or a real host match.
|
|
62
|
+
#
|
|
63
|
+
# Raises Hegel::Error if the host is unsupported, or if it is supported
|
|
64
|
+
# but no bundled library is found for it.
|
|
65
|
+
def resolve(env: ENV, host_cpu: RbConfig::CONFIG["host_cpu"], host_os: RbConfig::CONFIG["host_os"],
|
|
66
|
+
gem_dir: GEM_LIBHEGEL_DIR, finder: DEFAULT_FINDER)
|
|
67
|
+
overridden = from_env(env, host_cpu, host_os)
|
|
68
|
+
return overridden unless overridden.nil?
|
|
69
|
+
|
|
70
|
+
# Host support is checked before any filesystem lookup, so an
|
|
71
|
+
# unsupported host reports itself as unsupported rather than as a
|
|
72
|
+
# confusing "file not found" once the gem-bundled directory is missing
|
|
73
|
+
# or empty.
|
|
74
|
+
asset = asset_name(host_cpu: host_cpu, host_os: host_os)
|
|
75
|
+
finder.call(gem_dir, asset) || raise(Hegel::Error, missing_bundle_message(asset, gem_dir))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The release asset name for the given host, or raises Hegel::Error if
|
|
79
|
+
# hegel-rust does not publish a build for it.
|
|
80
|
+
def asset_name(host_cpu:, host_os:)
|
|
81
|
+
release_asset_name(host_cpu, host_os) || raise(Hegel::Error, unsupported_host_message(host_cpu, host_os))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Step 1: the explicit override, or nil if unset or empty. An empty
|
|
85
|
+
# value is treated as unset (not as "use the current directory"), so
|
|
86
|
+
# exporting the variable empty in CI does not silently break resolution.
|
|
87
|
+
def from_env(env, host_cpu, host_os)
|
|
88
|
+
value = env[LIBRARY_PATH_ENV]
|
|
89
|
+
return nil if value.nil? || value.empty?
|
|
90
|
+
|
|
91
|
+
File.directory?(value) ? find_override(value, host_cpu, host_os) : value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Searches +dir+ for the first of the three basenames a directory
|
|
95
|
+
# override may hold, in priority order, or raises Hegel::Error naming
|
|
96
|
+
# both the directory and every basename that was tried.
|
|
97
|
+
#
|
|
98
|
+
# 1. This host's release asset name (e.g. libhegel-darwin-arm64.dylib),
|
|
99
|
+
# the name `rake libhegel:fetch` itself installs, so it is the most
|
|
100
|
+
# likely match. Skipped, not raised, when the host is unsupported: a
|
|
101
|
+
# directory override is how an unsupported host (e.g. x86_64-darwin)
|
|
102
|
+
# points at a local build in the first place, so treating it as an
|
|
103
|
+
# error here would close off that path.
|
|
104
|
+
# 2. libhegel_c.<ext>, the name cargo gives the library when hegel-c's
|
|
105
|
+
# crate (`[lib] name = "hegel_c"`) is built without renaming it.
|
|
106
|
+
# 3. libhegel.<ext>, a renamed copy, per the hegel-go and hegel-ocaml
|
|
107
|
+
# READMEs.
|
|
108
|
+
def find_override(dir, host_cpu, host_os)
|
|
109
|
+
names = override_candidate_names(host_cpu, host_os)
|
|
110
|
+
names.each do |name|
|
|
111
|
+
path = File.join(dir, name)
|
|
112
|
+
return path if File.file?(path)
|
|
113
|
+
end
|
|
114
|
+
raise Hegel::Error, missing_override_message(dir, names)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The basenames find_override tries, in priority order. release_asset_name
|
|
118
|
+
# is omitted (not just skipped later) when the host is unsupported, so an
|
|
119
|
+
# unsupported host never appears in the candidate list or its error message.
|
|
120
|
+
def override_candidate_names(host_cpu, host_os)
|
|
121
|
+
ext = ext_of_os(host_os)
|
|
122
|
+
[release_asset_name(host_cpu, host_os), "libhegel_c.#{ext}", "libhegel.#{ext}"].compact
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The release asset name for the given host, or nil if hegel-rust does
|
|
126
|
+
# not publish a build for it. Split from asset_name (which raises) so a
|
|
127
|
+
# directory override can treat an unsupported host as "skip this
|
|
128
|
+
# candidate" instead of an error.
|
|
129
|
+
def release_asset_name(host_cpu, host_os)
|
|
130
|
+
ASSET_NAMES[host_id(host_cpu, host_os)]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# "<host_cpu>-<host_os>", normalized to the form ASSET_NAMES keys on.
|
|
134
|
+
def host_id(host_cpu, host_os)
|
|
135
|
+
os = normalize_os(host_os)
|
|
136
|
+
"#{normalize_cpu(host_cpu, os)}-#{os}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# RbConfig::CONFIG["host_os"] carries OS-version detail (e.g.
|
|
140
|
+
# "darwin25", "linux-gnu") that the asset-name table does not key on.
|
|
141
|
+
# An OS family this method does not recognize is passed through
|
|
142
|
+
# unchanged, so it deliberately fails the ASSET_NAMES lookup instead of
|
|
143
|
+
# being coerced into a false match.
|
|
144
|
+
def normalize_os(host_os)
|
|
145
|
+
case host_os
|
|
146
|
+
when /darwin/ then "darwin"
|
|
147
|
+
when /linux/ then "linux"
|
|
148
|
+
when /mingw|windows/ then "mingw-ucrt"
|
|
149
|
+
else host_os
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# RubyGems abbreviates the "x86_64" config.guess CPU to "x64" only for
|
|
154
|
+
# Windows platform strings (the "x64-mingw-ucrt" gem platform); every
|
|
155
|
+
# other OS keeps the raw host_cpu value, matching this project's own
|
|
156
|
+
# Gemfile.lock entry "arm64-darwin-25" on an Apple Silicon Mac.
|
|
157
|
+
def normalize_cpu(host_cpu, normalized_os)
|
|
158
|
+
(normalized_os == "mingw-ucrt" && host_cpu == "x86_64") ? "x64" : host_cpu
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Shared-library extension for host_os alone, independent of whether the
|
|
162
|
+
# specific host_cpu is one hegel-rust publishes a build for. This lets
|
|
163
|
+
# an explicit directory override (step 1) resolve on an otherwise
|
|
164
|
+
# unsupported host (e.g. x86_64-darwin), matching a local libhegel build
|
|
165
|
+
# that always produces the OS-native extension.
|
|
166
|
+
def ext_of_os(host_os)
|
|
167
|
+
case host_os
|
|
168
|
+
when /darwin/ then "dylib"
|
|
169
|
+
when /mingw|windows/ then "dll"
|
|
170
|
+
else "so"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def unsupported_host_message(host_cpu, host_os)
|
|
175
|
+
"libhegel has no published build for host \"#{host_cpu}-#{host_os}\". " \
|
|
176
|
+
"Set #{LIBRARY_PATH_ENV} to a local libhegel build."
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def missing_bundle_message(asset, gem_dir)
|
|
180
|
+
"libhegel not found: expected #{asset} under #{gem_dir}. " \
|
|
181
|
+
"Set #{LIBRARY_PATH_ENV} to a local libhegel build, or run `rake libhegel:fetch`."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def missing_override_message(dir, names)
|
|
185
|
+
"libhegel not found under #{dir} (from #{LIBRARY_PATH_ENV}). Looked for: #{names.join(", ")}."
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
data/lib/hegel/report.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hegel
|
|
4
|
+
# Turns a failing run's recorded entries into the text a caller sees on
|
|
5
|
+
# failure, and the blob Hegel.test(reproduce_failure:) accepts. Formatted
|
|
6
|
+
# after hegel-rust's own failure report, so a reader moving between the
|
|
7
|
+
# two bindings recognises the shape.
|
|
8
|
+
#
|
|
9
|
+
# This module only formats data it is handed; it does not know how a run
|
|
10
|
+
# was driven, and does not write anywhere itself (see Hegel::Runner for
|
|
11
|
+
# both).
|
|
12
|
+
module Report
|
|
13
|
+
# One failure's rendered ingredients. +test_cases+ and +discarded+ are
|
|
14
|
+
# the generation phase's own counts up to that failure's first
|
|
15
|
+
# appearance (see Hegel::Runner::GenerationStats), not the shrink
|
|
16
|
+
# phase's. +entries+ is the [:draw, name, value] / [:note, message] list
|
|
17
|
+
# Hegel::TestCase recorded on the final replay that produced this
|
|
18
|
+
# failure, in call order, still un-#inspect'd/un-#to_s'd (see
|
|
19
|
+
# Hegel::TestCase#record_draw and #note for why). +blob+ is the string
|
|
20
|
+
# Hegel.test(reproduce_failure:) accepts to replay this same failure.
|
|
21
|
+
Failure = Struct.new(:test_cases, :discarded, :entries, :blob)
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Assigns each :draw entry its display name: the name it was recorded
|
|
26
|
+
# under, suffixed with a 1-based, per-name counter only when that name
|
|
27
|
+
# occurs more than once among the :draw entries (their own relative
|
|
28
|
+
# order, unchanged). A single "n" stays "n"; two draws both named "draw"
|
|
29
|
+
# (the fallback every unlabelled draw shares) become "draw_1" and
|
|
30
|
+
# "draw_2", the same way hegel-rust's `__draw_named` disambiguates a
|
|
31
|
+
# repeated `repeatable`. :note entries carry no name to disambiguate and
|
|
32
|
+
# pass through unchanged, in the position they were recorded.
|
|
33
|
+
def assign_names(entries)
|
|
34
|
+
counts = entries.each_with_object(Hash.new(0)) do |entry, tally|
|
|
35
|
+
tally[entry[1]] += 1 if entry[0] == :draw
|
|
36
|
+
end
|
|
37
|
+
seen = Hash.new(0)
|
|
38
|
+
entries.map do |entry|
|
|
39
|
+
next entry unless entry[0] == :draw
|
|
40
|
+
|
|
41
|
+
_tag, name, value = entry
|
|
42
|
+
if counts[name] > 1
|
|
43
|
+
seen[name] += 1
|
|
44
|
+
[:draw, "#{name}_#{seen[name]}", value]
|
|
45
|
+
else
|
|
46
|
+
entry
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Renders one failure's block: its "Falsified after" header, its
|
|
52
|
+
# entries in call order (:draw #inspect'd, :note #to_s'd -- both here,
|
|
53
|
+
# on report assembly, not when Hegel::TestCase recorded them), and how
|
|
54
|
+
# to reproduce it. A :note shares the :draw lines' 2-space indent
|
|
55
|
+
# deliberately: both belong to the same block, and a different indent
|
|
56
|
+
# would read as a different kind of thing instead of the same
|
|
57
|
+
# call-order list.
|
|
58
|
+
def render_failure(failure)
|
|
59
|
+
cases = "#{failure.test_cases} test #{(failure.test_cases == 1) ? "case" : "cases"}"
|
|
60
|
+
lines = ["Falsified after #{cases} (#{failure.discarded} discarded):", ""]
|
|
61
|
+
assign_names(failure.entries).each do |entry|
|
|
62
|
+
if entry[0] == :draw
|
|
63
|
+
_tag, name, value = entry
|
|
64
|
+
lines << " #{name} = #{value.inspect}"
|
|
65
|
+
else
|
|
66
|
+
_tag, message = entry
|
|
67
|
+
lines << " #{message}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
lines << ""
|
|
71
|
+
lines << "To reproduce this failure, pass the blob below to Hegel.test:"
|
|
72
|
+
lines << " reproduce_failure: #{failure.blob.inspect}"
|
|
73
|
+
lines.join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Renders every failure, in the order given. A single failure is just
|
|
77
|
+
# its own block. More than one gets hegel-rust's own distinct-failures
|
|
78
|
+
# count first, with a blank line (the join separator) before every
|
|
79
|
+
# block, including the first, matching the decided report shape.
|
|
80
|
+
def render(failures)
|
|
81
|
+
blocks = failures.map { |failure| render_failure(failure) }
|
|
82
|
+
return blocks.first if blocks.size == 1
|
|
83
|
+
|
|
84
|
+
(["Property-based test failed with #{blocks.size} distinct failures."] + blocks).join("\n\n")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|