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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ module Result
5
+ Reference = Struct.new(:authors, :title, :venue, :year, :doi, keyword_init: true) do
6
+ def to_h
7
+ {
8
+ authors: authors,
9
+ title: title,
10
+ venue: venue,
11
+ year: year,
12
+ doi: doi
13
+ }
14
+ end
15
+ end
16
+
17
+ Finding = Struct.new(
18
+ :analyzer_id,
19
+ :rule_id,
20
+ :severity,
21
+ :location,
22
+ :message,
23
+ :references,
24
+ :suggestion,
25
+ keyword_init: true
26
+ ) do
27
+ def to_h
28
+ {
29
+ analyzer_id: analyzer_id,
30
+ rule_id: rule_id,
31
+ severity: severity,
32
+ location: location&.to_h,
33
+ message: message,
34
+ references: references.map(&:to_h),
35
+ suggestion: suggestion
36
+ }
37
+ end
38
+ end
39
+
40
+ AxisScore = Struct.new(
41
+ :axis,
42
+ :value,
43
+ :confidence,
44
+ :findings,
45
+ :measured,
46
+ keyword_init: true
47
+ ) do
48
+ def measured?
49
+ measured != false && !value.nil?
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ axis: axis,
55
+ value: value,
56
+ confidence: confidence,
57
+ measured: measured?,
58
+ findings: findings.map(&:to_h)
59
+ }
60
+ end
61
+ end
62
+
63
+ FileReport = Struct.new(
64
+ :test_file,
65
+ :axis_scores,
66
+ :composite,
67
+ :level,
68
+ :warnings,
69
+ keyword_init: true
70
+ ) do
71
+ def findings
72
+ axis_scores.flat_map(&:findings)
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ test_file: test_file.to_h,
78
+ axis_scores: axis_scores.to_h { |score| [score.axis, score.to_h] },
79
+ composite: composite,
80
+ level: level,
81
+ warnings: warnings
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "../model"
6
+
7
+ module SixthSense
8
+ module Runners
9
+ class CheckedCoverageEstimator
10
+ def estimate(test_file, coverage_map)
11
+ checked = {}
12
+ executed = Hash.new { |hash, key| hash[key] = Set.new }
13
+
14
+ test_file.test_cases.each do |test_case|
15
+ covered_by_path = group_requirements(coverage_map.line_requirements_for(test_case.id))
16
+ sut_paths = test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.to_set
17
+ covered_by_path.each do |path, lines|
18
+ executed[path].merge(lines) if sut_paths.include?(File.expand_path(path))
19
+ end
20
+ checked[test_case.id] = checked_lines_for(test_file, test_case, covered_by_path)
21
+ end
22
+
23
+ Model::CheckedCoverageMap.new(
24
+ per_test_checked_lines: checked,
25
+ executed_lines: executed.transform_values(&:to_a)
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def checked_lines_for(test_file, test_case, covered_by_path)
32
+ tokens = assertion_tokens(test_case)
33
+ return {} if tokens.empty?
34
+
35
+ test_file.sut_candidates.each_with_object({}) do |unit, checked|
36
+ covered_path = covered_by_path.keys.find { |path| File.expand_path(path) == File.expand_path(unit.path) }
37
+ next unless covered_path
38
+ next unless token_matches_unit?(tokens, unit)
39
+
40
+ checked[covered_path] = covered_by_path.fetch(covered_path).to_a
41
+ end
42
+ end
43
+
44
+ def group_requirements(requirements)
45
+ requirements.each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |(path, line), grouped|
46
+ grouped[path] << line
47
+ end
48
+ end
49
+
50
+ def assertion_tokens(test_case)
51
+ test_case.assertions.flat_map do |assertion|
52
+ assertion.subject_expr.to_s.scan(/[A-Za-z_]\w*[!?=]?/)
53
+ end.to_set
54
+ end
55
+
56
+ def token_matches_unit?(tokens, unit)
57
+ unit.constants.any? { |constant| tokens.include?(constant.split("::").last) } ||
58
+ unit.methods.any? { |method_name| tokens.include?(method_name.delete_suffix("=")) || tokens.include?(method_name) }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "tempfile"
7
+
8
+ require_relative "../framework_adapter"
9
+ require_relative "../model"
10
+
11
+ module SixthSense
12
+ module Runners
13
+ class CheckedCoverageRunner
14
+ def initialize(config: {})
15
+ @config = config
16
+ end
17
+
18
+ def run(test_files:)
19
+ test_files.to_h do |test_file|
20
+ [test_file.path, run_file(test_file)]
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def run_file(test_file)
27
+ return fallback_empty unless checked_coverage_supported?(test_file)
28
+
29
+ per_test_checked = {}
30
+ executed = Hash.new { |hash, key| hash[key] = [] }
31
+ test_file.test_cases.each do |test_case|
32
+ next if test_case.metadata[:pending]
33
+
34
+ snapshot = process_snapshot(test_file, test_case)
35
+ per_test_checked[test_case.id] = snapshot.fetch("checked_lines", {})
36
+ snapshot.fetch("executed_lines", {}).each do |path, lines|
37
+ executed[path] |= lines
38
+ end
39
+ end
40
+
41
+ Model::CheckedCoverageMap.new(
42
+ per_test_checked_lines: per_test_checked,
43
+ executed_lines: executed
44
+ )
45
+ end
46
+
47
+ def fallback_empty
48
+ Model::CheckedCoverageMap.new(per_test_checked_lines: {}, executed_lines: {})
49
+ end
50
+
51
+ def process_snapshot(test_file, test_case)
52
+ Tempfile.create(["sixth_sense_checked", ".json"]) do |file|
53
+ file.close
54
+ env = {
55
+ "SIXTH_SENSE_CHECKED_OUTPUT" => file.path,
56
+ "SIXTH_SENSE_SUT_PATHS" => test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.join(File::PATH_SEPARATOR),
57
+ "SIXTH_SENSE_CHECKED_WINDOW" => checked_window.to_s
58
+ }
59
+ command = checked_coverage_command(test_file, test_case)
60
+ _stdout, stderr, status = Open3.capture3(env, *command)
61
+ raise Error, "checked coverage run failed for #{test_case.id}: #{stderr}" unless status.success?
62
+
63
+ JSON.parse(File.read(file.path))
64
+ end
65
+ end
66
+
67
+ def checked_coverage_command(test_file, test_case)
68
+ custom_command = adapter_for(test_file)&.checked_coverage_test_command(test_case: test_case, config: @config)
69
+ return custom_command if custom_command
70
+
71
+ case test_file.framework
72
+ when :rspec
73
+ [
74
+ RbConfig.ruby,
75
+ *load_paths.flat_map { |path| ["-I", path] },
76
+ "-rsixth_sense/runners/rspec_checked_coverage_probe",
77
+ "-e",
78
+ "SixthSense::Runners::RSpecCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
79
+ test_case.location.path,
80
+ test_case.location.line.to_s
81
+ ]
82
+ when :minitest
83
+ [
84
+ RbConfig.ruby,
85
+ *load_paths.flat_map { |path| ["-I", path] },
86
+ "-rsixth_sense/runners/minitest_checked_coverage_probe",
87
+ "-e",
88
+ "SixthSense::Runners::MinitestCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
89
+ test_case.location.path,
90
+ test_case.id.split(":", 2).last
91
+ ]
92
+ when :test_unit
93
+ [
94
+ RbConfig.ruby,
95
+ *load_paths.flat_map { |path| ["-I", path] },
96
+ "-rsixth_sense/runners/test_unit_checked_coverage_probe",
97
+ "-e",
98
+ "SixthSense::Runners::TestUnitCheckedCoverageProbe.run(ARGV[0], ARGV[1])",
99
+ test_case.location.path,
100
+ test_case.id.split(":", 2).last
101
+ ]
102
+ else
103
+ raise NotImplementedError, "checked coverage is not implemented for #{test_file.framework}"
104
+ end
105
+ end
106
+
107
+ def checked_coverage_supported?(test_file)
108
+ return true if %i[rspec minitest test_unit].include?(test_file.framework)
109
+
110
+ adapter = adapter_for(test_file)
111
+ adapter && adapter.method(:checked_coverage_test_command).owner != FrameworkAdapter
112
+ end
113
+
114
+ def load_paths
115
+ configured = Array(@config.fetch("load_path", "lib"))
116
+ (configured + [File.expand_path("../..", __dir__)]).uniq
117
+ end
118
+
119
+ def checked_window
120
+ return 0 if @config.fetch("checked_mode", "windowed").to_s == "strict"
121
+
122
+ [@config.fetch("checked_window", 20).to_i, 0].max
123
+ end
124
+
125
+ def adapter_for(test_file)
126
+ FrameworkAdapter.for_path(test_file.path)&.new
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set"
5
+
6
+ module SixthSense
7
+ module Runners
8
+ module CheckedCoverageTrace
9
+ module_function
10
+
11
+ # Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32).
12
+ # This captures a TracePoint approximation of checked coverage rather
13
+ # than their full VM-level dynamic backward slicing.
14
+ def capture
15
+ executed = Hash.new { |hash, key| hash[key] = Set.new }
16
+ checked = Hash.new { |hash, key| hash[key] = Set.new }
17
+ trace = tracepoint(executed, checked)
18
+ trace.enable
19
+ yield
20
+ ensure
21
+ trace.disable if trace
22
+ write_snapshot(executed, checked) if executed && checked
23
+ end
24
+
25
+ # Paper basis: Schuler/Zeller 2011; assertion calls are treated as
26
+ # oracle boundaries, and SUT lines observed inside that boundary are checked.
27
+ def tracepoint(executed, checked)
28
+ TracePoint.new(:line, :call, :c_call, :return, :c_return) do |event|
29
+ case event.event
30
+ when :line
31
+ next unless sut_path?(event.path)
32
+
33
+ executed[event.path] << event.lineno
34
+ remember_line(event.path, event.lineno)
35
+ checked[event.path] << event.lineno if assertion_stack.positive?
36
+ when :call, :c_call
37
+ next unless assertion_method?(event.method_id)
38
+
39
+ mark_recent_lines(checked)
40
+ self.assertion_stack += 1
41
+ when :return, :c_return
42
+ next unless assertion_method?(event.method_id) && assertion_stack.positive?
43
+
44
+ self.assertion_stack -= 1
45
+ clear_recent_lines
46
+ end
47
+ end
48
+ end
49
+
50
+ def assertion_method?(method_id)
51
+ name = method_id.to_s
52
+ return true if %w[expect should should_not flunk].include?(name)
53
+ return true if name.start_with?("assert", "refute", "must_", "wont_")
54
+
55
+ false
56
+ end
57
+
58
+ def sut_path?(path)
59
+ sut_paths.include?(File.expand_path(path))
60
+ end
61
+
62
+ def sut_paths
63
+ @sut_paths ||= ENV.fetch("SIXTH_SENSE_SUT_PATHS", "").split(File::PATH_SEPARATOR).map { |path| File.expand_path(path) }.to_set
64
+ end
65
+
66
+ def assertion_stack
67
+ Thread.current[:sixth_sense_assertion_stack].to_i
68
+ end
69
+
70
+ def assertion_stack=(value)
71
+ Thread.current[:sixth_sense_assertion_stack] = value
72
+ end
73
+
74
+ def remember_line(path, line)
75
+ recent_lines << [path, line]
76
+ recent_lines.shift while recent_lines.length > recent_window
77
+ end
78
+
79
+ def mark_recent_lines(checked)
80
+ recent_lines.each do |path, line|
81
+ checked[path] << line
82
+ end
83
+ end
84
+
85
+ def clear_recent_lines
86
+ Thread.current[:sixth_sense_recent_sut_lines] = []
87
+ end
88
+
89
+ def recent_lines
90
+ Thread.current[:sixth_sense_recent_sut_lines] ||= []
91
+ end
92
+
93
+ def recent_window
94
+ ENV.fetch("SIXTH_SENSE_CHECKED_WINDOW", "20").to_i
95
+ end
96
+
97
+ def write_snapshot(executed, checked)
98
+ File.write(
99
+ ENV.fetch("SIXTH_SENSE_CHECKED_OUTPUT"),
100
+ JSON.generate(
101
+ {
102
+ executed_lines: executed.transform_values(&:to_a),
103
+ checked_lines: checked.transform_values(&:to_a)
104
+ }
105
+ )
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require "json"
5
+ require "open3"
6
+ require "rbconfig"
7
+ require "set"
8
+ require "stringio"
9
+ require "tempfile"
10
+
11
+ require_relative "../framework_adapter"
12
+ require_relative "../mutation_engine"
13
+ require_relative "../model"
14
+ require_relative "coverage_snapshot"
15
+
16
+ module SixthSense
17
+ module Runners
18
+ class CoverageRunner
19
+ def initialize(config: {})
20
+ @config = config
21
+ end
22
+
23
+ def availability
24
+ return Availability.new(available: false, reason: "Ruby Coverage module is unavailable") unless defined?(Coverage)
25
+ return Availability.new(available: false, reason: "Coverage.start is unavailable") unless Coverage.respond_to?(:start)
26
+
27
+ Availability.new(available: true, reason: "Ruby Coverage module is available")
28
+ end
29
+
30
+ def run(test_files:, isolation: :process)
31
+ available = availability
32
+ unless available.available?
33
+ raise Error, "coverage runner unavailable: #{available.reason}"
34
+ end
35
+
36
+ test_files.to_h do |test_file|
37
+ [test_file.path, run_file(test_file, isolation: isolation)]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def run_file(test_file, isolation:)
44
+ per_test_lines = {}
45
+ per_test_branches = {}
46
+ all_lines = Hash.new { |hash, key| hash[key] = Set.new }
47
+ all_branches = Hash.new { |hash, key| hash[key] = Set.new }
48
+
49
+ test_file.test_cases.each do |test_case|
50
+ next if test_case.metadata[:pending]
51
+
52
+ snapshot = isolation == :none ? in_process_snapshot(test_file, test_case) : process_snapshot(test_file, test_case)
53
+ snapshot = filter_snapshot(snapshot, test_file)
54
+ per_test_lines[test_case.id] = snapshot.fetch("lines", {})
55
+ per_test_branches[test_case.id] = snapshot.fetch("branches", {})
56
+ merge_requirements(all_lines, per_test_lines[test_case.id])
57
+ merge_requirements(all_branches, per_test_branches[test_case.id])
58
+ merge_requirements(all_lines, snapshot.fetch("executable_lines", {}))
59
+ merge_requirements(all_branches, snapshot.fetch("executable_branches", {}))
60
+ end
61
+
62
+ Model::CoverageMap.new(
63
+ per_test_lines: deep_sets(per_test_lines),
64
+ per_test_branches: deep_sets(per_test_branches),
65
+ total_lines: all_lines.transform_values(&:to_a),
66
+ total_branches: all_branches.transform_values(&:to_a)
67
+ )
68
+ end
69
+
70
+ def empty_coverage_map
71
+ Model::CoverageMap.new(
72
+ per_test_lines: {},
73
+ per_test_branches: {},
74
+ total_lines: {},
75
+ total_branches: {}
76
+ )
77
+ end
78
+
79
+ def process_snapshot(test_file, test_case)
80
+ Tempfile.create(["sixth_sense_coverage", ".json"]) do |file|
81
+ file.close
82
+ command = coverage_command(test_file, test_case)
83
+ env = { "SIXTH_SENSE_COVERAGE_OUTPUT" => file.path }
84
+ _stdout, stderr, status = Open3.capture3(env, *command)
85
+ raise Error, "coverage run failed for #{test_case.id}: #{stderr}" unless status.success?
86
+
87
+ JSON.parse(File.read(file.path))
88
+ end
89
+ end
90
+
91
+ def coverage_command(test_file, test_case)
92
+ custom_command = adapter_for(test_file)&.coverage_test_command(test_case: test_case, config: @config)
93
+ return custom_command if custom_command
94
+
95
+ case test_file.framework
96
+ when :rspec
97
+ [
98
+ RbConfig.ruby,
99
+ *load_paths.flat_map { |path| ["-I", path] },
100
+ "-rsixth_sense/runners/rspec_coverage_probe",
101
+ "-e",
102
+ "SixthSense::Runners::RSpecCoverageProbe.run(ARGV[0], ARGV[1])",
103
+ test_case.location.path,
104
+ test_case.location.line.to_s
105
+ ]
106
+ when :minitest
107
+ [
108
+ RbConfig.ruby,
109
+ *load_paths.flat_map { |path| ["-I", path] },
110
+ "-rsixth_sense/runners/minitest_coverage_probe",
111
+ "-e",
112
+ "SixthSense::Runners::MinitestCoverageProbe.run(ARGV[0], ARGV[1])",
113
+ test_case.location.path,
114
+ test_case.id.split(":", 2).last
115
+ ]
116
+ when :test_unit
117
+ [
118
+ RbConfig.ruby,
119
+ *load_paths.flat_map { |path| ["-I", path] },
120
+ "-rsixth_sense/runners/test_unit_coverage_probe",
121
+ "-e",
122
+ "SixthSense::Runners::TestUnitCoverageProbe.run(ARGV[0], ARGV[1])",
123
+ test_case.location.path,
124
+ test_case.id.split(":", 2).last
125
+ ]
126
+ else
127
+ raise NotImplementedError, "coverage is not implemented for #{test_file.framework}"
128
+ end
129
+ end
130
+
131
+ def in_process_snapshot(test_file, test_case)
132
+ load_paths.each { |path| $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path) }
133
+ Coverage.start(lines: true, branches: true)
134
+ passed = run_in_process(test_file, test_case)
135
+ result = Coverage.result
136
+ raise Error, "in-process coverage run failed for #{test_case.id}" unless passed
137
+
138
+ CoverageSnapshot.snapshot_for(result)
139
+ end
140
+
141
+ def run_in_process(test_file, test_case)
142
+ case test_file.framework
143
+ when :rspec then run_rspec_in_process(test_case)
144
+ when :minitest then run_minitest_in_process(test_case)
145
+ when :test_unit then run_test_unit_in_process(test_case)
146
+ else
147
+ raise NotImplementedError, "coverage is not implemented for #{test_file.framework}"
148
+ end
149
+ end
150
+
151
+ def run_rspec_in_process(test_case)
152
+ require "rspec/core"
153
+ require "rspec/expectations"
154
+
155
+ RSpec::Core::Runner.run(["--options", File::NULL, "#{test_case.location.path}:#{test_case.location.line}"], StringIO.new, StringIO.new).zero?
156
+ end
157
+
158
+ def run_minitest_in_process(test_case)
159
+ require File.expand_path(test_case.location.path)
160
+ Minitest.run(["--name", "/\\A#{Regexp.escape(test_case.id.split(":", 2).last)}\\z/"])
161
+ end
162
+
163
+ def run_test_unit_in_process(test_case)
164
+ require "test/unit"
165
+ require "test/unit/testresult"
166
+ require "test/unit/worker-context"
167
+ Test::Unit::AutoRunner.need_auto_run = false
168
+ require File.expand_path(test_case.location.path)
169
+ klass = test_unit_class_for(test_case.id.split(":", 2).last)
170
+ return false unless klass
171
+
172
+ result = Test::Unit::TestResult.new
173
+ test = klass.new(test_case.id.split(":", 2).last)
174
+ test.instance_variable_set(:@passed_assertions, [])
175
+ test.run(result) { |_event, *_args| }
176
+ result.passed?
177
+ end
178
+
179
+ def test_unit_classes
180
+ ObjectSpace.each_object(Class).select { |klass| klass < Test::Unit::TestCase }
181
+ end
182
+
183
+ def test_unit_class_for(test_name)
184
+ test_unit_classes.find { |candidate| candidate.public_instance_methods.map(&:to_s).include?(test_name) }
185
+ end
186
+
187
+ def filter_snapshot(snapshot, test_file)
188
+ allowed = test_file.sut_candidates.map { |unit| File.expand_path(unit.path) }.to_set
189
+ return snapshot if allowed.empty?
190
+
191
+ snapshot.transform_values do |requirements|
192
+ next requirements unless requirements.is_a?(Hash)
193
+
194
+ requirements.select { |path, _items| allowed.include?(File.expand_path(path)) }
195
+ end
196
+ end
197
+
198
+ def load_paths
199
+ configured = Array(@config.fetch("load_path", "lib"))
200
+ (configured + [File.expand_path("../..", __dir__)]).uniq
201
+ end
202
+
203
+ def adapter_for(test_file)
204
+ FrameworkAdapter.for_path(test_file.path)&.new
205
+ end
206
+
207
+ def merge_requirements(target, requirements)
208
+ requirements.each do |path, items|
209
+ target[path].merge(items)
210
+ end
211
+ end
212
+
213
+ def deep_sets(hash)
214
+ hash.transform_values do |requirements|
215
+ requirements.transform_values { |items| items.to_set }
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ module Runners
5
+ module CoverageSnapshot
6
+ module_function
7
+
8
+ def snapshot_for(result)
9
+ lines = {}
10
+ executable_lines = {}
11
+ branches = {}
12
+ executable_branches = {}
13
+ result.each do |path, payload|
14
+ line_counts = payload.is_a?(Hash) ? payload[:lines] || payload["lines"] : payload
15
+ branch_counts = payload[:branches] || payload["branches"] if payload.is_a?(Hash)
16
+ lines[path] = covered_lines(line_counts)
17
+ executable_lines[path] = executable_lines(line_counts)
18
+ branches[path] = covered_branches(branch_counts)
19
+ executable_branches[path] = executable_branches(branch_counts)
20
+ end
21
+ {
22
+ "lines" => lines,
23
+ "executable_lines" => executable_lines,
24
+ "branches" => branches,
25
+ "executable_branches" => executable_branches
26
+ }
27
+ end
28
+
29
+ def covered_lines(line_counts)
30
+ Array(line_counts).each_with_index.filter_map { |count, index| index + 1 if count.to_i.positive? }
31
+ end
32
+
33
+ def executable_lines(line_counts)
34
+ Array(line_counts).each_with_index.filter_map { |count, index| index + 1 unless count.nil? }
35
+ end
36
+
37
+ def covered_branches(branches)
38
+ return [] unless branches.respond_to?(:each)
39
+
40
+ branches.each_with_index.filter_map do |branch, index|
41
+ "#{branch_key(branch)}:#{index}" if branch_executed?(branch)
42
+ end
43
+ end
44
+
45
+ def executable_branches(branches)
46
+ return [] unless branches.respond_to?(:each)
47
+
48
+ branches.each_with_index.map { |branch, index| "#{branch_key(branch)}:#{index}" }
49
+ end
50
+
51
+ def branch_key(branch)
52
+ branch.respond_to?(:first) ? branch.first.inspect : branch.inspect
53
+ end
54
+
55
+ def branch_executed?(branch)
56
+ values = branch.respond_to?(:last) ? branch.last : branch
57
+ return values.to_i.positive? if values.is_a?(Integer)
58
+ return values.values.any? { |count| count.to_i.positive? } if values.respond_to?(:values)
59
+
60
+ false
61
+ end
62
+ end
63
+ end
64
+ end