zizek 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 +115 -0
- data/GUIDE.md +774 -0
- data/LICENSE +201 -0
- data/README.md +201 -0
- data/docs/assets/zizek-rb-logo-dark.svg +16 -0
- data/docs/assets/zizek-rb-logo.svg +16 -0
- data/examples/combinators.rb +93 -0
- data/examples/coverage_assertions.rb +138 -0
- data/examples/database_workflow.rb +120 -0
- data/examples/die_hard.rb +122 -0
- data/examples/hypothesis_quickstart.rb +44 -0
- data/examples/keyed_draws.rb +123 -0
- data/examples/ledger.rb +114 -0
- data/examples/min_stack.rb +108 -0
- data/examples/minitest_suite.rb +64 -0
- data/examples/multi.rb +46 -0
- data/examples/queue.rb +122 -0
- data/examples/roundtrip.rb +71 -0
- data/examples/stack.rb +68 -0
- data/examples/tour.rb +126 -0
- data/lib/zizek/database.rb +211 -0
- data/lib/zizek/gen.rb +10377 -0
- data/lib/zizek/generators.rb +879 -0
- data/lib/zizek/minitest.rb +94 -0
- data/lib/zizek/rspec.rb +67 -0
- data/lib/zizek/settings.rb +249 -0
- data/lib/zizek/shell.rb +2938 -0
- data/lib/zizek/stateful.rb +349 -0
- data/lib/zizek/version.rb +8 -0
- data/lib/zizek.rb +8 -0
- metadata +109 -0
data/examples/multi.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Port of Hegel's two-assertion example. Zizek reports the first failure found
|
|
4
|
+
# and preserves that assertion site while shrinking, so a candidate which trips
|
|
5
|
+
# the other assertion cannot be mistaken for progress on the selected bug.
|
|
6
|
+
|
|
7
|
+
require "zizek"
|
|
8
|
+
|
|
9
|
+
module MultiExample
|
|
10
|
+
EDGE = 1_337
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def property(test_case)
|
|
15
|
+
value = test_case.draw(
|
|
16
|
+
Zizek::Generators.integers.min_value(0).max_value(2_000)
|
|
17
|
+
)
|
|
18
|
+
raise "#{value} hit the forbidden edge" if value == EDGE
|
|
19
|
+
raise "#{value} is not below the edge" unless value < EDGE
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find_and_replay_one_origin
|
|
23
|
+
found =
|
|
24
|
+
begin
|
|
25
|
+
Zizek.check(method(:property), examples: 100, seed: 1)
|
|
26
|
+
raise "one of the two integer bugs should be discovered"
|
|
27
|
+
rescue Zizek::Failure => failure
|
|
28
|
+
message = failure.property_cause.message
|
|
29
|
+
unless message.include?("forbidden edge") || message.include?("not below the edge")
|
|
30
|
+
raise "unexpected failure origin: #{message}"
|
|
31
|
+
end
|
|
32
|
+
failure
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
Zizek.check(method(:property), reproduce: found.blob)
|
|
37
|
+
rescue Zizek::Failure => replayed
|
|
38
|
+
raise unless replayed.property_cause.message == found.property_cause.message
|
|
39
|
+
|
|
40
|
+
return found
|
|
41
|
+
end
|
|
42
|
+
raise "the selected failure origin must replay"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts MultiExample.find_and_replay_one_origin if $PROGRAM_NAME == __FILE__
|
data/examples/queue.rb
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model-based stateful example: a bounded ring queue vs a plain array model.
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates:
|
|
6
|
+
# - manual state machines (Zizek::Stateful::Rule, Zizek::Stateful.run);
|
|
7
|
+
# - assumptions inside rules (pop is rejected while the queue is empty);
|
|
8
|
+
# - invariants checked after every successful rule;
|
|
9
|
+
# - failure capture — the bug eventually reads past the backing array; and
|
|
10
|
+
# - structural shrinking of whole rule spans plus blob replay.
|
|
11
|
+
|
|
12
|
+
require "zizek"
|
|
13
|
+
|
|
14
|
+
module QueueExample
|
|
15
|
+
CAPACITY = 2
|
|
16
|
+
|
|
17
|
+
# The shrunk failure blob is identical in Go, Python, and Ruby.
|
|
18
|
+
PORTABLE_QUEUE_FAILURE =
|
|
19
|
+
"AXicjcnBCQBACAPB3e8VcP2XqiL4E8xnQvIB5UmlcYrbkp7ONAAiJQCa"
|
|
20
|
+
|
|
21
|
+
# A fixed-capacity FIFO queue over a circular buffer. Pop wraps the head
|
|
22
|
+
# index one slot too late — a deliberate off-by-one that reads past the
|
|
23
|
+
# backing array (returning nil) on the third pop.
|
|
24
|
+
class RingQueue
|
|
25
|
+
attr_reader :size
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@data = Array.new(CAPACITY, 0)
|
|
29
|
+
@head = 0
|
|
30
|
+
@tail = 0
|
|
31
|
+
@size = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def push(value)
|
|
35
|
+
return false if size == CAPACITY
|
|
36
|
+
|
|
37
|
+
@data[@tail] = value
|
|
38
|
+
@tail = (@tail + 1) % CAPACITY
|
|
39
|
+
@size += 1
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pop
|
|
44
|
+
value = @data[@head]
|
|
45
|
+
@head = (@head + 1) % (CAPACITY + 1) # BUG: should wrap modulo CAPACITY
|
|
46
|
+
@size -= 1
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class QueueTest
|
|
52
|
+
def initialize
|
|
53
|
+
@queue = RingQueue.new
|
|
54
|
+
@model = []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def push(test_case)
|
|
58
|
+
value = test_case.draw(Zizek::Generators.integers.min_value(0).max_value(99))
|
|
59
|
+
if @queue.push(value)
|
|
60
|
+
@model << value
|
|
61
|
+
test_case.note("push #{value}")
|
|
62
|
+
else
|
|
63
|
+
test_case.note("push #{value} refused: queue full")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pop(test_case)
|
|
68
|
+
test_case.assume(!@model.empty?)
|
|
69
|
+
want = @model.shift
|
|
70
|
+
got = @queue.pop
|
|
71
|
+
test_case.note("pop -> #{got.inspect}")
|
|
72
|
+
raise "pop returned #{got.inspect}, expected #{want}" unless got == want
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sizes_agree(_test_case)
|
|
76
|
+
return if @queue.size == @model.length
|
|
77
|
+
|
|
78
|
+
raise "queue reports #{@queue.size} items, model holds #{@model.length}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rules
|
|
82
|
+
[
|
|
83
|
+
Zizek::Stateful::Rule.new("push", ->(machine, test_case) { machine.push(test_case) }),
|
|
84
|
+
Zizek::Stateful::Rule.new("pop", ->(machine, test_case) { machine.pop(test_case) })
|
|
85
|
+
]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def invariants
|
|
89
|
+
[
|
|
90
|
+
Zizek::Stateful::Rule.new(
|
|
91
|
+
"sizes_agree",
|
|
92
|
+
->(machine, test_case) { machine.sizes_agree(test_case) }
|
|
93
|
+
)
|
|
94
|
+
]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
module_function
|
|
99
|
+
|
|
100
|
+
def property(test_case)
|
|
101
|
+
Zizek::Stateful.run(QueueTest.new, test_case)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_and_replay_bug
|
|
105
|
+
found =
|
|
106
|
+
begin
|
|
107
|
+
Zizek.check(method(:property), examples: 2_000, seed: 3)
|
|
108
|
+
raise "the ring-queue off-by-one should be discovered"
|
|
109
|
+
rescue Zizek::Failure => failure
|
|
110
|
+
failure
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
Zizek.check(method(:property), reproduce: found.blob)
|
|
115
|
+
rescue Zizek::Failure
|
|
116
|
+
return found
|
|
117
|
+
end
|
|
118
|
+
raise "the shrunk queue failure must replay"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
puts QueueExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Round-trip property example: run-length encoding.
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates:
|
|
6
|
+
# - the portable text generator (Zizek::Generators.text);
|
|
7
|
+
# - notes attached to the failing example (test_case.note);
|
|
8
|
+
# - shrinking to a minimal counterexample; and
|
|
9
|
+
# - replaying the portable reproduce blob.
|
|
10
|
+
|
|
11
|
+
require "zizek"
|
|
12
|
+
|
|
13
|
+
module RoundtripExample
|
|
14
|
+
# The shrunk failure blob is identical in Go, Python, and Ruby.
|
|
15
|
+
PORTABLE_ROUNDTRIP_FAILURE = "AAEAAAAEAQAAADAAAAA="
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Compresses text into [count, char] runs. Deliberate bug: the run in
|
|
20
|
+
# progress is never flushed when the input ends, so the final run of every
|
|
21
|
+
# non-empty input is lost.
|
|
22
|
+
def encode(text)
|
|
23
|
+
runs = []
|
|
24
|
+
count = 0
|
|
25
|
+
current = nil
|
|
26
|
+
text.each_char do |char|
|
|
27
|
+
if count.positive? && char == current
|
|
28
|
+
count += 1
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
runs << [count, current] if count.positive?
|
|
32
|
+
current = char
|
|
33
|
+
count = 1
|
|
34
|
+
end
|
|
35
|
+
runs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decode(runs)
|
|
39
|
+
runs.map { |count, char| char * count }.join
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def property(test_case)
|
|
43
|
+
text = test_case.draw(Zizek::Generators.text.max_size(12))
|
|
44
|
+
test_case.note("text #{text.inspect} encodes to #{encode(text).inspect}")
|
|
45
|
+
decoded = decode(encode(text))
|
|
46
|
+
return if decoded == text
|
|
47
|
+
|
|
48
|
+
raise "round trip lost data: #{text.inspect} became #{decoded.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def find_and_replay_bug
|
|
52
|
+
found =
|
|
53
|
+
begin
|
|
54
|
+
Zizek.check(method(:property), examples: 200, seed: 1)
|
|
55
|
+
raise "the round-trip bug should be discovered"
|
|
56
|
+
rescue Zizek::Failure => failure
|
|
57
|
+
failure
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The blob is a portable description of the shrunk choices: replaying it
|
|
61
|
+
# runs exactly the recorded example, here or in any other Zizek package.
|
|
62
|
+
begin
|
|
63
|
+
Zizek.check(method(:property), reproduce: found.blob)
|
|
64
|
+
rescue Zizek::Failure
|
|
65
|
+
return found
|
|
66
|
+
end
|
|
67
|
+
raise "the shrunk round-trip failure must replay"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts RoundtripExample.find_and_replay_bug if $PROGRAM_NAME == __FILE__
|
data/examples/stack.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Port of Hegel's passing stack state machine. It demonstrates a model whose
|
|
4
|
+
# rules include an assumption: push_pop is rejected while the stack is empty,
|
|
5
|
+
# without rejecting the whole generated example.
|
|
6
|
+
|
|
7
|
+
require "zizek"
|
|
8
|
+
|
|
9
|
+
module StackExample
|
|
10
|
+
class IntegerStack
|
|
11
|
+
def initialize
|
|
12
|
+
@stack = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def push(test_case)
|
|
16
|
+
@stack << test_case.draw(Zizek::Generators.integers)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pop(_test_case)
|
|
20
|
+
@stack.pop
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pop_push(test_case)
|
|
24
|
+
value = test_case.draw(Zizek::Generators.integers)
|
|
25
|
+
initial = @stack.dup
|
|
26
|
+
@stack << value
|
|
27
|
+
raise "pop did not return the pushed value" unless @stack.pop == value
|
|
28
|
+
raise "push then pop changed the stack" unless @stack == initial
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def push_pop(test_case)
|
|
32
|
+
initial = @stack.dup
|
|
33
|
+
value = @stack.pop
|
|
34
|
+
test_case.assume(!value.nil?)
|
|
35
|
+
@stack << value
|
|
36
|
+
raise "pop then push changed the stack" unless @stack == initial
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rules
|
|
40
|
+
[
|
|
41
|
+
Zizek::Stateful::Rule.new("push", ->(machine, test_case) { machine.push(test_case) }),
|
|
42
|
+
Zizek::Stateful::Rule.new("pop", ->(machine, test_case) { machine.pop(test_case) }),
|
|
43
|
+
Zizek::Stateful::Rule.new("pop_push", ->(machine, test_case) { machine.pop_push(test_case) }),
|
|
44
|
+
Zizek::Stateful::Rule.new("push_pop", ->(machine, test_case) { machine.push_pop(test_case) })
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def invariants
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def property(test_case)
|
|
56
|
+
Zizek::Stateful.run(IntegerStack.new, test_case)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def verify_stack_model
|
|
60
|
+
Zizek.check(method(:property), examples: 100, seed: 1)
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if $PROGRAM_NAME == __FILE__
|
|
66
|
+
StackExample.verify_stack_model
|
|
67
|
+
puts "stack model passed"
|
|
68
|
+
end
|
data/examples/tour.rb
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A tour of Zizek's draw and configuration surface, one scenario per feature:
|
|
4
|
+
#
|
|
5
|
+
# 1. bounded integers with a custom shrink target;
|
|
6
|
+
# 2. assumptions that reject examples instead of failing them;
|
|
7
|
+
# 3. boolean and byte draws shrinking together;
|
|
8
|
+
# 4. bounded IEEE-754 floats with portable shrinking; and
|
|
9
|
+
# 5. rejection limits (Zizek::TooManyRejections).
|
|
10
|
+
#
|
|
11
|
+
# Every scenario is deterministic: it uses a fixed seed and prints the shrunk
|
|
12
|
+
# failure it expects to find.
|
|
13
|
+
|
|
14
|
+
require "zizek"
|
|
15
|
+
|
|
16
|
+
module TourExample
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# shrink_towards: failures shrink to the failing value closest to the
|
|
20
|
+
# requested target instead of the interval minimum.
|
|
21
|
+
def shrink_targets
|
|
22
|
+
property = lambda do |test_case|
|
|
23
|
+
value = test_case.draw(
|
|
24
|
+
Zizek::Generators.integers.min_value(-1000).max_value(1000).shrink_towards(500)
|
|
25
|
+
)
|
|
26
|
+
test_case.note("value #{value}")
|
|
27
|
+
raise "value #{value} exceeds the budget of 700" if value > 700
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
Zizek.check(property, examples: 300, seed: 1)
|
|
32
|
+
raise "expected a failure above 700"
|
|
33
|
+
rescue Zizek::Failure => failure
|
|
34
|
+
failure
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# assume: odd draws are rejected (not failed), so the shrunk counterexample
|
|
39
|
+
# is the smallest *even* value above the limit.
|
|
40
|
+
def even_numbers_only
|
|
41
|
+
property = lambda do |test_case|
|
|
42
|
+
value = test_case.integer(0, 100)
|
|
43
|
+
test_case.assume(value.even?)
|
|
44
|
+
test_case.note("even value #{value}")
|
|
45
|
+
raise "even value #{value} exceeds 50" if value > 50
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
Zizek.check(property, examples: 300, seed: 1)
|
|
50
|
+
raise "expected an even failure above 50"
|
|
51
|
+
rescue Zizek::Failure => failure
|
|
52
|
+
failure
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Low-level boolean and bytes draws shrinking together: the boolean stays
|
|
57
|
+
# true because false passes, and the payload shrinks to the single smallest
|
|
58
|
+
# high byte.
|
|
59
|
+
def flags_and_bytes
|
|
60
|
+
property = lambda do |test_case|
|
|
61
|
+
compressed = test_case.boolean
|
|
62
|
+
payload = test_case.bytes(1, 4)
|
|
63
|
+
test_case.note("compressed=#{compressed} payload=#{payload.unpack1('H*')}")
|
|
64
|
+
next unless compressed
|
|
65
|
+
|
|
66
|
+
payload.each_byte do |value|
|
|
67
|
+
raise format("compressed payload contains high byte 0x%02x", value) if value >= 0x80
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
Zizek.check(property, examples: 300, seed: 1)
|
|
73
|
+
raise "expected a high-byte failure"
|
|
74
|
+
rescue Zizek::Failure => failure
|
|
75
|
+
failure
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Float generation, replay, and shrink ordering use the compiled float
|
|
80
|
+
# choice. The smallest value outside [0, 1) is exactly 1.0.
|
|
81
|
+
def bounded_floats
|
|
82
|
+
property = lambda do |test_case|
|
|
83
|
+
value = test_case.draw(
|
|
84
|
+
Zizek::Generators.floats.min_value(0.0).max_value(10.0)
|
|
85
|
+
)
|
|
86
|
+
test_case.note("value #{value.inspect}")
|
|
87
|
+
raise "float #{value.inspect} is outside [0, 1)" if value >= 1.0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
Zizek.check(property, examples: 300, seed: 1)
|
|
92
|
+
raise "expected a float failure"
|
|
93
|
+
rescue Zizek::Failure => failure
|
|
94
|
+
failure
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# max_rejections: a property whose assumptions reject every example stops
|
|
99
|
+
# with TooManyRejections instead of searching forever.
|
|
100
|
+
def rejection_limit
|
|
101
|
+
property = lambda do |test_case|
|
|
102
|
+
value = test_case.integer(0, 1000)
|
|
103
|
+
test_case.assume(value > 1000) # impossible: every example is rejected
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
Zizek.check(property, examples: 10, max_rejections: 20, seed: 1)
|
|
108
|
+
raise "expected TooManyRejections"
|
|
109
|
+
rescue Zizek::TooManyRejections => rejected
|
|
110
|
+
rejected
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
if $PROGRAM_NAME == __FILE__
|
|
116
|
+
puts "== 1. integers with a shrink target =="
|
|
117
|
+
puts TourExample.shrink_targets.message
|
|
118
|
+
puts "\n== 2. assumptions =="
|
|
119
|
+
puts TourExample.even_numbers_only.message
|
|
120
|
+
puts "\n== 3. booleans and bytes =="
|
|
121
|
+
puts TourExample.flags_and_bytes.message
|
|
122
|
+
puts "\n== 4. bounded floats =="
|
|
123
|
+
puts TourExample.bounded_floats.message
|
|
124
|
+
puts "\n== 5. rejection limits =="
|
|
125
|
+
puts TourExample.rejection_limit.message
|
|
126
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Zizek
|
|
7
|
+
# Where a counterexample goes after the run that found it.
|
|
8
|
+
#
|
|
9
|
+
# A database is a cache that never needs invalidating. It stores raw
|
|
10
|
+
# serialized choice streams — the bytes under a reproduce blob, not the blob
|
|
11
|
+
# — keyed by a property's name, so the next run replays yesterday's failure
|
|
12
|
+
# instead of hoping to rediscover it.
|
|
13
|
+
#
|
|
14
|
+
# Both digests are the project's compiled keyed-draw hash (ir/keys.zir), so a
|
|
15
|
+
# directory written by this gem is byte-for-byte the directory the Python and
|
|
16
|
+
# Go packages write.
|
|
17
|
+
module Database
|
|
18
|
+
# Where a database goes when a caller asks for one without saying where.
|
|
19
|
+
DEFAULT_PATH = ".zizek/examples"
|
|
20
|
+
|
|
21
|
+
# An entry's filename: sixteen lowercase hex digits and nothing else.
|
|
22
|
+
# Anything else in a key directory belongs to something other than the
|
|
23
|
+
# engine — .name, a .gitignore, an editor's swap file — and is ignored
|
|
24
|
+
# rather than replayed.
|
|
25
|
+
ENTRY_PATTERN = /\A[0-9a-f]{16}\z/.freeze
|
|
26
|
+
|
|
27
|
+
# The file naming a key directory, for a human reading a checkout. The
|
|
28
|
+
# engine writes it and never reads it.
|
|
29
|
+
NAME_FILE = ".name"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The sixteen hex digits a name or a stream is stored under.
|
|
34
|
+
#
|
|
35
|
+
# FNV-1a 64 over the tagged encoding compiled from ir/keys.zir, which is the
|
|
36
|
+
# same hash a keyed draw uses and the reason a database directory is
|
|
37
|
+
# portable across languages.
|
|
38
|
+
def digest(value)
|
|
39
|
+
format("%016x", Zizek.key_hash(value.to_s.b))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The order stored entries are replayed in: shortest first, then bytes.
|
|
43
|
+
# The same order the shrinker prefers, so the simplest stored
|
|
44
|
+
# counterexample is the first one a run pays for.
|
|
45
|
+
def shortlex(stream)
|
|
46
|
+
[stream.bytesize, stream]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Turn what a caller passed for `database:` into a database or nil.
|
|
50
|
+
#
|
|
51
|
+
# nil and false disable it, true selects the default directory, ":memory:"
|
|
52
|
+
# an in-memory one, any other string or Pathname a directory, and anything
|
|
53
|
+
# else is assumed to implement fetch/save/delete itself.
|
|
54
|
+
def resolve(spec)
|
|
55
|
+
return nil if spec.nil? || spec == false
|
|
56
|
+
return Directory.new(DEFAULT_PATH) if spec == true
|
|
57
|
+
return Memory.new if spec == ":memory:"
|
|
58
|
+
return Directory.new(spec) if spec.is_a?(String) || spec.respond_to?(:to_path)
|
|
59
|
+
|
|
60
|
+
%i[fetch save delete].each do |method|
|
|
61
|
+
next if spec.respond_to?(method)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError,
|
|
64
|
+
"database must be a path, \":memory:\", true, nil, or an object " \
|
|
65
|
+
"responding to fetch/save/delete; #{spec.inspect} does not respond to #{method}"
|
|
66
|
+
end
|
|
67
|
+
spec
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A database that forgets when the process exits.
|
|
71
|
+
#
|
|
72
|
+
# For a suite that wants replay within a run — or a test about the database
|
|
73
|
+
# itself — without leaving anything behind.
|
|
74
|
+
class Memory
|
|
75
|
+
def initialize
|
|
76
|
+
@entries = Hash.new { |store, key| store[key] = [] }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inspect
|
|
80
|
+
"#<Zizek::Database::Memory>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fetch(key)
|
|
84
|
+
@entries.key?(key) ? @entries[key].dup : []
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def save(key, value)
|
|
88
|
+
stream = value.b.freeze
|
|
89
|
+
@entries[key] << stream unless @entries[key].include?(stream)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def delete(key, value)
|
|
93
|
+
return unless @entries.key?(key)
|
|
94
|
+
|
|
95
|
+
@entries[key].delete(value.b)
|
|
96
|
+
@entries.delete(key) if @entries[key].empty?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A directory of counterexamples, one subdirectory per property.
|
|
101
|
+
#
|
|
102
|
+
# The layout is shared with every other zizek package, so a directory
|
|
103
|
+
# checked into version control replays in any of them:
|
|
104
|
+
#
|
|
105
|
+
# .zizek/examples/
|
|
106
|
+
# 9dc0c2b1f8a17c4e/ # the key's digest
|
|
107
|
+
# .name # the property's name, for a human
|
|
108
|
+
# 3a6e0f5c9b2d1877 # a stored stream
|
|
109
|
+
#
|
|
110
|
+
# Every operation is best-effort: a database that cannot be read or written
|
|
111
|
+
# must not fail a test that would otherwise pass.
|
|
112
|
+
class Directory
|
|
113
|
+
attr_reader :path
|
|
114
|
+
|
|
115
|
+
def initialize(path = DEFAULT_PATH)
|
|
116
|
+
@path = path.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inspect
|
|
120
|
+
"#<Zizek::Database::Directory #{path.inspect}>"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def ==(other)
|
|
124
|
+
other.is_a?(Directory) && other.path == path
|
|
125
|
+
end
|
|
126
|
+
alias eql? ==
|
|
127
|
+
|
|
128
|
+
def hash
|
|
129
|
+
[Directory, path].hash
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def key_path(key)
|
|
133
|
+
File.join(path, Database.__send__(:digest, key))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def value_path(key, value)
|
|
137
|
+
File.join(key_path(key), Database.__send__(:digest, value))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private :key_path, :value_path
|
|
141
|
+
|
|
142
|
+
def fetch(key)
|
|
143
|
+
directory = key_path(key)
|
|
144
|
+
names = begin
|
|
145
|
+
Dir.children(directory)
|
|
146
|
+
rescue SystemCallError
|
|
147
|
+
return []
|
|
148
|
+
end
|
|
149
|
+
names.select { |name| ENTRY_PATTERN.match?(name) }.map do |name|
|
|
150
|
+
begin
|
|
151
|
+
File.binread(File.join(directory, name))
|
|
152
|
+
rescue SystemCallError
|
|
153
|
+
# Deleted between the listing and the read: a concurrent run
|
|
154
|
+
# collected it, which is exactly what it is supposed to do.
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end.compact
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def save(key, value)
|
|
161
|
+
directory = key_path(key)
|
|
162
|
+
FileUtils.mkdir_p(directory)
|
|
163
|
+
name_file = File.join(directory, NAME_FILE)
|
|
164
|
+
Database.__send__(:atomic_write, name_file, key.to_s.b) unless File.exist?(name_file)
|
|
165
|
+
entry = File.join(directory, Database.__send__(:digest, value))
|
|
166
|
+
Database.__send__(:atomic_write, entry, value.b) unless File.exist?(entry)
|
|
167
|
+
rescue SystemCallError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def delete(key, value)
|
|
172
|
+
File.unlink(value_path(key, value))
|
|
173
|
+
# An emptied key directory takes its .name with it, so a database that
|
|
174
|
+
# collected everything leaves no husk behind — but only once the last
|
|
175
|
+
# entry is gone, or a directory that still holds counterexamples would
|
|
176
|
+
# lose the label that says whose they are.
|
|
177
|
+
begin
|
|
178
|
+
directory = key_path(key)
|
|
179
|
+
return if Dir.children(directory).any? { |name| ENTRY_PATTERN.match?(name) }
|
|
180
|
+
|
|
181
|
+
File.unlink(File.join(directory, NAME_FILE))
|
|
182
|
+
Dir.rmdir(directory)
|
|
183
|
+
rescue SystemCallError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
rescue SystemCallError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Write through a temporary file, so a reader never sees a partial entry.
|
|
192
|
+
def atomic_write(path, payload)
|
|
193
|
+
directory = File.dirname(path)
|
|
194
|
+
temporary = Tempfile.create(".zizek", directory, binmode: true)
|
|
195
|
+
begin
|
|
196
|
+
temporary.write(payload)
|
|
197
|
+
temporary.close
|
|
198
|
+
File.rename(temporary.path, path)
|
|
199
|
+
rescue SystemCallError
|
|
200
|
+
begin
|
|
201
|
+
File.unlink(temporary.path)
|
|
202
|
+
rescue SystemCallError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private_class_method :digest, :shortlex, :resolve, :atomic_write
|
|
209
|
+
private_constant :ENTRY_PATTERN, :NAME_FILE
|
|
210
|
+
end
|
|
211
|
+
end
|