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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: facbe6e366b74782ab68e979295856ef15c8da56ea9d9fe9291764cbf1629eb3
|
|
4
|
+
data.tar.gz: e78e0364232dc02974f7ca81135a34325a76287c425d59f1d384ef2f2acfb2aa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c977d4fd78f3439b18b731ae14c6772968aff53271fe1f44a8712f044a6dc0bcdcbd0ece18da677a7e3599a831651806a979c6985d5b942e6d89534c5c6eae14
|
|
7
|
+
data.tar.gz: 429fb8e7ce09f224c32368ad0d34bc69d1020f56891bdb2616da02acb86fa0ef5c174f340f27fc5a0cdf4918e8a4f82075781128850656b571e0b4232143abf5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
The first release.
|
|
6
|
+
|
|
7
|
+
`Hegel.test` runs a property against libhegel, shrinks a failure to its
|
|
8
|
+
smallest counterexample, reports the values it drew, and re-raises the
|
|
9
|
+
exception the test body itself raised.
|
|
10
|
+
|
|
11
|
+
Twenty-five generators, composing through `map` and `filter`. A test case can
|
|
12
|
+
discard itself with `assume` and `reject`, leave messages with `note`, and
|
|
13
|
+
steer generation with `target`. A run can be shaped with `phases`,
|
|
14
|
+
`suppress_health_check`, `report_multiple_failures`, `stateful_step_count`,
|
|
15
|
+
and libhegel's example database. Stateful testing runs a `Hegel::StateMachine`
|
|
16
|
+
and shrinks a failing sequence of rules.
|
|
17
|
+
|
|
18
|
+
The engine is called through the `ffi` gem. Each platform gem carries the
|
|
19
|
+
matching `libhegel` 0.32.5 build. The platform-independent gem carries none,
|
|
20
|
+
so a run on it needs `HEGEL_LIBHEGEL_PATH` pointing at a local build.
|
|
21
|
+
|
|
22
|
+
[0.1.0]: https://github.com/meganemura/hegel-ruby/releases/tag/v0.1.0
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"hegeltest" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at [meganemura@users.noreply.github.com](mailto:meganemura@users.noreply.github.com).
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 meganemura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Hegel for Ruby
|
|
2
|
+
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> **This is an unofficial, third-party implementation.** The Hegel project and
|
|
5
|
+
> the `libhegel` engine belong to Antithesis, LLC. This repository has no
|
|
6
|
+
> affiliation with Antithesis or with the `hegeldev` organization, and nobody
|
|
7
|
+
> there reviews or endorses it.
|
|
8
|
+
>
|
|
9
|
+
> The official implementations are
|
|
10
|
+
> [Rust](https://github.com/hegeldev/hegel-rust),
|
|
11
|
+
> [Go](https://github.com/hegeldev/hegel-go),
|
|
12
|
+
> [C++](https://github.com/hegeldev/hegel-cpp),
|
|
13
|
+
> [TypeScript](https://github.com/hegeldev/hegel-typescript),
|
|
14
|
+
> [Java](https://github.com/hegeldev/hegel-java), and
|
|
15
|
+
> [OCaml](https://github.com/hegeldev/hegel-ocaml). Report problems with this
|
|
16
|
+
> gem here, not to them.
|
|
17
|
+
|
|
18
|
+
> [!NOTE]
|
|
19
|
+
> Hegel itself is in beta, and its maintainers expect to make breaking changes.
|
|
20
|
+
> See <https://hegel.dev/compatibility>.
|
|
21
|
+
|
|
22
|
+
Hegel is a property-based testing framework based on
|
|
23
|
+
[Hypothesis](https://github.com/hypothesisworks/hypothesis). Instead of writing
|
|
24
|
+
tests with hand-picked inputs, you state a property that must hold for every
|
|
25
|
+
input. Hegel generates inputs, tries to falsify the property, and shrinks any
|
|
26
|
+
failure to a minimal counterexample.
|
|
27
|
+
|
|
28
|
+
This gem drives [libhegel](https://hegel.dev/reference/libhegel), the native
|
|
29
|
+
engine that the official implementations also drive. The engine runs in the
|
|
30
|
+
same process. There is no server and no Python dependency.
|
|
31
|
+
|
|
32
|
+
## Status
|
|
33
|
+
|
|
34
|
+
**The first release is prepared, and the version is `0.1.0`.** Everything
|
|
35
|
+
described below runs: Hegel finds a counterexample, shrinks it,
|
|
36
|
+
names the values it drew, and re-raises your own exception.
|
|
37
|
+
|
|
38
|
+
Work ran in three stages, and all three are done:
|
|
39
|
+
|
|
40
|
+
1. **The walking skeleton**: the libhegel binding, the run loop, and failure
|
|
41
|
+
reports.
|
|
42
|
+
2. **The full generator set**: twenty-five generators, composing through
|
|
43
|
+
`map` and `filter`.
|
|
44
|
+
3. **The advanced features**: the example database, targeted testing,
|
|
45
|
+
stateful testing, phases, and health checks.
|
|
46
|
+
|
|
47
|
+
**Nothing is published to RubyGems yet.** The packaging is built:
|
|
48
|
+
`rake platform_gems:build` produces one gem per platform, each carrying the
|
|
49
|
+
matching `libhegel` build. The arm64 macOS gem is verified to find that
|
|
50
|
+
engine inside itself and run a property with no `HEGEL_LIBHEGEL_PATH` set.
|
|
51
|
+
A `v*` tag publishes all six gems from CI. The Hegel maintainers permit a
|
|
52
|
+
third-party project to redistribute their release binaries
|
|
53
|
+
([hegeldev/hegel-rust#411]).
|
|
54
|
+
|
|
55
|
+
So running this today means cloning the repository:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bin/setup
|
|
59
|
+
bundle exec rake libhegel:fetch # downloads the pinned build, checked against its SHA-256
|
|
60
|
+
bundle exec rake
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every push to main runs the suite on Ruby 3.3, 3.4, and 4.0, across Linux x64
|
|
64
|
+
and arm64, macOS arm64, and Windows x64 and arm64. Windows arm64 starts at
|
|
65
|
+
Ruby 3.4, the oldest Ruby that RubyInstaller publishes an arm64 build for. A
|
|
66
|
+
pull request runs Linux x64 alone.
|
|
67
|
+
|
|
68
|
+
[hegeldev/hegel-rust#411]: https://github.com/hegeldev/hegel-rust/issues/411
|
|
69
|
+
|
|
70
|
+
## Design
|
|
71
|
+
|
|
72
|
+
| Decision | Choice |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| Gem name | `hegeltest` |
|
|
75
|
+
| Require path | `require "hegel"` (`require "hegeltest"` also works) |
|
|
76
|
+
| Namespace | `Hegel` |
|
|
77
|
+
| Binding | the `ffi` gem, which publishes a prebuilt binary for every platform above |
|
|
78
|
+
| Ruby | 3.3, 3.4, and 4.0 |
|
|
79
|
+
| Platforms | Linux amd64/arm64, macOS arm64, Windows amd64/arm64 |
|
|
80
|
+
| Engine delivery | One prebuilt `libhegel` per platform-specific gem, built by `rake platform_gems:build` |
|
|
81
|
+
|
|
82
|
+
`HEGEL_LIBHEGEL_PATH` will override the bundled engine with a local build.
|
|
83
|
+
|
|
84
|
+
The gem name follows the Rust implementation, whose published crate is
|
|
85
|
+
`hegeltest` and whose library is `hegel`. The name `hegel` on RubyGems.org
|
|
86
|
+
stays free for whoever publishes an official Ruby implementation.
|
|
87
|
+
|
|
88
|
+
macOS on Intel has no published `libhegel` artifact, so that platform will need
|
|
89
|
+
`HEGEL_LIBHEGEL_PATH` and a local build.
|
|
90
|
+
|
|
91
|
+
## Quickstart
|
|
92
|
+
|
|
93
|
+
Everything below runs today.
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# spec/spec_helper.rb
|
|
97
|
+
require "hegel"
|
|
98
|
+
|
|
99
|
+
RSpec.configure do |config|
|
|
100
|
+
config.include Hegel::Syntax::Methods
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# spec/my_sort_spec.rb
|
|
106
|
+
def my_sort(ls) = ls.sort.uniq # oops: uniq drops duplicates
|
|
107
|
+
|
|
108
|
+
RSpec.describe "my_sort" do
|
|
109
|
+
it "matches the builtin sort" do
|
|
110
|
+
Hegel.test do |tc|
|
|
111
|
+
xs = tc.draw(arrays(integers))
|
|
112
|
+
expect(my_sort(xs)).to eq(xs.sort)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
That test fails, and Hegel shrinks the failure to the smallest input that
|
|
119
|
+
still shows the bug:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Falsified after 11 test cases (0 discarded):
|
|
123
|
+
|
|
124
|
+
xs = [0, 0]
|
|
125
|
+
|
|
126
|
+
To reproduce this failure, pass the blob below to Hegel.test:
|
|
127
|
+
reproduce_failure: "AXicY2VgYGBkZOBiZEBhMAAAAd8AIQ=="
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Two duplicates are all it takes. Hegel names the value `xs` by reading the
|
|
131
|
+
line the draw was written on, then re-raises RSpec's own expectation failure,
|
|
132
|
+
so the framework reports it as its own.
|
|
133
|
+
|
|
134
|
+
Including `Hegel::Syntax::Methods` makes the generators available without a
|
|
135
|
+
prefix, the way FactoryBot makes `create` available. The same generators stay
|
|
136
|
+
reachable as `Hegel::Generators.arrays(...)` without the include.
|
|
137
|
+
|
|
138
|
+
The generators are `arrays`, `binary`, `booleans`, `characters`, `composite`,
|
|
139
|
+
`dates`, `datetimes`, `deferred`, `domains`, `emails`, `floats`, `from_regex`,
|
|
140
|
+
`hashes`, `integers`, `ip_addresses`, `just`, `one_of`, `optional`,
|
|
141
|
+
`sampled_from`, `sets`, `text`, `times`, `tuples`, `urls`, and `uuids`. Each
|
|
142
|
+
one composes through `map` and `filter`.
|
|
143
|
+
|
|
144
|
+
### Minitest
|
|
145
|
+
|
|
146
|
+
Hegel needs nothing from your test framework. Install the generator methods
|
|
147
|
+
once, and write the property inside an ordinary test:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# test/test_helper.rb
|
|
151
|
+
require "hegel"
|
|
152
|
+
|
|
153
|
+
class Minitest::Test
|
|
154
|
+
include Hegel::Syntax::Methods
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
# test/my_sort_test.rb
|
|
160
|
+
class MySortTest < Minitest::Test
|
|
161
|
+
def test_matches_the_builtin_sort
|
|
162
|
+
Hegel.test do |tc|
|
|
163
|
+
xs = tc.draw(arrays(integers))
|
|
164
|
+
assert_equal xs.sort, my_sort(xs)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Minitest reports the shrunk case as its own assertion failure, pointing at
|
|
171
|
+
your line:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
1) Failure:
|
|
175
|
+
MySortTest#test_matches_the_builtin_sort [test/my_sort_test.rb:5]:
|
|
176
|
+
Expected: [0, 0]
|
|
177
|
+
Actual: [0]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Stateful testing
|
|
181
|
+
|
|
182
|
+
Some bugs need a sequence of operations rather than one input. Declare the
|
|
183
|
+
operations as rules on a `Hegel::StateMachine`, and Hegel picks which runs
|
|
184
|
+
next, checks your invariants after each one, and shrinks a failure to the
|
|
185
|
+
shortest sequence that still shows it:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
class StackMachine < Hegel::StateMachine
|
|
189
|
+
def initialize
|
|
190
|
+
@stack = BoundedStack.new(3)
|
|
191
|
+
@model = []
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
rule :push do |tc|
|
|
195
|
+
x = tc.draw(integers(min_value: 0, max_value: 9))
|
|
196
|
+
@stack.push(x)
|
|
197
|
+
@model.push(x) if @model.size < 3
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
rule :pop do |tc|
|
|
201
|
+
tc.assume(!@model.empty?)
|
|
202
|
+
raise "pop disagreed" unless @stack.pop == @model.pop
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
invariant :size_agrees do
|
|
206
|
+
raise "size disagreed" unless @stack.size == @model.size
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
Hegel.test { |tc| Hegel::Stateful.run(StackMachine.new, tc) }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`tc.assume` inside a rule rejects that rule and lets Hegel choose another,
|
|
214
|
+
rather than throwing the whole test case away. For a rule that has to act on
|
|
215
|
+
something an earlier rule produced, such as freeing a handle that some
|
|
216
|
+
`alloc` actually returned, put the value in a `Hegel::Stateful::Pool` and
|
|
217
|
+
draw it back out.
|
|
218
|
+
|
|
219
|
+
### Shaping a run
|
|
220
|
+
|
|
221
|
+
`Hegel.test` takes keywords for the rest: `test_cases`, `seed`,
|
|
222
|
+
`derandomize`, `verbosity`, `phases`, `suppress_health_check`,
|
|
223
|
+
`report_multiple_failures`, and `stateful_step_count`. Each one left unset
|
|
224
|
+
means the engine's own default.
|
|
225
|
+
|
|
226
|
+
Two more turn on libhegel's example database, which stores a failing case and
|
|
227
|
+
replays it first next time. `database_key` is the switch and `database`
|
|
228
|
+
chooses the directory:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
Hegel.test(database_key: "my_sort matches the builtin sort") { |tc| ... }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Give each property its own key. See
|
|
235
|
+
[ADR 0009](docs/adr/0009-turn-the-example-database-on-with-a-key.md) for why
|
|
236
|
+
the key, rather than the directory, is what turns it on.
|
|
237
|
+
|
|
238
|
+
Inside a test, `tc.note` records a message the failure report prints, and
|
|
239
|
+
`tc.target` tells Hegel which inputs to search toward.
|
|
240
|
+
|
|
241
|
+
## Development
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
bin/setup # install dependencies
|
|
245
|
+
bundle exec rake # run the tests and the linter
|
|
246
|
+
bin/console # open an interactive prompt
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`just` recipes call the same Rake tasks, so `just test` and `just lint` work
|
|
250
|
+
for anyone who already uses `just` with the other Hegel implementations.
|
|
251
|
+
|
|
252
|
+
## Contributing
|
|
253
|
+
|
|
254
|
+
Report bugs and open pull requests at
|
|
255
|
+
<https://github.com/meganemura/hegel-ruby>. Contributors follow the
|
|
256
|
+
[code of conduct](CODE_OF_CONDUCT.md).
|
|
257
|
+
|
|
258
|
+
## License
|
|
259
|
+
|
|
260
|
+
This gem is available under the [MIT License](LICENSE.txt).
|
|
261
|
+
|
|
262
|
+
Released gems also carry `libhegel`'s own license, which is MIT and copyright
|
|
263
|
+
Antithesis, LLC. Each platform gem ships `NOTICE-libhegel.txt` next to the
|
|
264
|
+
engine, and that file holds the text.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "minitest/test_task"
|
|
5
|
+
|
|
6
|
+
Minitest::TestTask.create
|
|
7
|
+
|
|
8
|
+
load File.expand_path("lib/tasks/libhegel.rake", __dir__)
|
|
9
|
+
load File.expand_path("lib/tasks/platform_gems.rake", __dir__)
|
|
10
|
+
|
|
11
|
+
require "standard/rake"
|
|
12
|
+
|
|
13
|
+
desc "Run the test suite with coverage measurement enforced at 100%"
|
|
14
|
+
task :coverage do
|
|
15
|
+
ENV["COVERAGE"] = "1"
|
|
16
|
+
Rake::Task["test"].invoke
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
task default: %i[coverage standard]
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Documentation
|
|
2
|
+
|
|
3
|
+
An index of the design records for `hegel-ruby`.
|
|
4
|
+
|
|
5
|
+
- [architecture.md](architecture.md): the layers between Ruby and
|
|
6
|
+
libhegel, and the boundary each one draws.
|
|
7
|
+
|
|
8
|
+
## Architecture Decision Records
|
|
9
|
+
|
|
10
|
+
- [0001: Bind libhegel through Fiddle](adr/0001-bind-libhegel-through-fiddle.md)
|
|
11
|
+
- [0002: Ship one prebuilt engine per platform-specific gem](adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md)
|
|
12
|
+
- [0003: Publish as hegeltest, require as hegel](adr/0003-publish-as-hegeltest-require-as-hegel.md)
|
|
13
|
+
- [0004: Expose generators through a mixin, with keyword options](adr/0004-expose-generators-through-a-mixin-with-keyword-options.md)
|
|
14
|
+
- [0005: Name drawn values from the caller's source with Prism](adr/0005-name-drawn-values-from-the-callers-source-with-prism.md)
|
|
15
|
+
- [0006: Verify the binding in seven layers, with full coverage](adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md)
|
|
16
|
+
- [0007: Ship a thin Ruby skill, shaped for donation](adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md)
|
|
17
|
+
- [0008: Revisit the binding after milestone C, on measurement](adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md)
|
|
18
|
+
- [0009: Turn the example database on with a key](adr/0009-turn-the-example-database-on-with-a-key.md)
|
|
19
|
+
- [0010: Declare stateful rules with a class macro](adr/0010-declare-stateful-rules-with-a-class-macro.md)
|
|
20
|
+
- [0011: Let the test case own every pool drawn from it](adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md)
|
|
21
|
+
- [0012: Build a failure origin from the caller's own frame](adr/0012-build-a-failure-origin-from-the-callers-own-frame.md)
|
|
22
|
+
- [0013: Bind libhegel through the ffi gem](adr/0013-bind-libhegel-through-the-ffi-gem.md)
|
|
23
|
+
|
|
24
|
+
A new decision gets a new record. A changed decision supersedes the old
|
|
25
|
+
record instead of editing it, so the history stays readable.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 0001: Bind libhegel through Fiddle
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Superseded by [ADR 0013](0013-bind-libhegel-through-the-ffi-gem.md),
|
|
6
|
+
which binds libhegel through the `ffi` gem instead, on the measurement
|
|
7
|
+
[ADR 0008](0008-revisit-the-binding-after-milestone-c-on-measurement.md)
|
|
8
|
+
scheduled.
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
libhegel is hegel-rust's native engine, exposed as a C ABI in
|
|
13
|
+
`hegel-c/include/hegel.h`. It runs in the same process as its caller.
|
|
14
|
+
Nearly every function takes a `hegel_context_t*` as its first argument and
|
|
15
|
+
returns a `hegel_result_t` status code.
|
|
16
|
+
|
|
17
|
+
CRuby needs a way to call that ABI. A C extension can call it directly, but
|
|
18
|
+
building one needs a compiler at gem install time. Fiddle is a library
|
|
19
|
+
bundled with Ruby. It wraps libffi and `dlopen`, so it calls a C function
|
|
20
|
+
by address without compiling anything.
|
|
21
|
+
|
|
22
|
+
The other implementations bind the same ABI under different constraints.
|
|
23
|
+
The Java implementation drives it through the Foreign Function and Memory
|
|
24
|
+
API. It caches one `MethodHandle` per C symbol, behind a `Libhegel`
|
|
25
|
+
interface; `RealLibhegel` implements that interface in production, and a
|
|
26
|
+
fake implements it in tests. The Go implementation opens the library with
|
|
27
|
+
`dlopen` directly, giving each C symbol a Go func-typed struct field. The
|
|
28
|
+
OCaml implementation binds through `ctypes`, declaring each function with
|
|
29
|
+
`Foreign.foreign` over a handle from `Dl.dlopen`. The C++ implementation
|
|
30
|
+
links the library at compile time and calls its functions directly, with
|
|
31
|
+
no runtime loading step.
|
|
32
|
+
|
|
33
|
+
## Decision
|
|
34
|
+
|
|
35
|
+
Bind libhegel through Fiddle (`~> 1.1`). Confine every raw Fiddle call to
|
|
36
|
+
one module, `Hegel::LibHegel`. The rest of the library will call that
|
|
37
|
+
module's wrappers. This matches the way the Java implementation's
|
|
38
|
+
`Libhegel` interface sits between its runner and the raw FFI calls.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
Installing the gem needs no compiler and no link step against `hegel.h`.
|
|
43
|
+
The Go binding shares this property: it opens the library at run time
|
|
44
|
+
instead of linking against it.
|
|
45
|
+
|
|
46
|
+
`Hegel::LibHegel` needs a substitute implementation for testing error-code
|
|
47
|
+
translation without a real engine loaded. The Java implementation's fake
|
|
48
|
+
binding plays that role in its own test suite.
|
|
49
|
+
|
|
50
|
+
The gem can use only the Fiddle API that ships in Ruby 3.3's bundled
|
|
51
|
+
fiddle, 1.1.2, since that Ruby is the oldest one supported.
|
|
52
|
+
|
|
53
|
+
Whether `Fiddle::Closure` behaves the same from fiddle 1.1.2 through 1.1.8
|
|
54
|
+
has not been checked yet.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 0002: Ship one prebuilt engine per platform-specific gem
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
hegel-rust publishes libhegel as GitHub release assets. Release v0.32.5
|
|
10
|
+
carries ten assets: one shared library and one sha256 checksum, for each of
|
|
11
|
+
five platforms (`darwin-arm64`, `linux-amd64`, `linux-arm64`,
|
|
12
|
+
`windows-amd64`, `windows-arm64`).
|
|
13
|
+
|
|
14
|
+
The other implementations distribute these prebuilt libraries in different
|
|
15
|
+
shapes. The TypeScript implementation publishes one npm package per
|
|
16
|
+
platform, such as `@hegeldev/hegel-linux-x64`, listed as an
|
|
17
|
+
`optionalDependencies` entry so npm installs only the matching one. The Go
|
|
18
|
+
implementation embeds all five binaries into one module, behind
|
|
19
|
+
per-platform `go:embed` build tags. A build for one target links in only
|
|
20
|
+
its own binary. The OCaml implementation's loader can also download the
|
|
21
|
+
matching binary at install or first run, through a `dune` site that a
|
|
22
|
+
release tarball prefills.
|
|
23
|
+
|
|
24
|
+
RubyGems can build one physical gem per `platform` value declared in a
|
|
25
|
+
gemspec. Bundler and RubyGems then install and require the gem matching the
|
|
26
|
+
local platform automatically.
|
|
27
|
+
|
|
28
|
+
## Decision
|
|
29
|
+
|
|
30
|
+
Publish `hegeltest` as five platform-specific gems, one prebuilt libhegel
|
|
31
|
+
bundled in each, following the model the TypeScript implementation's
|
|
32
|
+
per-platform npm packages set.
|
|
33
|
+
|
|
34
|
+
libhegel resolves in two steps: `HEGEL_LIBHEGEL_PATH`, naming a file or a
|
|
35
|
+
directory that contains one, checked first; the platform gem's bundled
|
|
36
|
+
copy otherwise. The Go, TypeScript, Java, and OCaml implementations all
|
|
37
|
+
check `HEGEL_LIBHEGEL_PATH` before falling back to their own bundled or
|
|
38
|
+
downloaded engine.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
No Intel-Mac gem can ship, since v0.32.5's release assets cover five
|
|
43
|
+
platforms and Intel Mac is not one of them. That platform needs
|
|
44
|
+
`HEGEL_LIBHEGEL_PATH` and a local build.
|
|
45
|
+
|
|
46
|
+
Only the resolution step, `HEGEL_LIBHEGEL_PATH` then the bundled copy,
|
|
47
|
+
depends on how libhegel reaches the gem. The rest of the library calls
|
|
48
|
+
`Hegel::LibHegel` the same way, regardless of where the file came from.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# 0003: Publish as hegeltest, require as hegel
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
hegel-rust's `Cargo.toml` names the package `hegeltest` (`[package] name =
|
|
10
|
+
"hegeltest"`) and the library `hegel` (`[lib] name = "hegel"`). `cargo add
|
|
11
|
+
--dev hegeltest` installs it; source calls `use hegel::...`.
|
|
12
|
+
|
|
13
|
+
The Go module is `hegel.dev/go/hegel`, imported as package `hegel`. The
|
|
14
|
+
npm package is `@hegeldev/hegel`. The Java artifact is `dev.hegel:hegel`.
|
|
15
|
+
The OCaml package is `hegel`. In each of these four, the published name
|
|
16
|
+
and the code's own name match.
|
|
17
|
+
|
|
18
|
+
As of 2026-08, neither `hegel` nor `hegeltest` is registered on
|
|
19
|
+
RubyGems.org.
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
Publish the gem as `hegeltest`, matching hegel-rust's crate name. Keep the
|
|
24
|
+
require path and the namespace `hegel` and `Hegel`, matching hegel-rust's
|
|
25
|
+
library name. `lib/hegeltest.rb` exists only to satisfy Bundler's automatic
|
|
26
|
+
require, which is derived from the gem name; it requires `hegel.rb` and
|
|
27
|
+
defines nothing of its own.
|
|
28
|
+
|
|
29
|
+
## Consequences
|
|
30
|
+
|
|
31
|
+
`require "hegel"` is the documented form. `require "hegeltest"` also
|
|
32
|
+
works, because Bundler requires a gem automatically under its own name
|
|
33
|
+
when the gem is listed in a Gemfile.
|
|
34
|
+
|
|
35
|
+
The name `hegel` stays free on RubyGems.org for whoever publishes an
|
|
36
|
+
official Ruby implementation.
|
|
37
|
+
|
|
38
|
+
A reader who already knows the Rust implementation's naming pattern can
|
|
39
|
+
guess this gem's install name and require path correctly.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 0004: Expose generators through a mixin, with keyword options
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
FactoryBot, a widely used Ruby test-data library, defines a module,
|
|
10
|
+
`FactoryBot::Syntax::Methods`, holding methods such as `build` and
|
|
11
|
+
`create`. Including it in a test makes those methods callable bare, with
|
|
12
|
+
no `FactoryBot.` prefix. The same methods stay reachable through the
|
|
13
|
+
module's own namespace for a caller who does not include it.
|
|
14
|
+
|
|
15
|
+
hegel-rust and the Go implementation expose generators as builder-chain
|
|
16
|
+
calls: `gs::integers::<i32>()` in Rust and `hegel.Integers[int](min, max)`
|
|
17
|
+
in Go. Each is followed by chained methods, such as `min_size`, for
|
|
18
|
+
further options. Neither Rust nor Go has native optional keyword
|
|
19
|
+
arguments; Ruby does.
|
|
20
|
+
|
|
21
|
+
## Decision
|
|
22
|
+
|
|
23
|
+
Define `Hegel::Syntax::Methods`, a module of generator-constructing
|
|
24
|
+
methods (`integers`, `text`, `arrays`, and so on), matching
|
|
25
|
+
`FactoryBot::Syntax::Methods` in shape. A caller includes it, in an RSpec
|
|
26
|
+
`config.include` or a Minitest test class, to call `integers` and `text`
|
|
27
|
+
bare. The same generators stay reachable as `Hegel::Generators.integers`
|
|
28
|
+
without the include.
|
|
29
|
+
|
|
30
|
+
Generator options are keyword arguments, not a builder chain:
|
|
31
|
+
`text(min_size: 1, alphabet: "abc")`.
|
|
32
|
+
|
|
33
|
+
## Consequences
|
|
34
|
+
|
|
35
|
+
A caller who already uses FactoryBot recognizes the include-a-methods-
|
|
36
|
+
module pattern.
|
|
37
|
+
|
|
38
|
+
A generator with several options reads as one call, such as
|
|
39
|
+
`text(min_size: 1, max_size: 20)`, instead of a chain of setter calls.
|
|
40
|
+
|
|
41
|
+
`Hegel::Syntax::Methods`, `Hegel::Generators`, and every generator remain
|
|
42
|
+
to be written; only the naming decision is settled.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 0005: Name drawn values from the caller's source with Prism
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A failure report is more useful when each drawn value has a name, instead
|
|
10
|
+
of a position number. hegel-rust's `#[hegel::test]` macro rewrites, at
|
|
11
|
+
compile time, `let x = tc.draw(gen)` inside a test function into `let x =
|
|
12
|
+
tc.__draw_named(gen, "x", repeatable)`, so the failure report can print
|
|
13
|
+
`let x = <value>;`. The Go implementation resolves the caller's source
|
|
14
|
+
position at runtime with `runtime.Caller`, then parses that file with
|
|
15
|
+
`go/ast` to recover the enclosing statement's text.
|
|
16
|
+
|
|
17
|
+
Ruby has no macros. Prism, Ruby's parser, ships as a default gem starting
|
|
18
|
+
with Ruby 3.3, and can parse a source file into a node tree with source
|
|
19
|
+
locations. Parsing `n = tc.draw(gs.integers)` with Prism yields a
|
|
20
|
+
`Prism::LocalVariableWriteNode` whose `name` is `:n`.
|
|
21
|
+
|
|
22
|
+
## Decision
|
|
23
|
+
|
|
24
|
+
Recover a drawn value's name at runtime, by parsing the caller's source
|
|
25
|
+
file with Prism and reading the assignment target at the `draw` call's
|
|
26
|
+
location. This mirrors the Go implementation's runtime-parse approach, in
|
|
27
|
+
a language without Rust's compile-time macros.
|
|
28
|
+
|
|
29
|
+
A `label:` argument to `draw` overrides the recovered name. When no name
|
|
30
|
+
can be recovered, the value gets a number instead.
|
|
31
|
+
|
|
32
|
+
## Consequences
|
|
33
|
+
|
|
34
|
+
Ruby 3.3, the oldest supported Ruby, is also the oldest Ruby that ships
|
|
35
|
+
Prism by default. The two floors were chosen together.
|
|
36
|
+
|
|
37
|
+
No `draw` call, name recovery, or failure report exists in code yet.
|
|
38
|
+
|
|
39
|
+
How far this recovery reaches into a heredoc, several `draw` calls on one
|
|
40
|
+
line, or a method chain has not been checked.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# 0006: Verify the binding in seven layers, with full coverage
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
hegel-rust's own `tests/` directory separates concerns.
|
|
10
|
+
`tests/test_shrink_quality/` asserts an exact minimal counterexample for a
|
|
11
|
+
given predicate; for example, shrinking a byte pair under a length
|
|
12
|
+
constraint to `vec![0u8; 7]` and `vec![0u8; 8]`. This kind of assertion
|
|
13
|
+
depends on how the engine's shrinker responds to span structure. A missing
|
|
14
|
+
or misplaced span shows up there, as a counterexample larger than the
|
|
15
|
+
minimal one. hegel-rust's own contributor documentation states that every
|
|
16
|
+
invalid combination of builder values must be caught at draw time. It
|
|
17
|
+
also states that a validation message is public API, asserted against by
|
|
18
|
+
tests as a stable substring.
|
|
19
|
+
|
|
20
|
+
The Java implementation enforces 100% instruction and branch coverage
|
|
21
|
+
through its `jacoco-maven-plugin` configuration, `<minimum>1.00</minimum>`
|
|
22
|
+
for both counters. Its `Libhegel` interface exists so the run loop can be
|
|
23
|
+
tested against `FakeLibhegel`, without a real engine, exercising every
|
|
24
|
+
error code libhegel can return.
|
|
25
|
+
|
|
26
|
+
## Decision
|
|
27
|
+
|
|
28
|
+
Verify the binding in seven layers:
|
|
29
|
+
|
|
30
|
+
1. FFI, against a fake engine.
|
|
31
|
+
2. Library resolution.
|
|
32
|
+
3. Conformance, running every generator against the real engine.
|
|
33
|
+
4. Shrink quality, ported from hegel-rust's `tests/test_shrink_quality`
|
|
34
|
+
result assertions.
|
|
35
|
+
5. Generation quality, catching option-translation mistakes.
|
|
36
|
+
6. Failure reports: Prism naming, blob replay, output format.
|
|
37
|
+
7. Integration, running the Minitest and RSpec suites end to end.
|
|
38
|
+
|
|
39
|
+
Enforce 100% line and branch coverage in CI. Ruby's coverage tooling
|
|
40
|
+
counts lines where jacoco counts instructions, so the line counter is
|
|
41
|
+
this project's nearest equivalent to the instruction bar quoted above.
|
|
42
|
+
An excluded file carries a comment stating why.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
A missing or misplaced span costs nothing at generation time. It shows up
|
|
47
|
+
as a larger-than-minimal counterexample, the property the shrink-quality
|
|
48
|
+
layer asserts against.
|
|
49
|
+
|
|
50
|
+
Layer two, library resolution, is written and covered. The other six
|
|
51
|
+
follow the code they test, so they arrive with it.
|