duel 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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/AGENT_PROMPT.md +211 -0
  3. data/CHANGELOG.md +281 -0
  4. data/LICENSE +21 -0
  5. data/README.md +354 -0
  6. data/docs/README.md +23 -0
  7. data/docs/benchmarks.md +289 -0
  8. data/docs/ci.md +141 -0
  9. data/docs/configuration.md +429 -0
  10. data/docs/how-it-works.md +207 -0
  11. data/docs/json.md +306 -0
  12. data/docs/plugins.md +408 -0
  13. data/docs/troubleshooting.md +105 -0
  14. data/exe/duel +6 -0
  15. data/lib/duel/across.rb +48 -0
  16. data/lib/duel/baseline.rb +166 -0
  17. data/lib/duel/cli.rb +264 -0
  18. data/lib/duel/config.rb +343 -0
  19. data/lib/duel/context.rb +630 -0
  20. data/lib/duel/init.rb +211 -0
  21. data/lib/duel/json_report.rb +107 -0
  22. data/lib/duel/lock.rb +38 -0
  23. data/lib/duel/metric.rb +496 -0
  24. data/lib/duel/native.rb +162 -0
  25. data/lib/duel/process_sample.rb +144 -0
  26. data/lib/duel/profile.rb +116 -0
  27. data/lib/duel/rake_task.rb +25 -0
  28. data/lib/duel/resource.rb +209 -0
  29. data/lib/duel/rubies.rb +149 -0
  30. data/lib/duel/runner.rb +1114 -0
  31. data/lib/duel/stats.rb +73 -0
  32. data/lib/duel/summary.rb +237 -0
  33. data/lib/duel/templates/benchmark_duel.rb.erb +34 -0
  34. data/lib/duel/templates/duel.rb.erb +35 -0
  35. data/lib/duel/templates/perf.yml.erb +97 -0
  36. data/lib/duel/testing.rb +241 -0
  37. data/lib/duel/version.rb +5 -0
  38. data/lib/duel/worker.rb +211 -0
  39. data/lib/duel.rb +413 -0
  40. data/sig/duel/across.rbs +21 -0
  41. data/sig/duel/baseline.rbs +61 -0
  42. data/sig/duel/cli.rbs +95 -0
  43. data/sig/duel/config.rbs +181 -0
  44. data/sig/duel/context.rbs +387 -0
  45. data/sig/duel/init.rbs +97 -0
  46. data/sig/duel/json_report.rbs +47 -0
  47. data/sig/duel/lock.rbs +12 -0
  48. data/sig/duel/metric.rbs +351 -0
  49. data/sig/duel/native.rbs +98 -0
  50. data/sig/duel/process_sample.rbs +54 -0
  51. data/sig/duel/profile.rbs +64 -0
  52. data/sig/duel/rake_task.rbs +9 -0
  53. data/sig/duel/resource.rbs +106 -0
  54. data/sig/duel/rubies.rbs +45 -0
  55. data/sig/duel/runner.rbs +530 -0
  56. data/sig/duel/stats.rbs +26 -0
  57. data/sig/duel/summary.rbs +87 -0
  58. data/sig/duel/testing.rbs +91 -0
  59. data/sig/duel/version.rbs +3 -0
  60. data/sig/duel/worker.rbs +74 -0
  61. data/sig/duel.rbs +276 -0
  62. data/sig/manifest.yaml +4 -0
  63. metadata +124 -0
