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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/exe/sixth_sense +7 -0
  5. data/lib/sixth_sense/adapters/minitest.rb +145 -0
  6. data/lib/sixth_sense/adapters/rspec.rb +373 -0
  7. data/lib/sixth_sense/adapters/test_unit.rb +142 -0
  8. data/lib/sixth_sense/analysis_context.rb +35 -0
  9. data/lib/sixth_sense/analysis_runner.rb +141 -0
  10. data/lib/sixth_sense/analyzer.rb +85 -0
  11. data/lib/sixth_sense/analyzers/adequacy_checked_coverage.rb +39 -0
  12. data/lib/sixth_sense/analyzers/adequacy_coverage.rb +63 -0
  13. data/lib/sixth_sense/analyzers/adequacy_mutation.rb +141 -0
  14. data/lib/sixth_sense/analyzers/quality_assertion_density.rb +41 -0
  15. data/lib/sixth_sense/analyzers/quality_flakiness.rb +53 -0
  16. data/lib/sixth_sense/analyzers/quality_test_smells.rb +253 -0
  17. data/lib/sixth_sense/analyzers/redundancy_clone.rb +54 -0
  18. data/lib/sixth_sense/analyzers/redundancy_coverage.rb +39 -0
  19. data/lib/sixth_sense/analyzers/redundancy_mutation.rb +39 -0
  20. data/lib/sixth_sense/analyzers/redundancy_requirement.rb +70 -0
  21. data/lib/sixth_sense/changed_files.rb +45 -0
  22. data/lib/sixth_sense/cli.rb +229 -0
  23. data/lib/sixth_sense/config.rb +91 -0
  24. data/lib/sixth_sense/engines/mutant.rb +258 -0
  25. data/lib/sixth_sense/framework_adapter.rb +55 -0
  26. data/lib/sixth_sense/guardrail/baseline.rb +135 -0
  27. data/lib/sixth_sense/guardrail/evaluator.rb +69 -0
  28. data/lib/sixth_sense/model.rb +264 -0
  29. data/lib/sixth_sense/mutation_cache.rb +93 -0
  30. data/lib/sixth_sense/mutation_engine.rb +52 -0
  31. data/lib/sixth_sense/mutation_matrix_mutant_generator.rb +462 -0
  32. data/lib/sixth_sense/mutation_matrix_producer.rb +308 -0
  33. data/lib/sixth_sense/mutation_score_cache_writer.rb +75 -0
  34. data/lib/sixth_sense/rake_task.rb +16 -0
  35. data/lib/sixth_sense/reporters/console.rb +31 -0
  36. data/lib/sixth_sense/reporters/html.rb +62 -0
  37. data/lib/sixth_sense/reporters/json.rb +18 -0
  38. data/lib/sixth_sense/reporters/markdown.rb +34 -0
  39. data/lib/sixth_sense/reporters/sarif.rb +77 -0
  40. data/lib/sixth_sense/result.rb +86 -0
  41. data/lib/sixth_sense/runners/checked_coverage_estimator.rb +62 -0
  42. data/lib/sixth_sense/runners/checked_coverage_runner.rb +130 -0
  43. data/lib/sixth_sense/runners/checked_coverage_trace.rb +110 -0
  44. data/lib/sixth_sense/runners/coverage_runner.rb +220 -0
  45. data/lib/sixth_sense/runners/coverage_snapshot.rb +64 -0
  46. data/lib/sixth_sense/runners/minitest_checked_coverage_probe.rb +42 -0
  47. data/lib/sixth_sense/runners/minitest_coverage_probe.rb +49 -0
  48. data/lib/sixth_sense/runners/rspec_checked_coverage_probe.rb +26 -0
  49. data/lib/sixth_sense/runners/rspec_coverage_probe.rb +98 -0
  50. data/lib/sixth_sense/runners/test_unit_checked_coverage_probe.rb +43 -0
  51. data/lib/sixth_sense/runners/test_unit_coverage_probe.rb +51 -0
  52. data/lib/sixth_sense/scoring/aggregator.rb +117 -0
  53. data/lib/sixth_sense/source_location.rb +19 -0
  54. data/lib/sixth_sense/version.rb +5 -0
  55. data/lib/sixth_sense.rb +74 -0
  56. 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