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