kaisoku 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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +133 -0
  4. data/Rakefile +8 -0
  5. data/exe/kaisoku +6 -0
  6. data/lib/kaisoku/adapter.rb +81 -0
  7. data/lib/kaisoku/adapters/cucumber.rb +91 -0
  8. data/lib/kaisoku/adapters/minitest.rb +96 -0
  9. data/lib/kaisoku/adapters/rspec.rb +111 -0
  10. data/lib/kaisoku/adapters/test_unit.rb +35 -0
  11. data/lib/kaisoku/checksum.rb +55 -0
  12. data/lib/kaisoku/cli.rb +158 -0
  13. data/lib/kaisoku/commands/daemon_command.rb +31 -0
  14. data/lib/kaisoku/commands/map_command.rb +86 -0
  15. data/lib/kaisoku/commands/preload_command.rb +40 -0
  16. data/lib/kaisoku/commands/run_command.rb +93 -0
  17. data/lib/kaisoku/commands/watch_command.rb +37 -0
  18. data/lib/kaisoku/configuration.rb +69 -0
  19. data/lib/kaisoku/coverage_collector.rb +83 -0
  20. data/lib/kaisoku/daemon/client.rb +22 -0
  21. data/lib/kaisoku/daemon/server.rb +39 -0
  22. data/lib/kaisoku/daemon/socket_path.rb +13 -0
  23. data/lib/kaisoku/doctor.rb +46 -0
  24. data/lib/kaisoku/entity.rb +77 -0
  25. data/lib/kaisoku/entity_result.rb +19 -0
  26. data/lib/kaisoku/environment_tracker.rb +28 -0
  27. data/lib/kaisoku/evaluator/cli.rb +112 -0
  28. data/lib/kaisoku/evaluator/command_result.rb +64 -0
  29. data/lib/kaisoku/evaluator/history.rb +23 -0
  30. data/lib/kaisoku/evaluator/metrics.rb +45 -0
  31. data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
  32. data/lib/kaisoku/evaluator/replay.rb +121 -0
  33. data/lib/kaisoku/evaluator/report.rb +128 -0
  34. data/lib/kaisoku/hybrid_policy.rb +14 -0
  35. data/lib/kaisoku/listen_watcher.rb +34 -0
  36. data/lib/kaisoku/lsp/message_io.rb +42 -0
  37. data/lib/kaisoku/lsp/server.rb +100 -0
  38. data/lib/kaisoku/method_map.rb +75 -0
  39. data/lib/kaisoku/preload/client.rb +22 -0
  40. data/lib/kaisoku/preload/daemon.rb +48 -0
  41. data/lib/kaisoku/preload/fallback_runner.rb +45 -0
  42. data/lib/kaisoku/preload/server.rb +123 -0
  43. data/lib/kaisoku/preload/socket_path.rb +13 -0
  44. data/lib/kaisoku/prioritizer.rb +18 -0
  45. data/lib/kaisoku/rails/integration.rb +56 -0
  46. data/lib/kaisoku/reporters/console.rb +24 -0
  47. data/lib/kaisoku/reporters/factory.rb +24 -0
  48. data/lib/kaisoku/reporters/json.rb +28 -0
  49. data/lib/kaisoku/reporters/junit.rb +50 -0
  50. data/lib/kaisoku/reporters/tui.rb +29 -0
  51. data/lib/kaisoku/runner.rb +48 -0
  52. data/lib/kaisoku/runtime_context.rb +28 -0
  53. data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
  54. data/lib/kaisoku/scheduler/result_persister.rb +43 -0
  55. data/lib/kaisoku/selection.rb +51 -0
  56. data/lib/kaisoku/selector.rb +173 -0
  57. data/lib/kaisoku/snapshot/inline.rb +51 -0
  58. data/lib/kaisoku/snapshot/rspec.rb +22 -0
  59. data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
  60. data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
  61. data/lib/kaisoku/test_map/health.rb +24 -0
  62. data/lib/kaisoku/test_map/hot_files.rb +32 -0
  63. data/lib/kaisoku/test_map/schema.rb +81 -0
  64. data/lib/kaisoku/test_map/snapshot.rb +53 -0
  65. data/lib/kaisoku/test_map/stats.rb +19 -0
  66. data/lib/kaisoku/test_map.rb +170 -0
  67. data/lib/kaisoku/version.rb +5 -0
  68. data/lib/kaisoku/watch_session.rb +35 -0
  69. data/lib/kaisoku/watcher.rb +63 -0
  70. data/lib/kaisoku/worker.rb +46 -0
  71. data/lib/kaisoku.rb +67 -0
  72. metadata +186 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ class Doctor
