quint_driver 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.
@@ -0,0 +1,199 @@
1
+ require "rbconfig"
2
+ require "set"
3
+
4
+ # Tests a Ruby system against a Quint spec. Two ways share one driver: replay
5
+ # the traces `quint run --mbt` writes (QuintDriver::Trace.replay), or let Hegel
6
+ # choose the steps from the spec's actions (QuintDriver.machine, which
7
+ # `require "quint_driver/hegel"` adds). A driver answers #step(action, picks)
8
+ # by running that command on the system, and #state with the spec's variables
9
+ # as plain Ruby values.
10
+ #
11
+ # Plain values: a Quint set is a Set, a map a Hash, a tuple or a list an
12
+ # Array, a record a Hash with string keys, a sum type its tag, or
13
+ # [tag, value] when it carries one. An Option, a sum type of None and Some,
14
+ # is nil or its value.
15
+ module QuintDriver
16
+ # The trace a failure happened on. On a replay, +trace+ is its path. Under
17
+ # Quint.simulate or Quint.test that file is gone once the block returns, so
18
+ # +seed+ and +written_by+ hold the seed and the call that write it again,
19
+ # and the message names the trace by those instead of by its path. The gem
20
+ # fills these in, so a caller cannot name a trace that disagrees with the
21
+ # one that ran.
22
+ module NamedTrace
23
+ attr_reader :trace, :seed, :written_by
24
+
25
+ def to_s
26
+ return super unless trace
27
+
28
+ "#{super}\ntrace: #{written_by ? "#{File.basename(trace)} of #{written_by}" : trace}"
29
+ end
30
+
31
+ private
32
+ def name_trace(trace, seed: nil, written_by: nil) = (@trace, @seed, @written_by = trace, seed, written_by)
33
+ end
34
+ private_constant :NamedTrace
35
+
36
+ # The system and the spec disagree. A driver or a check of the caller's own
37
+ # raises one with a message alone, as `raise QuintDriver::Mismatch, "..."`.
38
+ class Mismatch < StandardError
39
+ include NamedTrace
40
+ end
41
+
42
+ # A step raised: the driver's code, or the system's beneath it, while it
43
+ # ran a step or reported its state, so the spec and the system were never
44
+ # compared. The message names the step, the project's frame that raised,
45
+ # and the steps from init. The backtrace is the error's, and +cause+ is the
46
+ # error.
47
+ class StepRaised < StandardError
48
+ include NamedTrace
49
+ end
50
+
51
+ # The quint command failed. The message holds its output.
52
+ class QuintFailed < StandardError; end
53
+
54
+ # The spec uses an operator the model does not evaluate, or a shape no
55
+ # driver can run, such as a step arm that names no action.
56
+ class Unsupported < StandardError; end
57
+
58
+ # The one check Trace.replay and the Hegel machine share. It is private, so
59
+ # the gem's API stays the driver protocol and the errors.
60
+ module Agreement
61
+ # Raises Mismatch naming each variable where +actual+ differs from
62
+ # +expected+, and the [action, picks] +steps+ the driver ran from init, so
63
+ # the failure can be run again without the trace file. The driver has to
64
+ # report every variable but those +ignore+ names, and each definition
65
+ # +observe+ names: a variable it leaves out would otherwise pass however
66
+ # wrong the system's copy is. The model evaluates an observed definition,
67
+ # also on a trace's state, so its line names the model. +spec+, when the
68
+ # caller has one, tells a name the driver reports that is no variable
69
+ # apart from a definition it forgot to observe.
70
+ def self.check!(after, expected, actual, by:, steps:, observe: [], spec: nil, ignore: [])
71
+ expected, actual = expected.except(*ignore), actual.except(*ignore)
72
+ return if expected == actual
73
+
74
+ lines = (actual.keys | expected.keys).reject { expected.key?(_1) && actual.key?(_1) && expected[_1] == actual[_1] }.map do |name|
75
+ next [ by, unknown(name, actual[name], expected.keys - observe, by:, spec:) ] unless expected.key?(name)
76
+
77
+ source = observe.include?(name) ? "model" : by
78
+ next [ source, " #{name}: #{source} #{show(expected[name])}, driver #{show(actual[name])}" ] if actual.key?(name)
79
+
80
+ fix = observe.include?(name) ? "report it, since observe: names it" : "report it, or name it in ignore: when the driver only reads it"
81
+ [ source, " #{name}: #{source} #{show(expected[name])}, but the driver's state leaves #{name} out; #{fix}" ]
82
+ end
83
+ raise Mismatch, "quint: after #{after}, the driver differs from the #{lines.map(&:first).uniq.join(' and the ')}\n" \
84
+ "#{lines.map(&:last).join("\n")}\n#{ran(steps)}", outside(caller_locations)
85
+ end
86
+
87
+ # A name +ignore+ holds that +names+ lacks is refused, so a misspelt name
88
+ # reads as one rather than as its variable left out.
89
+ def self.ignorable!(ignore, names, within)
90
+ unknown = ignore - names
91
+ return if unknown.empty?
92
+
93
+ refuse "quint: ignore names #{unknown.join(', ')}, which #{unknown.one? ? 'is' : 'are'} not in #{within}; it has #{names.sort.join(', ')}"
94
+ end
95
+
96
+ def self.unknown(name, reported, names, by:, spec:)
97
+ within = by == "trace" ? "the trace's state, which has #{names.sort.join(', ')}" : "the spec's variables, #{names.sort.join(', ')}"
98
+ hint = if spec.nil? then "; to hold the driver to a definition, pass spec: and observe:"
99
+ elsif spec.send(:observable).include?(name) then "; it is a definition of the spec, so name it in observe:"
100
+ else ""
101
+ end
102
+ " #{name}: driver #{show(reported)}, but #{name} is not in #{within}#{hint}"
103
+ end
104
+
105
+ # Runs the block, the driver's part of the step named +at+, and returns
106
+ # what it returns. An error the driver's own code raises comes out as
107
+ # StepRaised with +steps+; the gem's own errors pass through.
108
+ def self.drive(at, steps:)
109
+ yield
110
+ rescue Mismatch, StepRaised => failure
111
+ raise lead_outside(failure)
112
+ rescue Unsupported, QuintFailed
113
+ raise
114
+ rescue StandardError => error
115
+ raise StepRaised, "quint: at #{at}, the step raised #{error.class}: #{error.message}\n#{raised_in(error)}#{ran(steps)}",
116
+ outside(error.backtrace_locations || error.backtrace)
117
+ end
118
+
119
+ # +frames+ without quint_driver's own, nor Hegel's under the machine, so
120
+ # the first line a test runner prints of a Mismatch or a StepRaised is
121
+ # the driver's, the system's, or the test's: RSpec hides the frames of an
122
+ # installed gem, but not of one a Gemfile loads from a path, and Minitest
123
+ # hides none. Hegel groups the failures it shrinks by the first frame, so
124
+ # the frames stay Locations where Ruby can set them, from 3.4 on.
125
+ def self.outside(frames)
126
+ kept = frames.reject { _1.to_s.start_with?(*@hidden) }
127
+ kept = frames if kept.empty?
128
+ RUBY_VERSION >= "3.4" ? kept : kept.map(&:to_s)
129
+ end
130
+
131
+ # Raises +error+ (ArgumentError by default) with +message+ from the line
132
+ # that called quint_driver: the argument it is about, or the spec quint
133
+ # failed on, is that caller's. The frames of Ruby's library between two
134
+ # of quint_driver's, such as Dir.mktmpdir's, go too.
135
+ def self.refuse(message, error = ArgumentError)
136
+ frames = caller_locations
137
+ last = frames.rindex { _1.to_s.start_with?(*@hidden) }
138
+ raise error, message, outside(last ? frames.drop(last + 1) : frames)
139
+ end
140
+
141
+ # A failure the driver or a caller's block raised, its backtrace +outside+.
142
+ def self.lead_outside(failure)
143
+ failure.tap { _1.set_backtrace(outside(_1.backtrace_locations || _1.backtrace)) }
144
+ end
145
+
146
+ # The project's frame nearest the raise, the one to read first: an error
147
+ # raised inside a gem or Ruby's library names the line that called it.
148
+ # The frames below quint_driver's are the caller of the replay or the
149
+ # machine, so they are never the step's. An error with no frames of its
150
+ # own, such as one raised with a backtrace of strings, names none.
151
+ def self.raised_in(error)
152
+ frames = error.backtrace_locations or return
153
+ frame = frames.take_while { !library?(_1, QUINT_DRIVER) }.find { !library?(_1, *LIBRARIES) }
154
+ return "raised in: no frame of the project; every frame is in a gem, in Ruby's library, or in quint_driver\n" unless frame
155
+
156
+ "raised in: #{relative(frame.to_s)}\n"
157
+ end
158
+
159
+ # A path as the project names it, as RSpec prints one, when it is under
160
+ # the directory the run started in. That is the directory when the gem
161
+ # loaded, as RSpec reads it once, so a driver that changes directory does
162
+ # not turn the path absolute.
163
+ def self.relative(path) = path.delete_prefix(STARTED_IN)
164
+ STARTED_IN = File.join(Dir.pwd, "")
165
+
166
+ QUINT_DRIVER = File.join(__dir__, "quint_driver")
167
+ @hidden = [ QUINT_DRIVER ]
168
+
169
+ # The Hegel adapter hides Hegel's frames too, as it is the one file that
170
+ # knows where Hegel is.
171
+ def self.hide(prefix) = @hidden << prefix
172
+
173
+ LIBRARIES = [ QUINT_DRIVER, "<internal:", RbConfig::CONFIG["rubylibprefix"] + File::SEPARATOR,
174
+ *(defined?(Gem) ? Gem.path : []).map { File.join(_1, "") } ].freeze
175
+
176
+ def self.library?(frame, *prefixes) = frame.path.start_with?(*prefixes)
177
+
178
+ def self.ran(steps) = "steps:\n#{steps.map { |action, picks| " #{action} #{show(picks)}" }.join("\n")}"
179
+
180
+ # A plain value as a failure prints it: a set's elements and a hash's
181
+ # keys sorted, so the spec's side and the driver's side, and a replay and
182
+ # the machine, print the same value the same way on every Ruby.
183
+ def self.show(value)
184
+ case value
185
+ when Set then "{#{value.map { show(_1) }.sort.join(', ')}}"
186
+ when Hash then "{#{value.map { |k, v| [ show(k), show(v) ] }.sort.map { "#{_1} => #{_2}" }.join(', ')}}"
187
+ when Array then "[#{value.map { show(_1) }.join(', ')}]"
188
+ else value.inspect
189
+ end
190
+ end
191
+ end
192
+ private_constant :Agreement
193
+ end
194
+
195
+ require_relative "quint_driver/version"
196
+ require_relative "quint_driver/quint"
197
+ require_relative "quint_driver/trace"
198
+ require_relative "quint_driver/spec"
199
+ require_relative "quint_driver/model"