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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "time"
6
+
7
+ module SixthSense
8
+ module Guardrail
9
+ class Baseline
10
+ DEFAULT_PATH = ".sixth_sense_baseline.json"
11
+
12
+ attr_reader :path, :data
13
+
14
+ def self.load(path = DEFAULT_PATH)
15
+ data = File.file?(path) ? JSON.parse(File.read(path)) : empty_data
16
+ new(path: path, data: data)
17
+ end
18
+
19
+ def self.empty_data
20
+ {
21
+ "version" => 1,
22
+ "generated_at" => nil,
23
+ "level" => 0,
24
+ "files" => {}
25
+ }
26
+ end
27
+
28
+ def initialize(path:, data:)
29
+ @path = path
30
+ @data = data
31
+ end
32
+
33
+ def violations(reports, current_level:, tolerance:)
34
+ return [] if current_level < data.fetch("level", 0).to_i
35
+
36
+ reports.flat_map do |report|
37
+ baseline_file = data.fetch("files", {})[report.test_file.path]
38
+ next [] unless baseline_file
39
+ next [] if baseline_file["digest"] == digest_for(report.test_file)
40
+
41
+ report.axis_scores.filter_map do |score|
42
+ next unless score.measured?
43
+
44
+ baseline_value = baseline_file[score.axis.to_s]
45
+ next unless baseline_value
46
+ next unless score.value < baseline_value.to_f - tolerance
47
+
48
+ {
49
+ path: report.test_file.path,
50
+ axis: score.axis,
51
+ current: score.value,
52
+ baseline: baseline_value.to_f,
53
+ tolerance: tolerance
54
+ }
55
+ end
56
+ end
57
+ end
58
+
59
+ def warnings(reports, current_level:, tolerance:)
60
+ return [] if current_level < data.fetch("level", 0).to_i
61
+
62
+ reports.flat_map do |report|
63
+ baseline_file = data.fetch("files", {})[report.test_file.path]
64
+ next [] unless baseline_file
65
+ next [] unless baseline_file["digest"] == digest_for(report.test_file)
66
+
67
+ report.axis_scores.filter_map do |score|
68
+ next unless score.measured?
69
+
70
+ baseline_value = baseline_file[score.axis.to_s]
71
+ next unless baseline_value
72
+ next unless (score.value - baseline_value.to_f).abs > tolerance
73
+
74
+ {
75
+ type: :flaky_measurement,
76
+ path: report.test_file.path,
77
+ axis: score.axis,
78
+ current: score.value,
79
+ baseline: baseline_value.to_f,
80
+ tolerance: tolerance
81
+ }
82
+ end
83
+ end
84
+ end
85
+
86
+ def update(reports, level:, force: false)
87
+ return self if !force && data.fetch("level", 0).to_i > level
88
+
89
+ next_data = self.class.empty_data
90
+ next_data["generated_at"] = Time.now.utc.iso8601
91
+ next_data["level"] = level
92
+ reports.each do |report|
93
+ existing = data.fetch("files", {})[report.test_file.path] || {}
94
+ next_data["files"][report.test_file.path] = updated_entry(report, existing, force: force)
95
+ end
96
+ @data = next_data
97
+ end
98
+
99
+ def write
100
+ File.write(path, JSON.pretty_generate(data) + "\n")
101
+ end
102
+
103
+ def digest_for(test_file)
104
+ digest = Digest::SHA256.new
105
+ ([test_file.path] + test_file.sut_candidates.map(&:path)).uniq.each do |path|
106
+ next unless File.file?(path)
107
+
108
+ digest.update(path)
109
+ digest.update(File.binread(path))
110
+ end
111
+ "sha256:#{digest.hexdigest}"
112
+ end
113
+
114
+ private
115
+
116
+ def updated_entry(report, existing, force:)
117
+ entry = {
118
+ "digest" => digest_for(report.test_file)
119
+ }
120
+ report.axis_scores.each do |score|
121
+ next unless score.measured?
122
+
123
+ old_value = existing[score.axis.to_s]
124
+ entry[score.axis.to_s] = if old_value && !force
125
+ [old_value.to_f, score.value].max.round(2)
126
+ else
127
+ score.value.round(2)
128
+ end
129
+ end
130
+ entry
131
+ end
132
+
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "baseline"
4
+
5
+ module SixthSense
6
+ module Guardrail
7
+ Evaluation = Struct.new(:passed, :violations, :baseline_violations, :warnings, keyword_init: true) do
8
+ def passed?
9
+ passed
10
+ end
11
+ end
12
+
13
+ class Evaluator
14
+ def initialize(config:, baseline: Baseline.load)
15
+ @config = config
16
+ @baseline = baseline
17
+ end
18
+
19
+ def evaluate(reports, level:)
20
+ threshold_violations = threshold_violations(reports, level: level)
21
+ tolerance = @config.fetch(:guardrail, :tolerance, default: 1.0).to_f
22
+ baseline_violations = ratchet? ? @baseline.violations(reports, current_level: level, tolerance: tolerance) : []
23
+ warnings = ratchet? ? @baseline.warnings(reports, current_level: level, tolerance: tolerance) : []
24
+
25
+ Evaluation.new(
26
+ passed: threshold_violations.empty? && baseline_violations.empty?,
27
+ violations: threshold_violations,
28
+ baseline_violations: baseline_violations,
29
+ warnings: warnings
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def threshold_violations(reports, level:)
36
+ require_level = @config.fetch(:guardrail, :require_level, default: 0).to_i
37
+ level_violation = if level < require_level
38
+ [{ type: :level, current: level, required: require_level }]
39
+ else
40
+ []
41
+ end
42
+
43
+ fail_under = @config.fetch(:guardrail, :fail_under, default: {})
44
+ score_violations = reports.flat_map do |report|
45
+ report.axis_scores.filter_map do |score|
46
+ threshold = fail_under[score.axis.to_s] || fail_under[score.axis]
47
+ next unless threshold
48
+ next unless !score.measured? || score.value < threshold.to_f
49
+
50
+ {
51
+ type: :threshold,
52
+ path: report.test_file.path,
53
+ axis: score.axis,
54
+ current: score.value,
55
+ threshold: threshold.to_f,
56
+ measured: score.measured?
57
+ }
58
+ end
59
+ end
60
+
61
+ level_violation + score_violations
62
+ end
63
+
64
+ def ratchet?
65
+ @config.fetch(:guardrail, :ratchet, default: false)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "source_location"
6
+
7
+ module SixthSense
8
+ module Model
9
+ TestFile = Struct.new(
10
+ :path,
11
+ :framework,
12
+ :test_cases,
13
+ :sut_candidates,
14
+ :metadata,
15
+ keyword_init: true
16
+ ) do
17
+ def assertions
18
+ test_cases.flat_map(&:assertions)
19
+ end
20
+
21
+ def executable_loc
22
+ test_cases.sum(&:executable_loc)
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ path: path,
28
+ framework: framework,
29
+ test_cases: test_cases.map(&:to_h),
30
+ sut_candidates: sut_candidates.map(&:to_h),
31
+ metadata: metadata || {}
32
+ }
33
+ end
34
+ end
35
+
36
+ TestCase = Struct.new(
37
+ :id,
38
+ :description,
39
+ :location,
40
+ :assertions,
41
+ :ast,
42
+ :body,
43
+ :metadata,
44
+ keyword_init: true
45
+ ) do
46
+ def executable_loc
47
+ body.to_s.lines.count { |line| line.match?(/\S/) }
48
+ end
49
+
50
+ def to_h
51
+ {
52
+ id: id,
53
+ description: description,
54
+ location: location&.to_h,
55
+ assertions: assertions.map(&:to_h),
56
+ metadata: metadata
57
+ }
58
+ end
59
+ end
60
+
61
+ Assertion = Struct.new(
62
+ :location,
63
+ :matcher,
64
+ :subject_expr,
65
+ :message,
66
+ keyword_init: true
67
+ ) do
68
+ def signature
69
+ [matcher.to_s, subject_expr.to_s.gsub(/\s+/, " ").strip].join(":")
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ location: location&.to_h,
75
+ matcher: matcher,
76
+ subject_expr: subject_expr,
77
+ message: message
78
+ }
79
+ end
80
+ end
81
+
82
+ CodeUnit = Struct.new(:path, :constants, :methods, keyword_init: true) do
83
+ def to_h
84
+ {
85
+ path: path,
86
+ constants: constants,
87
+ methods: methods
88
+ }
89
+ end
90
+ end
91
+
92
+ CoverageMap = Struct.new(
93
+ :per_test_lines,
94
+ :per_test_branches,
95
+ :total_lines,
96
+ :total_branches,
97
+ keyword_init: true
98
+ ) do
99
+ # Paper basis: Zhu/Hall/May 1997 (doi:10.1145/267580.267590)
100
+ # treats executed lines as coverage requirements for adequacy criteria.
101
+ def line_requirements_for(test_case_id)
102
+ normalize_requirements(per_test_lines.fetch(test_case_id, {}))
103
+ end
104
+
105
+ # Paper basis: Zhu/Hall/May 1997; branch requirements provide a stronger
106
+ # structural adequacy signal than line requirements when available.
107
+ def branch_requirements_for(test_case_id)
108
+ normalize_requirements(per_test_branches.fetch(test_case_id, {}))
109
+ end
110
+
111
+ # Paper basis: Zhu/Hall/May 1997; this is the raw satisfied-line
112
+ # requirement ratio used by adequacy/coverage.
113
+ def line_coverage_ratio
114
+ ratio_for(per_test_lines, total_lines)
115
+ end
116
+
117
+ # Paper basis: Zhu/Hall/May 1997; this is the raw satisfied-branch
118
+ # requirement ratio preferred by adequacy/coverage.
119
+ def branch_coverage_ratio
120
+ ratio_for(per_test_branches, total_branches)
121
+ end
122
+
123
+ private
124
+
125
+ def ratio_for(data, totals)
126
+ covered_requirements = data.values.flat_map do |requirements_by_path|
127
+ normalize_requirements(requirements_by_path).to_a
128
+ end.to_set
129
+ covered = covered_requirements.length
130
+ total = totals ? normalize_requirements(totals).length : covered
131
+ return nil if total.zero?
132
+
133
+ covered.to_f / total
134
+ end
135
+
136
+ def normalize_requirements(requirements_by_path)
137
+ requirements_by_path.flat_map do |path, requirements|
138
+ requirements.map { |requirement| [path, requirement] }
139
+ end.to_set
140
+ end
141
+ end
142
+
143
+ CheckedCoverageMap = Struct.new(:per_test_checked_lines, :executed_lines, keyword_init: true) do
144
+ # Paper basis: Schuler/Zeller 2011 (doi:10.1109/ICST.2011.32)
145
+ # scores the fraction of executed code that is checked by an oracle.
146
+ def checked_ratio
147
+ executed = normalize_requirements(executed_lines || {})
148
+ return nil if executed.empty?
149
+
150
+ checked = per_test_checked_lines.values.flat_map do |requirements_by_path|
151
+ normalize_requirements(requirements_by_path).to_a
152
+ end.to_set
153
+ (checked & executed).length.to_f / executed.length
154
+ end
155
+
156
+ # Paper basis: Schuler/Zeller 2011; unchecked executed lines are reported
157
+ # as oracle-quality gaps rather than ordinary coverage gaps.
158
+ def unchecked_lines
159
+ checked = per_test_checked_lines.values.flat_map do |requirements_by_path|
160
+ normalize_requirements(requirements_by_path).to_a
161
+ end.to_set
162
+ normalize_requirements(executed_lines || {}) - checked
163
+ end
164
+
165
+ private
166
+
167
+ def normalize_requirements(requirements_by_path)
168
+ requirements_by_path.flat_map do |path, requirements|
169
+ requirements.map { |requirement| [path, requirement] }
170
+ end.to_set
171
+ end
172
+ end
173
+
174
+ Mutant = Struct.new(
175
+ :id,
176
+ :operator,
177
+ :location,
178
+ :status,
179
+ :diff,
180
+ :killing_hint,
181
+ :metadata,
182
+ keyword_init: true
183
+ ) do
184
+ # Paper basis: Jia/Harman 2011 (doi:10.1109/TSE.2010.62) counts killed
185
+ # mutants as behaviorally distinguished by the test suite.
186
+ def killed?
187
+ return timeout_killed? if status == :timeout || status == "timeout"
188
+
189
+ status == :killed || status == "killed" || status == :timeout
190
+ end
191
+
192
+ # Paper basis: Jia/Harman 2011 describes equivalent mutants as a threat;
193
+ # equivalent/skipped mutants are excluded from the mutation-score denominator.
194
+ def excluded?
195
+ status == :excluded || status == "excluded" || status == :equivalent || status == :skipped || status == "skipped"
196
+ end
197
+
198
+ def to_h
199
+ {
200
+ id: id,
201
+ operator: operator,
202
+ location: location&.to_h,
203
+ status: status,
204
+ diff: diff,
205
+ killing_hint: killing_hint,
206
+ metadata: metadata || {}
207
+ }
208
+ end
209
+
210
+ private
211
+
212
+ def timeout_killed?
213
+ (metadata || {}).fetch("timeout_policy", "killed") == "killed"
214
+ end
215
+ end
216
+
217
+ KillMatrix = Struct.new(:mutants, :kills, :metadata, keyword_init: true) do
218
+ # Paper basis: Jia/Harman 2011 defines mutation score as killed mutants
219
+ # over non-excluded mutants; Just et al. 2014 validates its fault relation.
220
+ def mutation_score
221
+ override = (metadata || {}).dig("summary", "mutation_score")
222
+ return override.to_f if override
223
+
224
+ countable = mutants.reject(&:excluded?)
225
+ return nil if countable.empty?
226
+
227
+ countable.count(&:killed?).to_f / countable.length
228
+ end
229
+
230
+ def matrix?
231
+ (metadata || {}).fetch("mode", "matrix") == "matrix"
232
+ end
233
+
234
+ def surviving_mutants
235
+ mutants.reject(&:excluded?).reject(&:killed?)
236
+ end
237
+
238
+ def timeout_ratio
239
+ countable = mutants.reject(&:excluded?)
240
+ return 0.0 if countable.empty?
241
+
242
+ countable.count { |mutant| mutant.status == :timeout || mutant.status == "timeout" }.to_f / countable.length
243
+ end
244
+
245
+ def sampled?
246
+ !!(metadata || {}).dig("sampling", "sampled")
247
+ end
248
+
249
+ # Paper basis: Jia/Harman 2011 highlights equivalent mutants; the ratio is
250
+ # surfaced to warn about excessive denominator exclusions.
251
+ def equivalent_ratio
252
+ return 0.0 if mutants.empty?
253
+
254
+ mutants.count { |mutant| mutant.status == :equivalent || mutant.status == "equivalent" }.to_f / mutants.length
255
+ end
256
+
257
+ # Paper basis: Koochakzadeh/Garousi 2010 uses killed-mutant sets as
258
+ # requirements for mutation-based redundancy detection.
259
+ def kill_requirements_for(test_case_id)
260
+ kills.fetch(test_case_id, Set.new).to_set
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "set"
6
+
7
+ require_relative "model"
8
+ require_relative "source_location"
9
+
10
+ module SixthSense
11
+ class MutationCache
12
+ DEFAULT_ROOT = ".sixth_sense_cache/kill_matrix"
13
+
14
+ def initialize(root: DEFAULT_ROOT)
15
+ @root = root
16
+ end
17
+
18
+ def load(test_file)
19
+ path = path_for(test_file.path)
20
+ return nil unless File.file?(path)
21
+
22
+ build_matrix(JSON.parse(File.read(path)), test_file)
23
+ end
24
+
25
+ def write(test_file, payload)
26
+ FileUtils.mkdir_p(@root)
27
+ File.write(path_for(test_file.path), JSON.pretty_generate(payload) + "\n")
28
+ end
29
+
30
+ def path_for(test_file_path)
31
+ File.join(@root, "#{test_file_path.gsub(%r{[^A-Za-z0-9]+}, "_").gsub(/\A_+|_+\z/, "")}.json")
32
+ end
33
+
34
+ private
35
+
36
+ def build_matrix(payload, test_file)
37
+ equivalent_ids = equivalent_annotations(test_file)
38
+ mutants = []
39
+ kills = Hash.new { |hash, key| hash[key] = Set.new }
40
+
41
+ payload.fetch("subjects", []).each do |subject|
42
+ subject.fetch("mutants", []).each do |entry|
43
+ status = equivalent_ids.include?(entry.fetch("id")) ? :equivalent : entry.fetch("status", "alive").to_sym
44
+ mutant = Model::Mutant.new(
45
+ id: entry.fetch("id"),
46
+ operator: entry["operator"],
47
+ location: location_for(entry["location"]),
48
+ status: status,
49
+ diff: entry["diff"],
50
+ killing_hint: entry["killing_hint"],
51
+ metadata: metadata_for(entry, payload)
52
+ )
53
+ mutants << mutant
54
+ Array(entry["killed_by"]).each { |test_case_id| kills[test_case_id] << mutant.id }
55
+ end
56
+ end
57
+
58
+ Model::KillMatrix.new(mutants: mutants, kills: kills, metadata: matrix_metadata(payload))
59
+ end
60
+
61
+ def location_for(payload)
62
+ return nil unless payload
63
+
64
+ SourceLocation.new(path: payload["file"], line: payload["line"], column: payload["column"] || 1)
65
+ end
66
+
67
+ def equivalent_annotations(test_file)
68
+ test_file.sut_candidates.flat_map do |unit|
69
+ next [] unless File.file?(unit.path)
70
+
71
+ File.read(unit.path).scan(/#\s*sixth_sense:equivalent\s+([A-Za-z0-9_.:-]+)/).flatten
72
+ end.to_set
73
+ end
74
+
75
+ def metadata_for(entry, payload)
76
+ (entry["metadata"] || {}).merge(
77
+ "timeout_policy" => payload.dig("metadata", "timeout_policy") || "killed",
78
+ "timed_out" => Array(entry["timed_out"])
79
+ )
80
+ end
81
+
82
+ def matrix_metadata(payload)
83
+ {
84
+ "engine" => payload["engine"],
85
+ "mode" => payload["mode"] || "matrix",
86
+ "summary" => payload["summary"] || {},
87
+ "sampling" => payload.dig("metadata", "sampling") || {},
88
+ "timeout_policy" => payload.dig("metadata", "timeout_policy") || "killed",
89
+ "warnings" => payload.dig("metadata", "warnings") || []
90
+ }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ Availability = Struct.new(:available, :reason, keyword_init: true) do
5
+ def available?
6
+ available
7
+ end
8
+ end
9
+
10
+ MutationPlan = Struct.new(:engine, :mode, :subjects, :test_files, :source_units, keyword_init: true) do
11
+ def to_h
12
+ {
13
+ engine: engine,
14
+ mode: mode,
15
+ subjects: subjects,
16
+ test_files: test_files,
17
+ source_units: source_units
18
+ }
19
+ end
20
+ end
21
+
22
+ MutationRunResult = Struct.new(:plan, :command, :success, :stdout, :stderr, :status, keyword_init: true) do
23
+ def success?
24
+ success
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ plan: plan.to_h,
30
+ command: command,
31
+ success: success,
32
+ stdout: stdout,
33
+ stderr: stderr,
34
+ status: status
35
+ }
36
+ end
37
+ end
38
+
39
+ class MutationEngine
40
+ def plan(sut_units:, test_files:)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def run(_plan, cache:)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ def availability
49
+ raise NotImplementedError
50
+ end
51
+ end
52
+ end