5
+ def initialize(test_map:, preload_server: nil)
6
+ @test_map = test_map
7
+ @preload_server = preload_server
8
+ end
9
+
10
+ def report
11
+ stats = @test_map.stats
12
+
13
+ [
14
+ 'Kaisoku doctor',
15
+ "map: #{@test_map.path}",
16
+ "schema_version: #{stats[:schema_version]}",
17
+ "ruby_version: #{stats[:ruby_version]}",
18
+ "adapter: #{stats[:adapter]}",
19
+ "entities: #{stats[:entities]}",
20
+ "dependencies: #{stats[:dependencies]}",
21
+ preload_line,
22
+ warnings_line
23
+ ].join("\n")
24
+ end
25
+
26
+ private
27
+
28
+ def warnings_line
29
+ warnings = @test_map.health_warnings
30
+ return 'warnings: none' if warnings.empty?
31
+
32
+ "warnings: #{warnings.join(', ')}"
33
+ end
34
+
35
+ def preload_line
36
+ return 'preload: unavailable' unless @preload_server
37
+
38
+ status = @preload_server.status
39
+ line = "preload: generation=#{status[:generation]}, loaded_files=#{status[:loaded_files]}, " \
40
+ "last_restart=#{status[:last_restart_reason]}"
41
+ return line if status[:healthy]
42
+
43
+ "#{line}, disabled=#{status[:disabled_reason]}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ Entity = Struct.new(
5
+ :adapter,
6
+ :identifier,
7
+ :file,
8
+ :line,
9
+ :avg_duration_ms,
10
+ :last_status,
11
+ :last_failed_at,
12
+ :failure_count,
13
+ keyword_init: true
14
+ ) do
15
+ def initialize(
16
+ adapter:,
17
+ identifier:,
18
+ file:,
19
+ line: nil,
20
+ avg_duration_ms: nil,
21
+ last_status: nil,
22
+ last_failed_at: nil,
23
+ failure_count: 0
24
+ )
25
+ super(
26
+ adapter: adapter.to_s,
27
+ identifier: identifier.to_s,
28
+ file: normalize_file(file),
29
+ line: line,
30
+ avg_duration_ms: avg_duration_ms,
31
+ last_status: last_status,
32
+ last_failed_at: last_failed_at,
33
+ failure_count: failure_count.to_i
34
+ )
35
+ end
36
+
37
+ def id
38
+ "#{adapter}:#{identifier}"
39
+ end
40
+
41
+ def failed?
42
+ last_status == 'failed'
43
+ end
44
+
45
+ def with_runtime(
46
+ avg_duration_ms: self.avg_duration_ms,
47
+ last_status: self.last_status,
48
+ last_failed_at: self.last_failed_at,
49
+ failure_count: self.failure_count
50
+ )
51
+ self.class.new(
52
+ adapter: adapter,
53
+ identifier: identifier,
54
+ file: file,
55
+ line: line,
56
+ avg_duration_ms: avg_duration_ms,
57
+ last_status: last_status,
58
+ last_failed_at: last_failed_at,
59
+ failure_count: failure_count
60
+ )
61
+ end
62
+
63
+ def eql?(other)
64
+ other.is_a?(Entity) && other.id == id
65
+ end
66
+
67
+ def hash
68
+ id.hash
69
+ end
70
+
71
+ private
72
+
73
+ def normalize_file(value)
74
+ value.to_s.sub(%r{\A\./}, '')
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ EntityResult = Struct.new(:entity, :payload, :dependencies, :collection_skipped, :error, keyword_init: true) do
5
+ def passed?
6
+ payload[:status] == 'passed'
7
+ end
8
+
9
+ def failed?
10
+ payload[:status] == 'failed' || error
11
+ end
12
+
13
+ def status
14
+ return 'error' if error
15
+
16
+ payload[:status]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Kaisoku
6
+ class EnvironmentTracker
7
+ DEPENDENCY_KEY = '__ENV__'
8
+
9
+ def initialize(env: ENV)
10
+ @env = env
11
+ @before = fingerprint
12
+ end
13
+
14
+ def changed?
15
+ @before != fingerprint
16
+ end
17
+
18
+ def dependency
19
+ [DEPENDENCY_KEY, fingerprint]
20
+ end
21
+
22
+ private
23
+
24
+ def fingerprint
25
+ Digest::SHA256.hexdigest(@env.to_h.sort.map { |key, value| "#{key}=#{value}" }.join("\0"))
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'optparse'
6
+
7
+ module Kaisoku
8
+ module Evaluator
9
+ class CLI
10
+ def initialize(argv)
11
+ @argv = argv
12
+ end
13
+
14
+ def call
15
+ options = parse_options
16
+ return replay(options) if options[:commits]
17
+ return mutations(options) if options[:seed_mutations]
18
+ return usage_error unless options[:total] && options[:selected]
19
+
20
+ payload = ratio_payload(options)
21
+ write_json_report(json_report_path(options), payload) if json_report_path(options)
22
+ append_history(history_path(options), payload) if history_path(options)
23
+ puts "Selection ratio: #{format('%.2f', selection_ratio(options) * 100)}%"
24
+ puts "Reduction ratio: #{format('%.2f', reduction_ratio(options) * 100)}%"
25
+ 0
26
+ end
27
+
28
+ private
29
+
30
+ def parse_options
31
+ options = {}
32
+ parser = OptionParser.new do |opts|
33
+ opts.on('--total N', Integer) { |value| options[:total] = value }
34
+ opts.on('--selected N', Integer) { |value| options[:selected] = value }
35
+ opts.on('--commits N', Integer) { |value| options[:commits] = value }
36
+ opts.on('--branch NAME') { |value| options[:branch] = value }
37
+ opts.on('--full-command COMMAND') { |value| options[:full_command] = value }
38
+ opts.on('--selected-command COMMAND') { |value| options[:selected_command] = value }
39
+ opts.on('--seed-mutations N', Integer) { |value| options[:seed_mutations] = value }
40
+ opts.on('--mutation-command COMMAND') { |value| options[:mutation_command] = value }
41
+ opts.on('--mutation-path PATH') { |value| (options[:mutation_paths] ||= []) << value }
42
+ opts.on('--json-report PATH') { |value| options[:json_report] = value }
43
+ opts.on('--history PATH') { |value| options[:history] = value }
44
+ opts.on('--ci') { options[:ci] = true }
45
+ end
46
+ parser.parse!(@argv)
47
+ options
48
+ end
49
+
50
+ def usage_error
51
+ warn 'usage: kaisoku eval --total N --selected N OR kaisoku eval --commits N'
52
+ 1
53
+ end
54
+
55
+ def replay(options)
56
+ replay = Replay.new(
57
+ commits: options[:commits],
58
+ branch: options[:branch] || 'HEAD',
59
+ full_command: options[:full_command] || 'ruby -Ilib exe/kaisoku run --all --reporter json',
60
+ selected_command: options[:selected_command] || 'ruby -Ilib exe/kaisoku run --reporter json'
61
+ )
62
+ report = Report.new(replay.run)
63
+ payload = report.to_h
64
+ write_json_report(json_report_path(options), payload) if json_report_path(options)
65
+ append_history(history_path(options), payload) if history_path(options)
66
+ puts report.render
67
+ report.safe? ? 0 : 1
68
+ end
69
+
70
+ def mutations(options)
71
+ command = options[:mutation_command] || 'ruby -Ilib exe/kaisoku run --all'
72
+ paths = options[:mutation_paths] || ['lib']
73
+ puts MutationSeeder.new(paths: paths, command: command, limit: options[:seed_mutations]).report
74
+ 0
75
+ end
76
+
77
+ def selection_ratio(options)
78
+ Metrics.selection_ratio(selected_count: options[:selected], total_count: options[:total])
79
+ end
80
+
81
+ def reduction_ratio(options)
82
+ Metrics.reduction_ratio(selected_count: options[:selected], total_count: options[:total])
83
+ end
84
+
85
+ def ratio_payload(options)
86
+ {
87
+ total_count: options[:total],
88
+ selected_count: options[:selected],
89
+ selection_ratio: selection_ratio(options),
90
+ reduction_ratio: reduction_ratio(options)
91
+ }
92
+ end
93
+
94
+ def write_json_report(path, payload)
95
+ FileUtils.mkdir_p(File.dirname(path))
96
+ File.write(path, JSON.pretty_generate(payload))
97
+ end
98
+
99
+ def append_history(path, payload)
100
+ History.new.append(path, payload)
101
+ end
102
+
103
+ def json_report_path(options)
104
+ options[:json_report] || ('.kaisoku/eval-report.json' if options[:ci])
105
+ end
106
+
107
+ def history_path(options)
108
+ options[:history] || ('.kaisoku/eval-history.jsonl' if options[:ci])
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Kaisoku
6
+ module Evaluator
7
+ class CommandResult
8
+ attr_reader :status, :time, :output
9
+
10
+ def initialize(status:, time:, output:)
11
+ @status = status
12
+ @time = time
13
+ @output = output
14
+ end
15
+
16
+ def failed_ids
17
+ results.select { |_id, payload| failed_payload?(payload) }.keys
18
+ end
19
+
20
+ def executed_ids
21
+ results.keys
22
+ end
23
+
24
+ def selected_count
25
+ selection['selected']
26
+ end
27
+
28
+ def total_count
29
+ selection['total']
30
+ end
31
+
32
+ def selection
33
+ parsed.fetch('selection', {})
34
+ end
35
+
36
+ def fallback?
37
+ selection['fallback'] || output.include?('fallback')
38
+ end
39
+
40
+ private
41
+
42
+ def results
43
+ parsed.fetch('results', {})
44
+ end
45
+
46
+ def parsed
47
+ @parsed ||= JSON.parse(json_payload)
48
+ rescue JSON::ParserError
49
+ { 'selection' => {}, 'results' => {} }
50
+ end
51
+
52
+ def json_payload
53
+ start = output.rindex("{\n \"selection\"")
54
+ return output unless start
55
+
56
+ output[start..]
57
+ end
58
+
59
+ def failed_payload?(payload)
60
+ payload['status'] == 'failed' || payload[:status] == 'failed' || payload['error'] || payload[:error]
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module Kaisoku
7
+ module Evaluator
8
+ class History
9
+ def append(path, payload)
10
+ FileUtils.mkdir_p(File.dirname(path))
11
+ File.open(path, 'a') do |file|
12
+ file.puts(JSON.generate(record(payload)))
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def record(payload)
19
+ payload.merge(recorded_at: Time.now.to_i)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kaisoku
4
+ module Evaluator
5
+ module Metrics
6
+ module_function
7
+
8
+ def selection_ratio(selected_count:, total_count:)
9
+ return 0.0 if total_count.to_i.zero?
10
+
11
+ selected_count.to_f / total_count
12
+ end
13
+
14
+ def reduction_ratio(selected_count:, total_count:)
15
+ 1.0 - selection_ratio(selected_count: selected_count, total_count: total_count)
16
+ end
17
+
18
+ def safety_violations(full_failures:, selected_ids:)
19
+ full_failures.map(&:to_s).uniq - selected_ids.map(&:to_s).uniq
20
+ end
21
+
22
+ def precision(selected_ids:, relevant_ids:)
23
+ selected = selected_ids.map(&:to_s).uniq
24
+ return 1.0 if selected.empty?
25
+
26
+ relevant = relevant_ids.map(&:to_s).uniq
27
+ (selected & relevant).length.to_f / selected.length
28
+ end
29
+
30
+ def apfd(order:, fault_detectors:)
31
+ ordered_ids = order.map { |entity| entity.respond_to?(:id) ? entity.id : entity.to_s }
32
+ fault_positions = fault_detectors.values.filter_map do |detectors|
33
+ Array(detectors).filter_map { |id| ordered_ids.index(id.to_s) }.min&.+(1)
34
+ end
35
+
36
+ return 1.0 if ordered_ids.empty? || fault_positions.empty?
37
+
38
+ n = ordered_ids.length.to_f
39
+ m = fault_positions.length.to_f
40
+
41
+ 1.0 - (fault_positions.sum / (n * m)) + (1.0 / (2.0 * n))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Kaisoku
6
+ module Evaluator
7
+ class MutationSeeder
8
+ Mutation = Struct.new(:file, :original, :replacement, :index, keyword_init: true)
9
+
10
+ PATTERNS = {
11
+ 'true' => 'false',
12
+ 'false' => 'true',
13
+ 'nil' => 'false'
14
+ }.freeze
15
+
16
+ def initialize(paths:, command:, limit: 10)
17
+ @paths = Array(paths)
18
+ @command = command
19
+ @limit = limit.to_i
20
+ end
21
+
22
+ def run
23
+ mutations.first(@limit).map { |mutation| evaluate(mutation) }
24
+ end
25
+
26
+ def report
27
+ results = run
28
+ killed = results.count { |result| result[:killed] }
29
+ "Mutation score: #{killed}/#{results.length}"
30
+ end
31
+
32
+ def mutations
33
+ ruby_files.flat_map { |file| mutations_for_file(file) }
34
+ end
35
+
36
+ def mutations_for_source(file, source)
37
+ PATTERNS.flat_map do |original, replacement|
38
+ source.enum_for(:scan, /\b#{Regexp.escape(original)}\b/).map do
39
+ Mutation.new(file: file, original: original, replacement: replacement, index: Regexp.last_match.begin(0))
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def ruby_files
47
+ @paths.flat_map do |path|
48
+ if File.directory?(path)
49
+ Dir.glob(File.join(path, '**', '*.rb'))
50
+ elsif File.file?(path) && path.end_with?('.rb')
51
+ [path]
52
+ else
53
+ []
54
+ end
55
+ end.uniq.sort
56
+ end
57
+
58
+ def mutations_for_file(file)
59
+ mutations_for_source(file, File.read(file))
60
+ end
61
+
62
+ def evaluate(mutation)
63
+ source = File.read(mutation.file)
64
+ File.write(mutation.file, mutated_source(source, mutation))
65
+ _output, status = Open3.capture2e(@command)
66
+ { mutation: mutation, killed: !status.success? }
67
+ ensure
68
+ File.write(mutation.file, source) if source
69
+ end
70
+
71
+ def mutated_source(source, mutation)
72
+ source.dup.tap do |copy|
73
+ copy[mutation.index, mutation.original.length] = mutation.replacement
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'shellwords'
6
+ require 'tmpdir'
7
+
8
+ module Kaisoku
9
+ module Evaluator
10
+ class Replay
11
+ Result = Struct.new(
12
+ :commit,
13
+ :full_status,
14
+ :selected_status,
15
+ :full_time,
16
+ :selected_time,
17
+ :fallback,
18
+ :safety_violations,
19
+ :precision,
20
+ :apfd,
21
+ :changed_files,
22
+ :selected_count,
23
+ :total_count,
24
+ keyword_init: true
25
+ )
26
+
27
+ def initialize(
28
+ commits:,
29
+ branch: 'HEAD',
30
+ full_command: 'ruby -Ilib exe/kaisoku run --all --reporter json',
31
+ selected_command: 'ruby -Ilib exe/kaisoku run --reporter json'
32
+ )
33
+ @commits = commits.to_i
34
+ @branch = branch
35
+ @full_command = full_command
36
+ @selected_command = selected_command
37
+ end
38
+
39
+ def run
40
+ commit_list.map { |commit| evaluate_commit(commit) }
41
+ end
42
+
43
+ def report
44
+ Report.new(run).render
45
+ end
46
+
47
+ private
48
+
49
+ def commit_list
50
+ output, status = Open3.capture2('git', 'rev-list', '--max-count', @commits.to_s, '--reverse', @branch)
51
+ raise Error, 'git rev-list failed' unless status.success?
52
+
53
+ output.lines.map(&:strip).reject(&:empty?)
54
+ end
55
+
56
+ def evaluate_commit(commit)
57
+ Dir.mktmpdir('kaisoku-eval') do |dir|
58
+ worktree = File.join(dir, 'worktree')
59
+ add_worktree(worktree, commit)
60
+ begin
61
+ changed = changed_files(commit)
62
+ full = timed_run(@full_command, worktree)
63
+ selected = timed_run(selected_command_for(changed), worktree)
64
+ violations = Metrics.safety_violations(
65
+ full_failures: full.failed_ids,
66
+ selected_ids: selected.executed_ids
67
+ )
68
+ Result.new(
69
+ commit: commit,
70
+ full_status: full.status,
71
+ selected_status: selected.status,
72
+ full_time: full.time,
73
+ selected_time: selected.time,
74
+ fallback: selected.fallback?,
75
+ safety_violations: violations,
76
+ precision: Metrics.precision(selected_ids: selected.executed_ids, relevant_ids: full.failed_ids),
77
+ apfd: Metrics.apfd(order: selected.executed_ids, fault_detectors: fault_detectors(full.failed_ids)),
78
+ changed_files: changed,
79
+ selected_count: selected.selected_count,
80
+ total_count: selected.total_count || full.executed_ids.length
81
+ )
82
+ ensure
83
+ remove_worktree(worktree)
84
+ end
85
+ end
86
+ end
87
+
88
+ def selected_command_for(changed_files)
89
+ files = changed_files.map { |file| Shellwords.escape(file) }.join(' ')
90
+ [@selected_command, files].reject(&:empty?).join(' ')
91
+ end
92
+
93
+ def changed_files(commit)
94
+ output, status = Open3.capture2('git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit)
95
+ return [] unless status.success?
96
+
97
+ output.lines.map(&:strip).reject(&:empty?)
98
+ end
99
+
100
+ def add_worktree(path, commit)
101
+ _output, status = Open3.capture2e('git', 'worktree', 'add', '--detach', path, commit)
102
+ raise Error, 'git worktree add failed' unless status.success?
103
+ end
104
+
105
+ def remove_worktree(path)
106
+ Open3.capture2e('git', 'worktree', 'remove', '--force', path)
107
+ end
108
+
109
+ def timed_run(command, chdir)
110
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
111
+ output, status = Open3.capture2e(command, chdir: chdir)
112
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
113
+ CommandResult.new(output: output, status: status.exitstatus, time: elapsed)
114
+ end
115
+
116
+ def fault_detectors(failed_ids)
117
+ failed_ids.to_h { |id| [id, [id]] }
118
+ end
119
+ end
120
+ end
121
+ end