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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +8 -0
- data/exe/kaisoku +6 -0
- data/lib/kaisoku/adapter.rb +81 -0
- data/lib/kaisoku/adapters/cucumber.rb +91 -0
- data/lib/kaisoku/adapters/minitest.rb +96 -0
- data/lib/kaisoku/adapters/rspec.rb +111 -0
- data/lib/kaisoku/adapters/test_unit.rb +35 -0
- data/lib/kaisoku/checksum.rb +55 -0
- data/lib/kaisoku/cli.rb +158 -0
- data/lib/kaisoku/commands/daemon_command.rb +31 -0
- data/lib/kaisoku/commands/map_command.rb +86 -0
- data/lib/kaisoku/commands/preload_command.rb +40 -0
- data/lib/kaisoku/commands/run_command.rb +93 -0
- data/lib/kaisoku/commands/watch_command.rb +37 -0
- data/lib/kaisoku/configuration.rb +69 -0
- data/lib/kaisoku/coverage_collector.rb +83 -0
- data/lib/kaisoku/daemon/client.rb +22 -0
- data/lib/kaisoku/daemon/server.rb +39 -0
- data/lib/kaisoku/daemon/socket_path.rb +13 -0
- data/lib/kaisoku/doctor.rb +46 -0
- data/lib/kaisoku/entity.rb +77 -0
- data/lib/kaisoku/entity_result.rb +19 -0
- data/lib/kaisoku/environment_tracker.rb +28 -0
- data/lib/kaisoku/evaluator/cli.rb +112 -0
- data/lib/kaisoku/evaluator/command_result.rb +64 -0
- data/lib/kaisoku/evaluator/history.rb +23 -0
- data/lib/kaisoku/evaluator/metrics.rb +45 -0
- data/lib/kaisoku/evaluator/mutation_seeder.rb +78 -0
- data/lib/kaisoku/evaluator/replay.rb +121 -0
- data/lib/kaisoku/evaluator/report.rb +128 -0
- data/lib/kaisoku/hybrid_policy.rb +14 -0
- data/lib/kaisoku/listen_watcher.rb +34 -0
- data/lib/kaisoku/lsp/message_io.rb +42 -0
- data/lib/kaisoku/lsp/server.rb +100 -0
- data/lib/kaisoku/method_map.rb +75 -0
- data/lib/kaisoku/preload/client.rb +22 -0
- data/lib/kaisoku/preload/daemon.rb +48 -0
- data/lib/kaisoku/preload/fallback_runner.rb +45 -0
- data/lib/kaisoku/preload/server.rb +123 -0
- data/lib/kaisoku/preload/socket_path.rb +13 -0
- data/lib/kaisoku/prioritizer.rb +18 -0
- data/lib/kaisoku/rails/integration.rb +56 -0
- data/lib/kaisoku/reporters/console.rb +24 -0
- data/lib/kaisoku/reporters/factory.rb +24 -0
- data/lib/kaisoku/reporters/json.rb +28 -0
- data/lib/kaisoku/reporters/junit.rb +50 -0
- data/lib/kaisoku/reporters/tui.rb +29 -0
- data/lib/kaisoku/runner.rb +48 -0
- data/lib/kaisoku/runtime_context.rb +28 -0
- data/lib/kaisoku/scheduler/fork_pool.rb +105 -0
- data/lib/kaisoku/scheduler/result_persister.rb +43 -0
- data/lib/kaisoku/selection.rb +51 -0
- data/lib/kaisoku/selector.rb +173 -0
- data/lib/kaisoku/snapshot/inline.rb +51 -0
- data/lib/kaisoku/snapshot/rspec.rb +22 -0
- data/lib/kaisoku/static_analysis/analyzer.rb +120 -0
- data/lib/kaisoku/test_map/dependency_queries.rb +81 -0
- data/lib/kaisoku/test_map/health.rb +24 -0
- data/lib/kaisoku/test_map/hot_files.rb +32 -0
- data/lib/kaisoku/test_map/schema.rb +81 -0
- data/lib/kaisoku/test_map/snapshot.rb +53 -0
- data/lib/kaisoku/test_map/stats.rb +19 -0
- data/lib/kaisoku/test_map.rb +170 -0
- data/lib/kaisoku/version.rb +5 -0
- data/lib/kaisoku/watch_session.rb +35 -0
- data/lib/kaisoku/watcher.rb +63 -0
- data/lib/kaisoku/worker.rb +46 -0
- data/lib/kaisoku.rb +67 -0
- 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
|