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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.ja.md +865 -0
- data/README.md +648 -0
- data/examples/loans/loans.qnt +57 -0
- data/examples/loans/loans.rb +62 -0
- data/lib/quint_driver/hegel.rb +141 -0
- data/lib/quint_driver/model.rb +289 -0
- data/lib/quint_driver/quint.rb +376 -0
- data/lib/quint_driver/spec.rb +110 -0
- data/lib/quint_driver/trace.rb +129 -0
- data/lib/quint_driver/version.rb +3 -0
- data/lib/quint_driver.rb +199 -0
- data/skills/quint_driver/SKILL.md +925 -0
- metadata +55 -0
data/lib/quint_driver.rb
ADDED
|
@@ -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"
|