hegeltest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +264 -0
  6. data/Rakefile +19 -0
  7. data/docs/README.md +25 -0
  8. data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
  9. data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
  10. data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
  11. data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
  12. data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
  13. data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
  14. data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
  15. data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
  16. data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
  17. data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
  18. data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
  19. data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
  20. data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
  21. data/docs/architecture.md +182 -0
  22. data/lib/hegel/draw_name.rb +109 -0
  23. data/lib/hegel/errors.rb +47 -0
  24. data/lib/hegel/generator.rb +98 -0
  25. data/lib/hegel/generators.rb +865 -0
  26. data/lib/hegel/lib_hegel/real.rb +1149 -0
  27. data/lib/hegel/lib_hegel.rb +269 -0
  28. data/lib/hegel/libhegel_version.rb +9 -0
  29. data/lib/hegel/locate.rb +188 -0
  30. data/lib/hegel/report.rb +87 -0
  31. data/lib/hegel/runner.rb +464 -0
  32. data/lib/hegel/settings.rb +164 -0
  33. data/lib/hegel/state_machine.rb +89 -0
  34. data/lib/hegel/stateful/pool.rb +111 -0
  35. data/lib/hegel/stateful.rb +120 -0
  36. data/lib/hegel/syntax/methods.rb +173 -0
  37. data/lib/hegel/test_case.rb +523 -0
  38. data/lib/hegel/version.rb +5 -0
  39. data/lib/hegel.rb +92 -0
  40. data/lib/hegeltest.rb +7 -0
  41. data/lib/tasks/libhegel.rake +112 -0
  42. data/lib/tasks/platform_gems.rake +111 -0
  43. data/sig/hegel.rbs +563 -0
  44. data/skills/hegel-ruby/SKILL.md +30 -0
  45. data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
  46. metadata +113 -0
@@ -0,0 +1,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
@@ -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
@@ -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