data/lib/duel.rb ADDED
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "duel/version"
4
+ require_relative "duel/config"
5
+ require_relative "duel/metric"
6
+
7
+ # Performance regression guards: every benchmark runs on two versions of
8
+ # a codebase side by side, the baseline and the candidate, and the
9
+ # candidate fails if it's slower at any of them. See the README.
10
+ module Duel
11
+ # What Duel raises when something is used wrong, or fails: a setting
12
+ # there isn't, a helper called where it would be counted, a resource
13
+ # that didn't start (Failed).
14
+ class Error < StandardError; end
15
+
16
+ # The hooks, which run code around a benchmark's samples: in .duel.rb
17
+ # (Config#setup, say), at the top of a file of benchmarks (DSL#setup),
18
+ # or as an option of one benchmark (DSL#duel's setup:). Where several
19
+ # give one, the configuration's run first, then the file's, then the
20
+ # benchmark's (teardown's in reverse, and around_iteration's outermost
21
+ # first):
22
+ #
23
+ # - setup: once in each worker, before the benchmark's samples
24
+ # - teardown: once in each worker, after them
25
+ # - before_sample: before every sample (the warm-up's too), not
26
+ # measured
27
+ # - around_iteration: around each iteration, given it (an Iteration) to
28
+ # run: only the iteration is measured
29
+ # - after_iterations: after each sample's iterations (inside each
30
+ # iteration, with around_iteration), measured with them
31
+ HOOKS = %i[setup teardown before_sample around_iteration after_iterations].freeze
32
+
33
+ # No hooks, by name.
34
+ # @api private
35
+ NO_HOOKS = HOOKS.to_h { |hook| [hook, Array.new(0).freeze] }.freeze #: Hash[Symbol, Array[hook]]
36
+
37
+ # A benchmark (see DSL#duel), defined in +file+, whose hooks (those of
38
+ # its file, and its own, +hooks+, by name) it gets. Or a benchmark of a
39
+ # whole +process+ (see DSL#duel_process), with no +body+. What a
40
+ # metric sees of it (Sample#benchmark), and its hooks are given, is
41
+ # its +name+, +only+ and +peak_memory+. Its +file+, +body+, +hooks+
42
+ # and +process+, and its writers, are @api private.
43
+ Definition = Struct.new(:name, :only, :peak_memory, :file, :body, :hooks, :process)
44
+
45
+ class Definition
46
+ # Whose CPU time it takes: the benchmark thread's, or (judged on its
47
+ # timings alone, as work on threads of its own is) the process's.
48
+ # @api private
49
+ def cpu = (only == :timing) ? :process : :thread
50
+
51
+ # What the runner learns of it.
52
+ # @api private
53
+ def to_h = {"name" => name, "only" => only&.to_s, "peak_memory" => peak_memory, "process" => process}
54
+ end
55
+
56
+ # The iterations of a benchmark judged on its counts alone (see
57
+ # DSL#duel).
58
+ # @api private
59
+ COUNTS_ONLY = 4
60
+
61
+ # What a benchmark may be judged on (see DSL#duel): nil for both.
62
+ # @api private
63
+ ONLY = [nil, :count, :timing] #: Array[only?]
64
+
65
+ # What a worker's blocks (Config#in_worker) are given: blocks to run
66
+ # when the worker ends, last first.
67
+ class Stops
68
+ # @api private
69
+ def initialize
70
+ @blocks = []
71
+ end
72
+
73
+ # Runs the block when the worker ends.
74
+ def at_stop(&block) = @blocks << block
75
+
76
+ # Runs them, last first.
77
+ # @api private
78
+ def run = @blocks.reverse_each(&:call)
79
+ end
80
+
81
+ # What Config#in_worker returns: what its block returned in the worker
82
+ # (a connection, say, that metrics and benchmarks use).
83
+ class InWorker
84
+ # The worker's. Outside a worker (the runner's process, or a test not
85
+ # in Duel::Testing.in_worker), it fails.
86
+ def value
87
+ made = @made
88
+ raise Error, "made in each worker only (c.in_worker): in tests, inside Duel::Testing.in_worker" unless made
89
+
90
+ made.first
91
+ end
92
+
93
+ # @api private
94
+ def made(value) = @made = [value]
95
+
96
+ # @api private
97
+ def forget = @made = nil
98
+ end
99
+
100
+ class << self
101
+ # The configuration: the project's .duel.rb, or the conventions.
102
+ def config = @config ||= Config.new
103
+
104
+ # @api private
105
+ attr_writer :config
106
+
107
+ # Yields the configuration (see Config), as .duel.rb does.
108
+ def configure = yield(config)
109
+
110
+ # Whether a benchmark is being measured right now: in a worker, inside
111
+ # a sample, but not in its +untimed+ blocks (or in setup). What a
112
+ # plugin's helper does then would be counted (a query it runs, say),
113
+ # so a helper meant for setting up can fail instead.
114
+ def measuring? = @measuring == true
115
+
116
+ # Fails while a benchmark is being measured (see #measuring?): a
117
+ # plugin's helper meant for setting up, +name+d, calls it first, since
118
+ # what it does then would be counted.
119
+ #
120
+ # def self.flush
121
+ # Duel.unmeasured!("Duel::Redis.flush")
122
+ # ...
123
+ def unmeasured!(name)
124
+ raise Error, "#{name}: in setup or untimed only (it would be counted)" if measuring?
125
+ end
126
+
127
+ # Runs the block with #measuring? as +measuring+, and returns what it
128
+ # does.
129
+ # @api private
130
+ def measured(measuring)
131
+ was = @measuring
132
+ begin
133
+ @measuring = measuring
134
+ yield
135
+ ensure
136
+ @measuring = was
137
+ end
138
+ end
139
+
140
+ # Loads a config file (.duel.rb): a fresh configuration, rooted where
141
+ # the file is.
142
+ # @api private
143
+ def load_config(path)
144
+ path = File.expand_path(path)
145
+ self.config = Config.new(File.dirname(path))
146
+ load path
147
+ config
148
+ end
149
+
150
+ # Where +method+ is defined ("file:line"), or, for one with no file,
151
+ # whose it is (Kernel, say): for an error about it.
152
+ # @api private
153
+ def where(method) = method.source_location&.join(":") || method.owner.to_s
154
+
155
+ # Every benchmark defined, by name.
156
+ # @api private
157
+ def benchmarks = @benchmarks ||= {}
158
+
159
+ # Each file's hooks (see DSL#setup, say), by its path, then by name.
160
+ # @api private
161
+ def file_hooks = @file_hooks ||= Hash.new { |all, file| all[file] = HOOKS.to_h { |hook| [hook, []] } }
162
+
163
+ # Adds +block+ as the hook +name+ (one of HOOKS) of the file of
164
+ # benchmarks running (see #defining_file), or else of +file+, the one
165
+ # that gave it (not where the block is: a lambda of a plugin's, say).
166
+ # @api private
167
+ def file_hook(name, block, file)
168
+ hook = hook(name, block)
169
+ file_hooks[defining_file || file].fetch(name) << hook
170
+ end
171
+
172
+ # +block+, a hook +name+d (one of HOOKS), or a failure if it isn't
173
+ # one: a Proc (a block, or a lambda), and for around_iteration, one
174
+ # that takes the iteration.
175
+ # @api private
176
+ def hook(name, block)
177
+ raise ArgumentError, "#{name}: give a block (or a lambda), not #{block.inspect}" unless block.is_a?(Proc)
178
+ return block unless name == :around_iteration
179
+ return block if block.arity == 1
180
+
181
+ raise ArgumentError, "around_iteration: take the iteration, and run it: { |iteration| ... iteration.call ... }"
182
+ end
183
+
184
+ # Registers a plugin, +name+d as its file is required (duel/+name+,
185
+ # which Config#use requires): +plugin+, a module whose +configure+
186
+ # Config#use calls, given the configuration and the options it's used
187
+ # with. Returns its name.
188
+ #
189
+ # module Duel
190
+ # module Redis
191
+ # Duel.plugin(:redis, self)
192
+ #
193
+ # def self.configure(config, server: "redis-server")
194
+ # config.prepare(:worker) { |w| ... }
195
+ # end
196
+ # end
197
+ # end
198
+ #
199
+ # Its own kinds of benchmark, if it has any, are the methods of its
200
+ # module's DSL module, which files of benchmarks get as .duel.rb uses
201
+ # it (see DSL.add):
202
+ #
203
+ # module Duel
204
+ # module MyApp
205
+ # Duel.plugin(:my_app, self)
206
+ #
207
+ # module DSL
208
+ # def duel_request(name, path, **options) = duel(name, **options) { ::MyApp.get(path) }
209
+ # end
210
+ # end
211
+ # end
212
+ def plugin(name, plugin)
213
+ plugins[name] = plugin
214
+ name
215
+ end
216
+
217
+ # Every plugin registered, by name.
218
+ # @api private
219
+ def plugins = @plugins ||= {} #: Hash[Symbol, Module & _Plugin]
220
+
221
+ # The file of benchmarks that called the DSL method running (see
222
+ # DSL.add), if one is: the file that what it defines is filed
223
+ # under, so it gets that file's hooks, not those of the file the
224
+ # method is in.
225
+ # @api private
226
+ attr_reader :defining_file
227
+
228
+ # Runs the block with +file+ as #defining_file, unless a DSL method
229
+ # that called this one gave one already (the outermost, a file of
230
+ # benchmarks, is the one), and returns what it does.
231
+ # @api private
232
+ def defining(file)
233
+ outer = @defining_file
234
+ @defining_file = outer || file
235
+ begin
236
+ yield
237
+ ensure
238
+ @defining_file = outer
239
+ end
240
+ end
241
+ end
242
+
243
+ # What files of benchmarks call (the worker gives it to its main
244
+ # object).
245
+ module DSL
246
+ # Adds the public methods of +methods+, a plugin's DSL module (see
247
+ # Duel.plugin), to the DSL, as .duel.rb uses the plugin (see
248
+ # Config#use): files of benchmarks call them on their main object,
249
+ # so they can call #duel. Adding a module's again does nothing.
250
+ #
251
+ # A name the DSL has already fails, and so does one that every object
252
+ # has (Kernel's +test+, say), which it would hide. What a method
253
+ # defines (with #duel or #duel_process, or a hook: #setup, say) is
254
+ # filed under the file of benchmarks that called it, not the
255
+ # plugin's, so it gets that file's hooks, as a benchmark defined
256
+ # there with #duel does.
257
+ # @api private
258
+ def self.add(methods) = methods.public_instance_methods(false).each { |name| define(name, methods) }
259
+
260
+ # Adds +methods+' method +name+ to the DSL (see .add), unless it's
261
+ # there already.
262
+ # @api private
263
+ def self.define(name, methods)
264
+ return if DEFINED[name].equal?(methods)
265
+
266
+ taken = [self, Object].find { |owner| owner.method_defined?(name) || owner.private_method_defined?(name) }
267
+ raise Error, "#{methods}##{name}: the DSL has #{name} already (#{Duel.where((DEFINED[name] || taken).instance_method(name))})" if taken
268
+
269
+ DEFINED[name] = methods
270
+ define_method(name) do |*args, **options, &block|
271
+ # @type var options: Hash[Symbol, untyped]
272
+ # @type var block: (^(*untyped, **untyped) -> untyped)?
273
+ # @type self: Object
274
+ # (Where it's called from: the one frame there always is.)
275
+ # @type var from: Thread::Backtrace::Location
276
+ caller_locations(1, 1) => [from]
277
+ # (The plugin's method as it is now, looked up each call.)
278
+ Duel.defining(from.path) { methods.instance_method(name).bind_call(self, *args, **options, &block) }
279
+ end
280
+ end
281
+
282
+ # The plugin's DSL module each method DSL.add added is from (for
283
+ # where it's defined).
284
+ # @api private
285
+ DEFINED = {} #: Hash[Symbol, Module]
286
+
287
+ # Defines a benchmark. Its block is timed, run as many times as a
288
+ # sample needs. With a parameter, it's run once a sample and given
289
+ # how many iterations to do:
290
+ #
291
+ # duel "parse: a document" do
292
+ # once { @input = File.read("doc.json") } # not timed: once, first
293
+ # MyLib.parse(@input) # timed
294
+ # end
295
+ #
296
+ # duel "queue: drain the queue" do |n|
297
+ # queue = untimed { MyQueue.new(Array.new(n)) } # not timed: every sample
298
+ # queue.drain
299
+ # end
300
+ #
301
+ # Options: only: what it's judged on, if not both its timings and its
302
+ # counts: :count for work too slow and light to time (waiting, say),
303
+ # run a few iterations at a time, or :timing for work done by
304
+ # threads of its own, which make its counts vary (and whose CPU time
305
+ # is the process's, not the benchmark thread's). peak_memory: true to
306
+ # judge its peak memory too. And its own hooks (see HOOKS), lambdas
307
+ # run in its context, after its configuration's and its file's: an
308
+ # around_iteration one takes the iteration (an Iteration) and runs
309
+ # it, and only the iteration is measured, so what it does before and
310
+ # after (rolling back what the iteration wrote, say) isn't.
311
+ #
312
+ # duel "posts: create", around_iteration: ->(iteration) { rolled_back { iteration.call } } do
313
+ # Post.create!(title: "Hi")
314
+ # end
315
+ def duel(name, only: nil, peak_memory: false, **hooks, &body)
316
+ raise ArgumentError, "duplicate benchmark #{name.inspect}" if Duel.benchmarks.key?(name)
317
+ raise ArgumentError, "only: must be :count or :timing, not #{only.inspect}" unless ONLY.include?(only)
318
+
319
+ Duel.benchmarks[name] = Definition.new(name, only, peak_memory, Duel.defining_file || body.source_location.first, body,
320
+ DSL.hooks(name, hooks, body))
321
+ end
322
+
323
+ # A benchmark's own hooks (+hooks+, by name), each one a hook there
324
+ # is (see Duel.hook): an around_iteration one runs around each
325
+ # iteration, so +body+ (the benchmark's block) can't take them.
326
+ # @api private
327
+ def self.hooks(name, hooks, body)
328
+ unknown = hooks.keys - HOOKS
329
+ raise ArgumentError, "duel #{name.inspect}: no option #{unknown.join(", ")} (only, peak_memory, #{HOOKS.join(", ")})" unless unknown.empty?
330
+
331
+ hooks.each { |hook, block| Duel.hook(hook, block) }
332
+ if hooks.key?(:around_iteration) && body.arity == 1
333
+ raise ArgumentError, "duel #{name.inspect}: around_iteration runs around each iteration: give the benchmark's block no parameter"
334
+ end
335
+
336
+ hooks
337
+ end
338
+
339
+ # Defines a benchmark of a whole process, booting an app or requiring
340
+ # a library, say: each of its samples is a fresh process for each
341
+ # version, in turn, started in the version's tree with its
342
+ # environment (and on its Ruby: see Config#ruby), and it's judged on
343
+ # the process's CPU time (its children's too) and, where Duel can tell
344
+ # it, its peak memory.
345
+ #
346
+ # duel_process "boot: the app", "bin/rails", "runner", "1"
347
+ # duel_process "boot: require the library", ruby: %(require "my_gem")
348
+ #
349
+ # A +command+ is run as Process.spawn takes it (one String with
350
+ # spaces is a shell command). Code given as +ruby+ is run by the
351
+ # version's Ruby, with the version's lib/ first on the load path. A
352
+ # process that fails (a status other than 0) is an error. Each sample
353
+ # is the fastest of +runs+ processes: by default, as many as take about
354
+ # a second of CPU time, up to 3.
355
+ #
356
+ # It isn't run in a worker, so what runs there doesn't apply to it:
357
+ # hooks (the configuration's, and its file's), and plugins' metrics.
358
+ # Nor do #duel's options: given one (around_iteration:, say), it
359
+ # fails.
360
+ def duel_process(name, *command, ruby: nil, runs: nil, **options)
361
+ raise ArgumentError, "duplicate benchmark #{name.inspect}" if Duel.benchmarks.key?(name)
362
+ raise ArgumentError, "duel_process #{name.inspect}: give a command, or ruby: code, not both" if command.empty? == ruby.nil?
363
+ counted = runs.nil? || (runs.instance_of?(Integer) && runs.positive?)
364
+ raise ArgumentError, "duel_process #{name.inspect}: runs: is a number of processes a sample, 1 or more, not #{runs.inspect}" unless counted
365
+ unless options.empty?
366
+ raise ArgumentError, "duel_process #{name.inspect}: #{options.keys.join(", ")} #{(options.size == 1) ? "is" : "are"} duel's, " \
367
+ "for a benchmark run in a worker: a process benchmark runs no hooks, and is judged on its CPU time and peak memory alone"
368
+ end
369
+
370
+ process = {"command" => (command unless command.empty?), "ruby" => ruby, "runs" => runs} #: Definition::process
371
+ Duel.benchmarks[name] = Definition.new(name, nil, false, Duel.defining_file || caller_locations.fetch(0).path, nil, {}, process)
372
+ end
373
+
374
+ # En garde.
375
+ # @api private
376
+ alias_method :⚔️, :duel
377
+ # @api private
378
+ alias_method :⚔, :duel
379
+
380
+ # The file's hooks (see HOOKS), for every benchmark in it: run in each
381
+ # benchmark's context, after the configuration's and before the
382
+ # benchmark's own. setup runs once in each worker before a
383
+ # benchmark's samples, and teardown after them (last first).
384
+ # before_sample runs before every sample (the warm-up's too), not
385
+ # measured: to put back what earlier samples changed (data they
386
+ # wrote, say), so that every sample starts alike. The first three
387
+ # are given the benchmark, if they take it.
388
+ def setup(&block) = Duel.file_hook(:setup, block, DSL.caller_file)
389
+
390
+ def teardown(&block) = Duel.file_hook(:teardown, block, DSL.caller_file)
391
+
392
+ def before_sample(&block) = Duel.file_hook(:before_sample, block, DSL.caller_file)
393
+
394
+ # Runs the block around each iteration of every benchmark in the
395
+ # file, given the iteration (an Iteration) to run, once: only the
396
+ # iteration is measured.
397
+ def around_iteration(&block) = Duel.file_hook(:around_iteration, block, DSL.caller_file)
398
+
399
+ # Runs the block after each sample's iterations of every benchmark in
400
+ # the file (inside each iteration, with around_iteration), measured
401
+ # with them: to do the work they left to be done later.
402
+ def after_iterations(&block) = Duel.file_hook(:after_iterations, block, DSL.caller_file)
403
+
404
+ # The file that called the DSL method calling this (the one frame
405
+ # there always is, past it).
406
+ # @api private
407
+ def self.caller_file
408
+ # @type var from: Thread::Backtrace::Location
409
+ caller_locations(2, 1) => [from]
410
+ from.path
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,21 @@
1
+ module Duel
2
+ # Whether the candidate is slower across the board.
3
+ # @api private
4
+ class Across
5
+ # A timed metric's verdict across the board ("confirmed": slower in a
6
+ # second measurement too, see Runner#across).
7
+ type verdict = { "median" => Float, "interval" => Array[Float]?, "limit" => Float, "benchmarks" => Integer,
8
+ "verdict" => String, ?"confirmed" => bool }
9
+
10
+ @results: Array[Result]
11
+ @confidence: Float
12
+
13
+ # The across-the-board limit for a metric whose own limit is +limit+.
14
+ def self.limit: (Float limit) -> Float
15
+
16
+ def initialize: (Array[Result] results, confidence: Float) -> void
17
+
18
+ # Every timed metric any benchmark was judged on, by name.
19
+ def verdicts: () -> Hash[String, verdict]
20
+ end
21
+ end
@@ -0,0 +1,61 @@
1
+ module Duel
2
+ # The version to compare against: a directory, or a git commit checked
3
+ # out (and built) where #checkout says.
4
+ # @api private
5
+ class Baseline
6
+ LAST_RELEASE: String
7
+ BRANCHES: Array[String]
8
+
9
+ @config: Config
10
+ @settings: Settings
11
+ @ref: String?
12
+ @prefix: String?
13
+
14
+ def initialize: (Config config, Settings settings) -> void
15
+
16
+ # The commit, or nil for "last-release" with no release yet.
17
+ def ref: () -> String?
18
+
19
+ # Whether nothing Config#watch names changed since the baseline.
20
+ def unchanged?: () -> bool
21
+
22
+ # The baseline's tree, checked out and built if it's a commit.
23
+ def root: () -> String
24
+
25
+ # Where the project is in a checkout of its repository at +dir+.
26
+ def within: (String dir) -> String
27
+
28
+ # The project's path from its repository's top level ("" at the top).
29
+ def prefix: () -> String
30
+
31
+ # Where a git baseline is checked out: next to the repository, under a
32
+ # hidden name as long as its own, or else tmp/duel/base.
33
+ def checkout: () -> String
34
+
35
+ # Removes what Duel keeps: tmp/duel, and the baseline's checkout.
36
+ def clean: () -> void
37
+
38
+ # The main branch.
39
+ def main_branch: () -> String
40
+
41
+ # Builds the tree in +dir+ (Config#build), if there's a build.
42
+ def build: (String dir, String what) -> void
43
+
44
+ private
45
+
46
+ # Where HEAD left the main branch: their merge base, or a failure that
47
+ # says why there's none.
48
+ def merge_base: () -> String
49
+
50
+ # Whether +dir+ is a checkout of this repository.
51
+ def ours?: (String dir) -> bool
52
+
53
+ # The repository a directory is a checkout of, or nil for none.
54
+ def common_dir: (String dir) -> String?
55
+
56
+ # Git's output. It raises Error when it fails, unless +allow_failure+
57
+ # (then nil).
58
+ def git: (*String args) -> String
59
+ | (*String args, allow_failure: bool) -> String?
60
+ end
61
+ end
data/sig/duel/cli.rbs ADDED
@@ -0,0 +1,95 @@
1
+ module Duel
2
+ # The `duel` command.
3
+ # @api private
4
+ class CLI
5
+ # The environment (ENV, or a Hash in its place).
6
+ interface _Env
7
+ def reject: () { (String name, String value) -> boolish } -> _Variables
8
+ end
9
+
10
+ # What's left of it.
11
+ interface _Variables
12
+ def key?: (String name) -> bool
13
+
14
+ def fetch: (String name) -> String
15
+ end
16
+
17
+ USAGE: String
18
+
19
+ # Where GitHub Actions has a step add to its job's summary.
20
+ STEP_SUMMARY: String
21
+
22
+ # The variables that set options, to their options.
23
+ ENV_OPTIONS: Hash[String, String]
24
+
25
+ # The variables that set a flag by being set at all.
26
+ ENV_FLAGS: Hash[String, String]
27
+
28
+ @argv: Array[String]
29
+ @env: _Env
30
+ @out: _Output
31
+ @err: _Output
32
+ @clean: bool?
33
+ @markdown: String?
34
+ @json: String?
35
+ @settings: Settings?
36
+
37
+ def initialize: (Array[String] argv, ?env: _Env, ?out: _Output, ?err: _Output) -> void
38
+
39
+ # Runs, and returns the exit status: 0 not slower (or nothing to compare),
40
+ # 1 slower (or a benchmark failed), 2 couldn't compare.
41
+ def run: () -> Integer
42
+
43
+ # The settings, from the environment and then the options.
44
+ # +source+ as a Regexp, or an option's invalid argument.
45
+ def self.regexp: (String source) -> Regexp
46
+
47
+ def parse: () -> Settings
48
+
49
+ private
50
+
51
+ # What #run does, holding the project's lock.
52
+ def compare: (Config config, Settings settings) -> Integer
53
+
54
+ # Says there's nothing to compare, and why, and passes.
55
+ def nothing: (String why) -> Integer
56
+
57
+ # Reports a run that didn't compare, and returns +exit+.
58
+ def unfinished: (String title, String status, String message, Integer exit) -> Integer
59
+
60
+ # Writes the reports where they're asked for, each made only if it's
61
+ # written.
62
+ def publish: (^() -> String markdown, ^() -> JSONReport::report json) -> void
63
+
64
+ # Writes +text+ to +path+, in a directory made for it.
65
+ def write: (String path, String text) -> void
66
+
67
+ # The baseline, in Markdown.
68
+ def described_baseline: (Config config, Settings settings, Baseline baseline) -> String
69
+
70
+ # The candidate, in Markdown.
71
+ def described_candidate: (Config config, Settings settings) -> String
72
+
73
+ # The baseline, for JSON: the ref that named it, its commit, and its
74
+ # tree.
75
+ def baseline_json: (Config config, Settings settings, Baseline baseline, String root) -> Hash[String, String?]
76
+
77
+ # The candidate, for JSON: its commit, whether it has changes not
78
+ # committed, and its tree.
79
+ def candidate_json: (Config config, Settings settings) -> Hash[String, String | bool | nil]
80
+
81
+ # Git's output, and its status, in +dir+.
82
+ def git: (String dir, *String args) -> [String, Process::Status]
83
+
84
+ # Says so if the two versions' paths differ in length.
85
+ def uneven: (String base_root, String candidate) -> void
86
+
87
+ # The DUEL_* variables set, as options.
88
+ def env_options: () -> Array[String]
89
+
90
+ # The environment's variables that are set.
91
+ def variables: () -> _Variables
92
+
93
+ def parser: (Settings settings) -> OptionParser
94
+ end
95
+ end