sixth_sense 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 +125 -0
- data/exe/sixth_sense +7 -0
- data/lib/sixth_sense/adapters/minitest.rb +145 -0
- data/lib/sixth_sense/adapters/rspec.rb +373 -0
- data/lib/sixth_sense/adapters/test_unit.rb +142 -0
- data/lib/sixth_sense/analysis_context.rb +35 -0
- data/lib/sixth_sense/analysis_runner.rb +141 -0
- data/lib/sixth_sense/analyzer.rb +85 -0
- data/lib/sixth_sense/analyzers/adequacy_checked_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/adequacy_coverage.rb +63 -0
- data/lib/sixth_sense/analyzers/adequacy_mutation.rb +141 -0
- data/lib/sixth_sense/analyzers/quality_assertion_density.rb +41 -0
- data/lib/sixth_sense/analyzers/quality_flakiness.rb +53 -0
- data/lib/sixth_sense/analyzers/quality_test_smells.rb +253 -0
- data/lib/sixth_sense/analyzers/redundancy_clone.rb +54 -0
- data/lib/sixth_sense/analyzers/redundancy_coverage.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_mutation.rb +39 -0
- data/lib/sixth_sense/analyzers/redundancy_requirement.rb +70 -0
- data/lib/sixth_sense/changed_files.rb +45 -0
- data/lib/sixth_sense/cli.rb +229 -0
- data/lib/sixth_sense/config.rb +91 -0
- data/lib/sixth_sense/engines/mutant.rb +258 -0
- data/lib/sixth_sense/framework_adapter.rb +55 -0
- data/lib/sixth_sense/guardrail/baseline.rb +135 -0
- data/lib/sixth_sense/guardrail/evaluator.rb +69 -0
- data/lib/sixth_sense/model.rb +264 -0
- data/lib/sixth_sense/mutation_cache.rb +93 -0
- data/lib/sixth_sense/mutation_engine.rb +52 -0
- data/lib/sixth_sense/mutation_matrix_mutant_generator.rb +462 -0
- data/lib/sixth_sense/mutation_matrix_producer.rb +308 -0
- data/lib/sixth_sense/mutation_score_cache_writer.rb +75 -0
- data/lib/sixth_sense/rake_task.rb +16 -0
- data/lib/sixth_sense/reporters/console.rb +31 -0
- data/lib/sixth_sense/reporters/html.rb +62 -0
- data/lib/sixth_sense/reporters/json.rb +18 -0
- data/lib/sixth_sense/reporters/markdown.rb +34 -0
- data/lib/sixth_sense/reporters/sarif.rb +77 -0
- data/lib/sixth_sense/result.rb +86 -0
- data/lib/sixth_sense/runners/checked_coverage_estimator.rb +62 -0
- data/lib/sixth_sense/runners/checked_coverage_runner.rb +130 -0
- data/lib/sixth_sense/runners/checked_coverage_trace.rb +110 -0
- data/lib/sixth_sense/runners/coverage_runner.rb +220 -0
- data/lib/sixth_sense/runners/coverage_snapshot.rb +64 -0
- data/lib/sixth_sense/runners/minitest_checked_coverage_probe.rb +42 -0
- data/lib/sixth_sense/runners/minitest_coverage_probe.rb +49 -0
- data/lib/sixth_sense/runners/rspec_checked_coverage_probe.rb +26 -0
- data/lib/sixth_sense/runners/rspec_coverage_probe.rb +98 -0
- data/lib/sixth_sense/runners/test_unit_checked_coverage_probe.rb +43 -0
- data/lib/sixth_sense/runners/test_unit_coverage_probe.rb +51 -0
- data/lib/sixth_sense/scoring/aggregator.rb +117 -0
- data/lib/sixth_sense/source_location.rb +19 -0
- data/lib/sixth_sense/version.rb +5 -0
- data/lib/sixth_sense.rb +74 -0
- metadata +113 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "analysis_runner"
|
|
7
|
+
require_relative "changed_files"
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "engines/mutant"
|
|
10
|
+
require_relative "guardrail/baseline"
|
|
11
|
+
require_relative "guardrail/evaluator"
|
|
12
|
+
require_relative "mutation_cache"
|
|
13
|
+
require_relative "reporters/console"
|
|
14
|
+
require_relative "reporters/json"
|
|
15
|
+
require_relative "reporters/markdown"
|
|
16
|
+
require_relative "reporters/sarif"
|
|
17
|
+
require_relative "reporters/html"
|
|
18
|
+
|
|
19
|
+
module SixthSense
|
|
20
|
+
class CLI
|
|
21
|
+
def run(argv)
|
|
22
|
+
command = argv.shift || "analyze"
|
|
23
|
+
case command
|
|
24
|
+
when "analyze" then analyze(argv)
|
|
25
|
+
when "guard" then guard(argv)
|
|
26
|
+
when "baseline" then baseline(argv)
|
|
27
|
+
when "explain" then explain(argv)
|
|
28
|
+
when "calibrate" then calibrate(argv)
|
|
29
|
+
when "mutation-plan" then mutation_plan(argv)
|
|
30
|
+
when "mutation-run" then mutation_run(argv)
|
|
31
|
+
else
|
|
32
|
+
warn "Unknown command: #{command}"
|
|
33
|
+
2
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def analyze(argv)
|
|
40
|
+
options = parse_analysis_options(argv)
|
|
41
|
+
reports = run_analysis(argv, options)
|
|
42
|
+
write_report(reports, options)
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def guard(argv)
|
|
47
|
+
options = parse_analysis_options(argv)
|
|
48
|
+
options[:level] ||= Config.load(options[:config_path]).fetch(:guardrail, :require_level, default: 0).to_i
|
|
49
|
+
guard_config = Config.load(options[:config_path])
|
|
50
|
+
diff_ref = options[:diff] || (guard_config.fetch(:guardrail, :diff_only, default: false) && guard_config.fetch(:guardrail, :diff_ref, default: "origin/main"))
|
|
51
|
+
paths = diff_ref ? ChangedFiles.test_files(diff_ref) : argv
|
|
52
|
+
reports = run_analysis(paths, options)
|
|
53
|
+
write_report(reports, options)
|
|
54
|
+
|
|
55
|
+
config = Config.load(options[:config_path])
|
|
56
|
+
evaluation = Guardrail::Evaluator.new(config: config).evaluate(reports, level: options[:level])
|
|
57
|
+
print_guard_violations(evaluation)
|
|
58
|
+
evaluation.passed? ? 0 : 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def baseline(argv)
|
|
62
|
+
options = parse_analysis_options(argv)
|
|
63
|
+
reports = run_analysis(argv, options)
|
|
64
|
+
baseline = Guardrail::Baseline.load
|
|
65
|
+
baseline.update(reports, level: options[:level], force: options[:force])
|
|
66
|
+
baseline.write
|
|
67
|
+
puts "Updated #{Guardrail::Baseline::DEFAULT_PATH}"
|
|
68
|
+
0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def explain(argv)
|
|
72
|
+
analyzer_id = argv.shift
|
|
73
|
+
analyzer = SixthSense.analyzers.find { |item| item.analyzer_id == analyzer_id }
|
|
74
|
+
unless analyzer
|
|
75
|
+
warn "Unknown analyzer: #{analyzer_id}"
|
|
76
|
+
return 2
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
puts "#{analyzer.analyzer_id} axis=#{analyzer.axis} level=#{analyzer.level}"
|
|
80
|
+
analyzer.references.each do |reference|
|
|
81
|
+
doi = reference.doi ? " doi:#{reference.doi}" : ""
|
|
82
|
+
puts "- #{reference.authors} (#{reference.year}). #{reference.title}. #{reference.venue}.#{doi}"
|
|
83
|
+
end
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mutation_plan(argv)
|
|
88
|
+
options = parse_analysis_options(argv)
|
|
89
|
+
config = Config.load(options[:config_path])
|
|
90
|
+
apply_mutation_mode(config, options)
|
|
91
|
+
runner = AnalysisRunner.new(config: config)
|
|
92
|
+
test_files = runner.discover(argv).filter_map do |path|
|
|
93
|
+
adapter_class = FrameworkAdapter.for_path(path)
|
|
94
|
+
adapter_class&.new&.parse(path)
|
|
95
|
+
end
|
|
96
|
+
engine = Engines::Mutant.new(config: config.fetch(:mutation, default: {}))
|
|
97
|
+
available = engine.availability
|
|
98
|
+
warn "mutation engine unavailable: #{available.reason}" unless available.available?
|
|
99
|
+
plan = engine.plan(sut_units: test_files.flat_map(&:sut_candidates), test_files: test_files)
|
|
100
|
+
puts JSON.pretty_generate(
|
|
101
|
+
{
|
|
102
|
+
availability: { available: available.available?, reason: available.reason },
|
|
103
|
+
plan: plan.to_h
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
available.available? ? 0 : 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def mutation_run(argv)
|
|
110
|
+
options = parse_analysis_options(argv)
|
|
111
|
+
config = Config.load(options[:config_path])
|
|
112
|
+
apply_mutation_mode(config, options)
|
|
113
|
+
runner = AnalysisRunner.new(config: config)
|
|
114
|
+
test_files = runner.discover(argv).filter_map do |path|
|
|
115
|
+
adapter_class = FrameworkAdapter.for_path(path)
|
|
116
|
+
adapter_class&.new&.parse(path)
|
|
117
|
+
end
|
|
118
|
+
engine = Engines::Mutant.new(config: config.fetch(:mutation, default: {}))
|
|
119
|
+
plan = engine.plan(sut_units: test_files.flat_map(&:sut_candidates), test_files: test_files)
|
|
120
|
+
result = engine.run(plan, cache: config.fetch(:mutation, :cache_dir, default: MutationCache::DEFAULT_ROOT))
|
|
121
|
+
puts JSON.pretty_generate(result.to_h)
|
|
122
|
+
result.success? ? 0 : 1
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def calibrate(argv)
|
|
126
|
+
options = parse_analysis_options(argv)
|
|
127
|
+
level1 = run_analysis(argv, options.merge(level: 1, format: options[:format]))
|
|
128
|
+
level2 = run_analysis(argv, options.merge(level: 2, format: options[:format]))
|
|
129
|
+
pairs = level1.zip(level2).filter_map do |left, right|
|
|
130
|
+
coverage = left.axis_scores.find { |score| score.axis == :adequacy }
|
|
131
|
+
mutation = right.axis_scores.find { |score| score.axis == :adequacy }
|
|
132
|
+
[coverage.value, mutation.value] if coverage&.measured? && mutation&.measured?
|
|
133
|
+
end
|
|
134
|
+
pairs = pairs.first(options[:sample]) if options[:sample]
|
|
135
|
+
rho = spearman(pairs)
|
|
136
|
+
recommendation = rho.nil? || rho < 0.5 ? "require_level: 2" : "require_level: 1"
|
|
137
|
+
puts JSON.pretty_generate({ sample_size: pairs.length, spearman_rho: rho, recommendation: recommendation })
|
|
138
|
+
0
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parse_analysis_options(argv)
|
|
142
|
+
options = {
|
|
143
|
+
format: "console",
|
|
144
|
+
level: 0,
|
|
145
|
+
config_path: ".sixth_sense.yml"
|
|
146
|
+
}
|
|
147
|
+
parser = OptionParser.new do |opts|
|
|
148
|
+
opts.on("--level LEVEL", Integer) { |value| options[:level] = value }
|
|
149
|
+
opts.on("--format FORMAT") { |value| options[:format] = value }
|
|
150
|
+
opts.on("-o", "--output PATH") { |value| options[:output] = value }
|
|
151
|
+
opts.on("--config PATH") { |value| options[:config_path] = value }
|
|
152
|
+
opts.on("--diff REF") { |value| options[:diff] = value }
|
|
153
|
+
opts.on("--all") { options[:all] = true }
|
|
154
|
+
opts.on("--force") { options[:force] = true }
|
|
155
|
+
opts.on("--sample N", Integer) { |value| options[:sample] = value }
|
|
156
|
+
opts.on("--mutation-mode MODE") { |value| options[:mutation_mode] = value }
|
|
157
|
+
opts.on("--score-engine ENGINE") { |value| options[:score_engine] = value }
|
|
158
|
+
end
|
|
159
|
+
parser.parse!(argv)
|
|
160
|
+
options
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def run_analysis(paths, options)
|
|
164
|
+
config = Config.load(options[:config_path])
|
|
165
|
+
selected_paths = options[:all] ? [] : paths
|
|
166
|
+
AnalysisRunner.new(config: config).analyze(paths: selected_paths, level: options[:level])
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def apply_mutation_mode(config, options)
|
|
170
|
+
config.data["mutation"] ||= {}
|
|
171
|
+
config.data["mutation"]["mode"] = options[:mutation_mode] if options[:mutation_mode]
|
|
172
|
+
config.data["mutation"]["score_engine"] = options[:score_engine] if options[:score_engine]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_report(reports, options)
|
|
176
|
+
rendered = reporter_for(options[:format]).render(reports)
|
|
177
|
+
if options[:output]
|
|
178
|
+
File.write(options[:output], rendered)
|
|
179
|
+
else
|
|
180
|
+
puts rendered
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reporter_for(format)
|
|
185
|
+
case format
|
|
186
|
+
when "console" then Reporters::Console.new
|
|
187
|
+
when "json" then Reporters::Json.new
|
|
188
|
+
when "markdown" then Reporters::Markdown.new
|
|
189
|
+
when "sarif" then Reporters::Sarif.new
|
|
190
|
+
when "html" then Reporters::Html.new
|
|
191
|
+
else
|
|
192
|
+
raise Error, "unknown format: #{format}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def print_guard_violations(evaluation)
|
|
197
|
+
evaluation.violations.each do |violation|
|
|
198
|
+
warn "guard violation: #{violation}"
|
|
199
|
+
end
|
|
200
|
+
evaluation.baseline_violations.each do |violation|
|
|
201
|
+
warn "ratchet violation: #{violation}"
|
|
202
|
+
end
|
|
203
|
+
evaluation.warnings.each do |warning|
|
|
204
|
+
warn "guard warning: #{warning}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def spearman(pairs)
|
|
209
|
+
return nil if pairs.length < 2
|
|
210
|
+
|
|
211
|
+
left_ranks = ranks(pairs.map(&:first))
|
|
212
|
+
right_ranks = ranks(pairs.map(&:last))
|
|
213
|
+
mean_left = left_ranks.sum / left_ranks.length.to_f
|
|
214
|
+
mean_right = right_ranks.sum / right_ranks.length.to_f
|
|
215
|
+
numerator = left_ranks.zip(right_ranks).sum { |left, right| (left - mean_left) * (right - mean_right) }
|
|
216
|
+
denominator = Math.sqrt(left_ranks.sum { |left| (left - mean_left)**2 } * right_ranks.sum { |right| (right - mean_right)**2 })
|
|
217
|
+
return nil if denominator.zero?
|
|
218
|
+
|
|
219
|
+
(numerator / denominator).round(4)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def ranks(values)
|
|
223
|
+
sorted = values.each_with_index.sort_by(&:first)
|
|
224
|
+
ranks = Array.new(values.length)
|
|
225
|
+
sorted.each_with_index { |(_value, original_index), rank| ranks[original_index] = rank + 1 }
|
|
226
|
+
ranks
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SixthSense
|
|
6
|
+
class Config
|
|
7
|
+
DEFAULT = {
|
|
8
|
+
"guardrail" => {
|
|
9
|
+
"fail_under" => {},
|
|
10
|
+
"require_level" => 0,
|
|
11
|
+
"ratchet" => false,
|
|
12
|
+
"tolerance" => 1.0,
|
|
13
|
+
"diff_only" => false,
|
|
14
|
+
"diff_ref" => "origin/main"
|
|
15
|
+
},
|
|
16
|
+
"quality" => {
|
|
17
|
+
"assertion_roulette_threshold" => 3,
|
|
18
|
+
"assertion_density_min" => 0.05,
|
|
19
|
+
"smell_cap" => 5
|
|
20
|
+
},
|
|
21
|
+
"adequacy" => {
|
|
22
|
+
"coverage_transform" => "raw",
|
|
23
|
+
"coverage_gamma" => 0.5
|
|
24
|
+
},
|
|
25
|
+
"coverage" => {
|
|
26
|
+
"enabled" => true,
|
|
27
|
+
"load_path" => "lib",
|
|
28
|
+
"checked_mode" => "windowed",
|
|
29
|
+
"checked_window" => 20
|
|
30
|
+
},
|
|
31
|
+
"mutation" => {
|
|
32
|
+
"engine" => "mutant",
|
|
33
|
+
"mode" => "score",
|
|
34
|
+
"score_engine" => "auto",
|
|
35
|
+
"version_requirement" => ">= 0",
|
|
36
|
+
"timeout" => 10,
|
|
37
|
+
"timeout_policy" => "killed",
|
|
38
|
+
"jobs" => "auto",
|
|
39
|
+
"cache_dir" => ".sixth_sense_cache/kill_matrix",
|
|
40
|
+
"neutral_runs" => 2,
|
|
41
|
+
"max_mutants" => 500,
|
|
42
|
+
"equivalent_threshold" => 0.2,
|
|
43
|
+
"operators" => {
|
|
44
|
+
"exclude" => []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :data
|
|
50
|
+
|
|
51
|
+
def self.load(path = ".sixth_sense.yml")
|
|
52
|
+
file_data = File.file?(path) ? YAML.load_file(path) : {}
|
|
53
|
+
new(deep_merge(DEFAULT, file_data || {}))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.deep_merge(left, right)
|
|
57
|
+
merged = deep_dup(left)
|
|
58
|
+
right.each do |key, value|
|
|
59
|
+
merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
60
|
+
deep_merge(merged[key], value)
|
|
61
|
+
else
|
|
62
|
+
deep_dup(value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
merged
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.deep_dup(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash
|
|
71
|
+
value.each_with_object({}) { |(key, nested), copy| copy[key] = deep_dup(nested) }
|
|
72
|
+
when Array
|
|
73
|
+
value.map { |nested| deep_dup(nested) }
|
|
74
|
+
else
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def initialize(data = DEFAULT)
|
|
80
|
+
@data = data
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fetch(*keys, default: nil)
|
|
84
|
+
keys.reduce(data) do |current, key|
|
|
85
|
+
break default unless current.respond_to?(:fetch)
|
|
86
|
+
|
|
87
|
+
current.fetch(key.to_s) { current.fetch(key.to_sym, default) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
|
|
8
|
+
require_relative "../framework_adapter"
|
|
9
|
+
require_relative "../mutation_engine"
|
|
10
|
+
require_relative "../mutation_cache"
|
|
11
|
+
require_relative "../mutation_matrix_producer"
|
|
12
|
+
require_relative "../mutation_score_cache_writer"
|
|
13
|
+
|
|
14
|
+
module SixthSense
|
|
15
|
+
module Engines
|
|
16
|
+
class Mutant < MutationEngine
|
|
17
|
+
SUPPORTED_ENGINE = "ruby"
|
|
18
|
+
|
|
19
|
+
def initialize(config: {})
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def availability
|
|
24
|
+
return Availability.new(available: true, reason: "sixth_sense matrix producer is available") if matrix_mode?
|
|
25
|
+
return Availability.new(available: true, reason: "sixth_sense built-in score producer is available") if builtin_score_engine?
|
|
26
|
+
|
|
27
|
+
external = external_availability
|
|
28
|
+
return external if external.available? || external_score_engine?
|
|
29
|
+
|
|
30
|
+
Availability.new(available: true, reason: "sixth_sense built-in score producer fallback is available")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def external_availability
|
|
34
|
+
return Availability.new(available: false, reason: "mutant requires CRuby") unless RUBY_ENGINE == SUPPORTED_ENGINE
|
|
35
|
+
mutant_spec = gem_spec("mutant")
|
|
36
|
+
return Availability.new(available: false, reason: "mutant gem is not installed") unless mutant_spec
|
|
37
|
+
return Availability.new(available: false, reason: "mutant-rspec gem is not installed") unless gem_installed?("mutant-rspec")
|
|
38
|
+
unless supported_mutant_version?(mutant_spec.version)
|
|
39
|
+
return Availability.new(available: false, reason: "mutant #{mutant_spec.version} does not satisfy #{@config.fetch("version_requirement", ">= 0")}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Availability.new(available: true, reason: "mutant and mutant-rspec are available")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def plan(sut_units:, test_files:)
|
|
46
|
+
configured = test_files.flat_map { |test_file| Array(test_file.metadata&.fetch(:mutation_subjects, [])) }
|
|
47
|
+
subjects = (configured + sut_units.flat_map { |unit| expressions_for(unit) }).uniq
|
|
48
|
+
MutationPlan.new(
|
|
49
|
+
engine: "mutant",
|
|
50
|
+
mode: @config.fetch("mode", "score"),
|
|
51
|
+
subjects: subjects,
|
|
52
|
+
test_files: test_files.map(&:path),
|
|
53
|
+
source_units: sut_units.map(&:to_h)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run(plan, cache:)
|
|
58
|
+
return failed_result(plan, ["sixth_sense", "mutation-run"], "mutation plan has no subjects") if plan.subjects.empty?
|
|
59
|
+
return run_matrix(plan, cache: cache) if matrix_mode?(plan.mode)
|
|
60
|
+
return run_builtin_score(plan, cache: cache) if builtin_score_engine? || requires_builtin_score?(plan)
|
|
61
|
+
|
|
62
|
+
available = external_availability
|
|
63
|
+
unless available.available?
|
|
64
|
+
return failed_result(plan, command_for(plan, cache: cache), "mutation engine unavailable: #{available.reason}")
|
|
65
|
+
end
|
|
66
|
+
begin
|
|
67
|
+
neutral_run!(plan)
|
|
68
|
+
rescue Error => error
|
|
69
|
+
return failed_result(plan, command_for(plan, cache: cache), error.message)
|
|
70
|
+
end
|
|
71
|
+
command = command_for(plan, cache: cache)
|
|
72
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
73
|
+
MutationScoreCacheWriter.new(cache: cache).write(plan, stdout: stdout, stderr: stderr) if status.success? && cache
|
|
74
|
+
MutationRunResult.new(
|
|
75
|
+
plan: plan,
|
|
76
|
+
command: command,
|
|
77
|
+
success: status.success?,
|
|
78
|
+
stdout: stdout,
|
|
79
|
+
stderr: stderr,
|
|
80
|
+
status: status.exitstatus
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def matrix_mode?(mode = @config.fetch("mode", "score"))
|
|
87
|
+
mode.to_s == "matrix"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def builtin_score_engine?
|
|
91
|
+
@config.fetch("score_engine", "auto").to_s == "builtin"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def external_score_engine?
|
|
95
|
+
@config.fetch("score_engine", "auto").to_s == "mutant"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def requires_builtin_score?(plan)
|
|
99
|
+
return false if external_score_engine?
|
|
100
|
+
|
|
101
|
+
!external_availability.available? || plan_frameworks(plan).any? { |framework| framework != :rspec }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def plan_frameworks(plan)
|
|
105
|
+
plan.test_files.filter_map do |path|
|
|
106
|
+
FrameworkAdapter.for_path(path)&.new&.parse(path)&.framework
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_matrix(plan, cache:)
|
|
111
|
+
producer = MutationMatrixProducer.new(config: @config)
|
|
112
|
+
begin
|
|
113
|
+
producer.neutral_run!(plan.test_files)
|
|
114
|
+
rescue Error => error
|
|
115
|
+
write_unstable_cache(plan, cache, error.message) if cache
|
|
116
|
+
return failed_result(plan, ["sixth_sense", "mutation-run", "--mutation-mode", "matrix"], error.message)
|
|
117
|
+
end
|
|
118
|
+
result = producer.run(plan)
|
|
119
|
+
result.write(cache)
|
|
120
|
+
MutationRunResult.new(
|
|
121
|
+
plan: plan,
|
|
122
|
+
command: ["sixth_sense", "mutation-run", "--mutation-mode", "matrix"],
|
|
123
|
+
success: true,
|
|
124
|
+
stdout: JSON.generate(result.payload),
|
|
125
|
+
stderr: "",
|
|
126
|
+
status: 0
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def run_builtin_score(plan, cache:)
|
|
131
|
+
producer = MutationMatrixProducer.new(config: @config)
|
|
132
|
+
begin
|
|
133
|
+
producer.neutral_run!(plan.test_files)
|
|
134
|
+
rescue Error => error
|
|
135
|
+
write_unstable_cache(plan, cache, error.message) if cache
|
|
136
|
+
return failed_result(plan, ["sixth_sense", "mutation-run", "--mutation-mode", "score"], error.message)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
result = producer.run(plan)
|
|
140
|
+
score = score_for(result.payload)
|
|
141
|
+
MutationScoreCacheWriter.new(cache: cache).write_score(plan, score) if cache
|
|
142
|
+
stdout = JSON.generate(
|
|
143
|
+
{
|
|
144
|
+
"engine" => "sixth_sense/matrix",
|
|
145
|
+
"mode" => "score",
|
|
146
|
+
"mutation_score" => score,
|
|
147
|
+
"source_mode" => "built_in_matrix"
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
MutationRunResult.new(
|
|
151
|
+
plan: plan,
|
|
152
|
+
command: ["sixth_sense", "mutation-run", "--mutation-mode", "score", "--score-engine", "builtin"],
|
|
153
|
+
success: true,
|
|
154
|
+
stdout: stdout,
|
|
155
|
+
stderr: "",
|
|
156
|
+
status: 0
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def score_for(payload)
|
|
161
|
+
mutants = payload.fetch("subjects", []).flat_map { |subject| subject.fetch("mutants", []) }
|
|
162
|
+
countable = mutants.reject { |mutant| mutant.fetch("status", "alive") == "skipped" }
|
|
163
|
+
return 0.0 if countable.empty?
|
|
164
|
+
|
|
165
|
+
killed = countable.count do |mutant|
|
|
166
|
+
status = mutant.fetch("status", "alive")
|
|
167
|
+
status == "killed" || (status == "timeout" && @config.fetch("timeout_policy", "killed").to_s == "killed")
|
|
168
|
+
end
|
|
169
|
+
killed.to_f / countable.length
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def failed_result(plan, command, message)
|
|
173
|
+
MutationRunResult.new(
|
|
174
|
+
plan: plan,
|
|
175
|
+
command: command,
|
|
176
|
+
success: false,
|
|
177
|
+
stdout: JSON.generate({ error: message }),
|
|
178
|
+
stderr: message,
|
|
179
|
+
status: 1
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def gem_installed?(name)
|
|
184
|
+
Gem::Specification.find_all_by_name(name).any?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def gem_spec(name)
|
|
188
|
+
Gem::Specification.find_all_by_name(name).first
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def supported_mutant_version?(version)
|
|
192
|
+
Gem::Requirement.new(@config.fetch("version_requirement", ">= 0")).satisfied_by?(version)
|
|
193
|
+
rescue Gem::Requirement::BadRequirementError
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def expressions_for(unit)
|
|
198
|
+
return unit.constants if unit.methods.empty?
|
|
199
|
+
|
|
200
|
+
unit.constants.flat_map do |constant|
|
|
201
|
+
[constant] + unit.methods.map { |method_name| "#{constant}##{method_name}" }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def command_for(plan, cache:)
|
|
206
|
+
command = ["mutant", "run", "--integration", "rspec", "--usage", @config.fetch("usage", "opensource")]
|
|
207
|
+
command.concat(["-I", @config.fetch("load_path", "lib")])
|
|
208
|
+
timeout = @config.fetch("timeout", nil)
|
|
209
|
+
command.concat(["--mutation-timeout", timeout.to_s]) if timeout
|
|
210
|
+
jobs = @config.fetch("jobs", nil)
|
|
211
|
+
command.concat(["--jobs", jobs.to_s]) if jobs && jobs != "auto"
|
|
212
|
+
command.concat(["--env", "SIXTH_SENSE_CACHE=#{cache}"]) if cache
|
|
213
|
+
plan.test_files.each do |test_file|
|
|
214
|
+
command.concat(["--integration-argument", test_file])
|
|
215
|
+
end
|
|
216
|
+
plan.subjects.each do |subject|
|
|
217
|
+
command.concat(["--start-subject", subject])
|
|
218
|
+
end
|
|
219
|
+
command
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def write_unstable_cache(plan, cache, message)
|
|
223
|
+
mutation_cache = MutationCache.new(root: cache)
|
|
224
|
+
plan.test_files.each do |test_file|
|
|
225
|
+
mutation_cache.write(
|
|
226
|
+
Model::TestFile.new(path: test_file, framework: :rspec, test_cases: [], sut_candidates: []),
|
|
227
|
+
{
|
|
228
|
+
"version" => 1,
|
|
229
|
+
"engine" => "sixth_sense/matrix",
|
|
230
|
+
"mode" => "matrix",
|
|
231
|
+
"metadata" => {
|
|
232
|
+
"warnings" => [
|
|
233
|
+
{
|
|
234
|
+
"type" => "unstable_baseline",
|
|
235
|
+
"message" => message
|
|
236
|
+
}
|
|
237
|
+
]
|
|
238
|
+
},
|
|
239
|
+
"subjects" => []
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def neutral_run!(plan)
|
|
246
|
+
runs = @config.fetch("neutral_runs", 2).to_i
|
|
247
|
+
statuses = runs.times.map do
|
|
248
|
+
command = [RbConfig.ruby, "-I", @config.fetch("load_path", "lib"), "-e", "require 'rspec/core'; require 'rspec/expectations'; exit RSpec::Core::Runner.run(ARGV)", "--", "--options", File::NULL, *plan.test_files]
|
|
249
|
+
_stdout, stderr, status = Open3.capture3(*command)
|
|
250
|
+
raise Error, "neutral run failed: #{stderr}" unless status.success?
|
|
251
|
+
|
|
252
|
+
status.exitstatus
|
|
253
|
+
end
|
|
254
|
+
raise Error, "neutral run was unstable" unless statuses.uniq.length == 1
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SixthSense
|
|
4
|
+
class FrameworkAdapter
|
|
5
|
+
class << self
|
|
6
|
+
def register(name, adapter_class)
|
|
7
|
+
registry[name.to_sym] = adapter_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def for_path(path)
|
|
15
|
+
registry.values.find { |adapter_class| adapter_class.new.handles?(path) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def handles?(_path)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse(_path)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_with_coverage(test_files:, isolation:)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mutation_session(test_files:)
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mutation_test_command(test_case:, config:)
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def coverage_test_command(test_case:, config:)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def checked_coverage_test_command(test_case:, config:)
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def map_to_sut(_test_file)
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def assertion_patterns
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|