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,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "rbconfig"
6
+
7
+ require_relative "adapters/minitest"
8
+ require_relative "adapters/rspec"
9
+ require_relative "adapters/test_unit"
10
+ require_relative "framework_adapter"
11
+ require_relative "mutation_cache"
12
+ require_relative "mutation_matrix_mutant_generator"
13
+
14
+ module SixthSense
15
+ class MutationMatrixResult
16
+ attr_reader :payload
17
+ def initialize(payload)
18
+ @payload = payload
19
+ end
20
+
21
+ def write(cache_root)
22
+ cache = MutationCache.new(root: cache_root)
23
+ test_files.each do |test_file|
24
+ cache.write(Model::TestFile.new(path: test_file, framework: :rspec, test_cases: [], sut_candidates: []), payload_for(test_file))
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def test_files
31
+ payload.fetch("test_files")
32
+ end
33
+
34
+ def payload_for(test_file)
35
+ subjects = payload.fetch("subjects").map do |subject|
36
+ subject.merge(
37
+ "mutants" => subject.fetch("mutants").map do |mutant|
38
+ killed_by = Array(mutant["killed_by"]).select { |id| id.start_with?(test_file) }
39
+ executed = Array(mutant["executed"]).select { |id| id.start_with?(test_file) }
40
+ mutant.merge("killed_by" => killed_by, "executed" => executed)
41
+ end
42
+ )
43
+ end
44
+ payload.merge("subjects" => subjects)
45
+ end
46
+ end
47
+
48
+ class MutationMatrixProducer
49
+ def initialize(config: {})
50
+ @config = config
51
+ end
52
+
53
+ # Paper basis: Luo et al. 2014 (doi:10.1145/2635868.2635920) motivates
54
+ # isolating flaky/unstable baseline behavior before mutation analysis.
55
+ def neutral_run!(test_files)
56
+ runs = neutral_runs.times.map do
57
+ test_cases(test_files).map do |entry|
58
+ [entry.fetch(:test_case).id, run_test_case(entry)]
59
+ end
60
+ end
61
+ failed = runs.flatten(1).find { |_id, status| status != :passed }
62
+ raise Error, "neutral run failed for #{failed.fetch(0)}: #{failed.fetch(1)}" if failed
63
+ raise Error, "neutral run was unstable" unless runs.uniq.length == 1
64
+ end
65
+
66
+ # Paper basis: Jia/Harman 2011 (doi:10.1109/TSE.2010.62) defines mutation
67
+ # testing over generated mutants; this produces the per-test kill matrix
68
+ # consumed by mutation adequacy and redundancy analyzers.
69
+ def run(plan)
70
+ selection = MutationMatrixMutantGenerator.new(config: @config).generate(plan)
71
+ mutants = selection.fetch(:mutants)
72
+ sampling = selection.fetch(:sampling)
73
+ subjects = plan.source_units.map do |unit|
74
+ source_mutants = mutants.select { |mutant| File.expand_path(mutant.path) == File.expand_path(unit.fetch(:path) { unit["path"] }) }
75
+ {
76
+ "expression" => Array(unit[:constants] || unit["constants"]).first.to_s,
77
+ "source_digest" => digest_for(unit.fetch(:path) { unit["path"] }),
78
+ "mutants" => source_mutants.map { |mutant| execute_mutant(mutant, plan.test_files) }
79
+ }
80
+ end
81
+ MutationMatrixResult.new(
82
+ {
83
+ "version" => 1,
84
+ "engine" => "sixth_sense/matrix",
85
+ "mode" => "matrix",
86
+ "test_files" => plan.test_files,
87
+ "metadata" => {
88
+ "timeout_policy" => timeout_policy,
89
+ "sampling" => sampling,
90
+ "warnings" => sampling.fetch("sampled") ? ["mutation sample truncated to #{sampling.fetch("selected_count")} of #{sampling.fetch("generated_count")} generated mutants"] : []
91
+ },
92
+ "subjects" => subjects
93
+ }
94
+ )
95
+ end
96
+
97
+ private
98
+
99
+ # Paper basis: Koochakzadeh/Garousi 2010 (doi:10.1155/2010/932686) uses
100
+ # killed-mutant sets per test for redundancy; every test is run to record
101
+ # killed_by instead of using fail-fast mutation scoring.
102
+ def execute_mutant(mutant, test_files)
103
+ return skipped_mutant(mutant) if mutant.status == "skipped"
104
+
105
+ killed_by = []
106
+ executed = []
107
+ timed_out = []
108
+ with_mutation(mutant) do
109
+ test_cases(test_files).each do |test_case|
110
+ executed << test_case.fetch(:test_case).id
111
+ case run_test_case(test_case)
112
+ when :passed
113
+ next
114
+ when :timeout
115
+ timed_out << test_case.fetch(:test_case).id
116
+ killed_by << test_case.fetch(:test_case).id if timeout_policy == "killed"
117
+ else
118
+ killed_by << test_case.fetch(:test_case).id
119
+ end
120
+ end
121
+ end
122
+ {
123
+ "id" => mutant.id,
124
+ "operator" => mutant.operator,
125
+ "location" => { "file" => mutant.path, "line" => mutant.line },
126
+ "diff" => "-#{mutant.original}+#{mutant.replacement}",
127
+ "status" => mutant_status(killed_by, timed_out),
128
+ "killed_by" => killed_by,
129
+ "executed" => executed,
130
+ "timed_out" => timed_out,
131
+ "metadata" => { "timeout_policy" => timeout_policy }
132
+ }
133
+ end
134
+
135
+ def with_mutation(mutant)
136
+ original = File.read(mutant.path)
137
+ File.write(mutant.path, mutated_source(original, mutant))
138
+ yield
139
+ ensure
140
+ File.write(mutant.path, original) if original
141
+ end
142
+
143
+ def mutated_source(original, mutant)
144
+ range = mutant.metadata["range"] || mutant.metadata[:range]
145
+ return line_mutated_source(original, mutant) unless range
146
+
147
+ start_offset = range["start"] || range[:start]
148
+ end_offset = range["end"] || range[:end]
149
+ original.byteslice(0, start_offset) + mutant.replacement + original.byteslice(end_offset, original.bytesize - end_offset).to_s
150
+ end
151
+
152
+ def line_mutated_source(original, mutant)
153
+ lines = original.lines
154
+ lines[mutant.line - 1] = mutant.replacement
155
+ lines.join
156
+ end
157
+
158
+ def test_cases(test_files)
159
+ @test_cases_by_paths ||= {}
160
+ key = test_files.join(File::PATH_SEPARATOR)
161
+ return @test_cases_by_paths[key] if @test_cases_by_paths.key?(key)
162
+
163
+ test_files.flat_map do |path|
164
+ adapter, test_file = parse_test_file(path)
165
+ test_file.test_cases.reject { |test_case| test_case.metadata[:pending] }.map do |test_case|
166
+ { adapter: adapter, framework: test_file.framework, test_case: test_case }
167
+ end
168
+ end.then { |cases| @test_cases_by_paths[key] = cases }
169
+ end
170
+
171
+ def parse_test_file(path)
172
+ adapter_class = FrameworkAdapter.for_path(path)
173
+ raise Error, "no framework adapter accepted #{path}" unless adapter_class
174
+
175
+ adapter = adapter_class.new
176
+ [adapter, adapter.parse(path)]
177
+ end
178
+
179
+ def run_test_case(entry)
180
+ run_command(test_command(entry))
181
+ end
182
+
183
+ def test_command(entry)
184
+ custom_command = entry.fetch(:adapter).mutation_test_command(test_case: entry.fetch(:test_case), config: @config)
185
+ return custom_command if custom_command
186
+
187
+ framework = entry.fetch(:framework)
188
+ test_case = entry.fetch(:test_case)
189
+ case framework
190
+ when :rspec
191
+ [ruby, *load_path_args, "-e", rspec_runner_source, "--", "--options", File::NULL, "#{test_case.location.path}:#{test_case.location.line}"]
192
+ when :minitest
193
+ [ruby, *load_path_args, "-e", minitest_runner_source, test_case.location.path, test_case.id.split(":", 2).last]
194
+ when :test_unit
195
+ [ruby, *load_path_args, "-e", test_unit_runner_source, test_case.location.path, test_case.id.split(":", 2).last]
196
+ else
197
+ raise NotImplementedError, "mutation matrix execution is not implemented for #{framework}"
198
+ end
199
+ end
200
+
201
+ def ruby
202
+ RbConfig.ruby
203
+ end
204
+
205
+ def load_path_args
206
+ ["-I", @config.fetch("load_path", "lib")]
207
+ end
208
+
209
+ def rspec_runner_source
210
+ "require 'rspec/core'; require 'rspec/expectations'; exit RSpec::Core::Runner.run(ARGV)"
211
+ end
212
+
213
+ def minitest_runner_source
214
+ <<~'RUBY'
215
+ require File.expand_path(ARGV[0])
216
+ Minitest.seed ||= 0
217
+ klass = Minitest::Runnable.runnables.select { |candidate| candidate < Minitest::Test }.find { |candidate| candidate.runnable_methods.include?(ARGV[1]) }
218
+ exit 1 unless klass
219
+ result = klass.new(ARGV[1]).run
220
+ Minitest::Runnable.runnables.clear
221
+ exit(result.passed? ? 0 : 1)
222
+ RUBY
223
+ end
224
+
225
+ def test_unit_runner_source
226
+ <<~'RUBY'
227
+ require "test/unit"
228
+ require "test/unit/testresult"
229
+ require "test/unit/worker-context"
230
+ Test::Unit::AutoRunner.need_auto_run = false
231
+ require File.expand_path(ARGV[0])
232
+ klass = ObjectSpace.each_object(Class).select { |candidate| candidate < Test::Unit::TestCase }.find { |candidate| candidate.public_instance_methods.map(&:to_s).include?(ARGV[1]) }
233
+ exit 1 unless klass
234
+ result = Test::Unit::TestResult.new
235
+ test = klass.new(ARGV[1])
236
+ test.instance_variable_set(:@passed_assertions, [])
237
+ test.run(result) { |_event, *_args| }
238
+ exit(result.passed? ? 0 : 1)
239
+ RUBY
240
+ end
241
+
242
+ def run_command(command)
243
+ Open3.popen3(*command) do |stdin, stdout, stderr, wait_thread|
244
+ stdin.close
245
+ stdout_reader = Thread.new { stdout.read }
246
+ stderr_reader = Thread.new { stderr.read }
247
+ return command_status(wait_thread, stdout_reader, stderr_reader) if wait_thread.join(timeout_seconds)
248
+
249
+ terminate(wait_thread)
250
+ stdout_reader.kill
251
+ stderr_reader.kill
252
+ :timeout
253
+ end
254
+ end
255
+
256
+ def command_status(wait_thread, stdout_reader, stderr_reader)
257
+ stdout_reader.value
258
+ stderr_reader.value
259
+ wait_thread.value.success? ? :passed : :failed
260
+ end
261
+
262
+ def terminate(wait_thread)
263
+ Process.kill("TERM", wait_thread.pid)
264
+ return if wait_thread.join(0.2)
265
+
266
+ Process.kill("KILL", wait_thread.pid)
267
+ rescue Errno::ESRCH
268
+ nil
269
+ end
270
+
271
+ def digest_for(path)
272
+ "sha256:#{Digest::SHA256.file(path).hexdigest}"
273
+ end
274
+
275
+ def timeout_seconds
276
+ @config.fetch("timeout", 10).to_f
277
+ end
278
+
279
+ def timeout_policy
280
+ @config.fetch("timeout_policy", "killed").to_s
281
+ end
282
+
283
+ def neutral_runs
284
+ [@config.fetch("neutral_runs", 2).to_i, 1].max
285
+ end
286
+
287
+ def skipped_mutant(mutant)
288
+ {
289
+ "id" => mutant.id,
290
+ "operator" => mutant.operator,
291
+ "location" => { "file" => mutant.path, "line" => mutant.line },
292
+ "diff" => "-#{mutant.original}+#{mutant.replacement}",
293
+ "status" => "skipped",
294
+ "killed_by" => [],
295
+ "executed" => [],
296
+ "timed_out" => [],
297
+ "metadata" => mutant.metadata.merge("timeout_policy" => timeout_policy)
298
+ }
299
+ end
300
+
301
+ def mutant_status(killed_by, timed_out)
302
+ return "timeout" if timed_out.any?
303
+ return "killed" if killed_by.any?
304
+
305
+ "alive"
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "model"
6
+ require_relative "mutation_cache"
7
+
8
+ module SixthSense
9
+ class MutationScoreCacheWriter
10
+ def initialize(cache:)
11
+ @cache = cache
12
+ end
13
+
14
+ def write(plan, stdout:, stderr:)
15
+ score = parse_mutation_score([stdout, stderr].join("\n"))
16
+ return false unless score
17
+
18
+ write_score(plan, score)
19
+ end
20
+
21
+ def write_score(plan, score)
22
+ mutation_cache = MutationCache.new(root: @cache)
23
+ plan.test_files.each do |test_file|
24
+ mutation_cache.write(test_file_model(test_file), payload(plan, score))
25
+ end
26
+ true
27
+ end
28
+
29
+ private
30
+
31
+ def payload(plan, score)
32
+ {
33
+ "version" => 1,
34
+ "engine" => "mutant",
35
+ "mode" => "score",
36
+ "summary" => { "mutation_score" => score },
37
+ "metadata" => { "warnings" => ["score mode cache contains adequacy data only"] },
38
+ "subjects" => plan.source_units.map { |unit| subject_payload(unit) }
39
+ }
40
+ end
41
+
42
+ def subject_payload(unit)
43
+ {
44
+ "expression" => Array(unit[:constants] || unit["constants"]).first.to_s,
45
+ "source_digest" => source_digest(unit),
46
+ "mutants" => []
47
+ }
48
+ end
49
+
50
+ def parse_mutation_score(output)
51
+ percentage = output[/mutation\s+score:\s*([0-9]+(?:\.[0-9]+)?)%/i, 1] ||
52
+ output[/score:\s*([0-9]+(?:\.[0-9]+)?)%/i, 1]
53
+ return percentage.to_f / 100.0 if percentage
54
+
55
+ counts = output.match(/(?<killed>\d+)\s+killed.*?(?<total>\d+)\s+(?:total|mutations?)/im)
56
+ return unless counts
57
+
58
+ total = counts[:total].to_i
59
+ return if total.zero?
60
+
61
+ counts[:killed].to_i.to_f / total
62
+ end
63
+
64
+ def source_digest(unit)
65
+ path = unit[:path] || unit["path"]
66
+ return nil unless path && File.file?(path)
67
+
68
+ "sha256:#{Digest::SHA256.file(path).hexdigest}"
69
+ end
70
+
71
+ def test_file_model(path)
72
+ Model::TestFile.new(path: path, framework: :rspec, test_cases: [], sut_candidates: [])
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+
5
+ require_relative "cli"
6
+
7
+ module SixthSense
8
+ class RakeTask
9
+ def initialize(name = :sixth_sense)
10
+ desc "Run SixthSense guardrails"
11
+ task name do
12
+ exit CLI.new.run(["guard"])
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ module Reporters
5
+ class Console
6
+ def render(reports)
7
+ reports.map { |report| render_report(report) }.join("\n\n")
8
+ end
9
+
10
+ private
11
+
12
+ def render_report(report)
13
+ lines = []
14
+ lines << "#{report.test_file.path} composite=#{format_score(report.composite)} level=#{report.level}"
15
+ report.axis_scores.each do |score|
16
+ lines << " #{score.axis}: #{format_score(score.value)} confidence=#{score.confidence} measured=#{score.measured?}"
17
+ end
18
+ report.findings.first(10).each do |finding|
19
+ lines << " #{finding.severity} #{finding.analyzer_id}/#{finding.rule_id} #{finding.location}: #{finding.message}"
20
+ end
21
+ remaining = report.findings.length - 10
22
+ lines << " ... #{remaining} more finding(s)" if remaining.positive?
23
+ lines.join("\n")
24
+ end
25
+
26
+ def format_score(value)
27
+ value.nil? ? "not_measured" : format("%.2f", value)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ module Reporters
5
+ class Html
6
+ def render(reports)
7
+ <<~HTML
8
+ <!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <title>SixthSense Report</title>
13
+ <style>
14
+ body { font-family: system-ui, sans-serif; margin: 2rem; color: #1f2937; }
15
+ table { border-collapse: collapse; width: 100%; }
16
+ th, td { border: 1px solid #d1d5db; padding: .5rem; text-align: left; }
17
+ th { background: #f3f4f6; }
18
+ .finding { margin: .35rem 0; }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <h1>SixthSense Report</h1>
23
+ #{summary_table(reports)}
24
+ #{findings(reports)}
25
+ </body>
26
+ </html>
27
+ HTML
28
+ end
29
+
30
+ private
31
+
32
+ def summary_table(reports)
33
+ rows = reports.map do |report|
34
+ scores = report.axis_scores.to_h { |score| [score.axis, score] }
35
+ "<tr><td>#{escape(report.test_file.path)}</td><td>#{score(scores[:adequacy])}</td><td>#{score(scores[:redundancy])}</td><td>#{score(scores[:quality])}</td><td>#{number(report.composite)}</td></tr>"
36
+ end.join("\n")
37
+ "<table><thead><tr><th>File</th><th>Adequacy</th><th>Redundancy</th><th>Quality</th><th>Composite</th></tr></thead><tbody>#{rows}</tbody></table>"
38
+ end
39
+
40
+ def findings(reports)
41
+ body = reports.flat_map(&:findings).map do |finding|
42
+ "<li class=\"finding\"><strong>#{escape(finding.severity)}</strong> #{escape(finding.analyzer_id)}/#{escape(finding.rule_id)} #{escape(finding.location)}: #{escape(finding.message)}</li>"
43
+ end.join("\n")
44
+ "<h2>Findings</h2><ul>#{body}</ul>"
45
+ end
46
+
47
+ def score(axis_score)
48
+ return "not measured" unless axis_score&.measured?
49
+
50
+ "#{number(axis_score.value)} (#{escape(axis_score.confidence)})"
51
+ end
52
+
53
+ def number(value)
54
+ value.nil? ? "not measured" : format("%.2f", value)
55
+ end
56
+
57
+ def escape(value)
58
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SixthSense
6
+ module Reporters
7
+ class Json
8
+ def render(reports)
9
+ JSON.pretty_generate(
10
+ {
11
+ version: SixthSense::VERSION,
12
+ reports: reports.map(&:to_h)
13
+ }
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SixthSense
4
+ module Reporters
5
+ class Markdown
6
+ def render(reports)
7
+ lines = ["| Test file | Adequacy | Redundancy | Quality | Composite |", "|---|---:|---:|---:|---:|"]
8
+ reports.each do |report|
9
+ axes = report.axis_scores.to_h { |score| [score.axis, score] }
10
+ lines << [
11
+ report.test_file.path,
12
+ score_cell(axes[:adequacy]),
13
+ score_cell(axes[:redundancy]),
14
+ score_cell(axes[:quality]),
15
+ numeric(report.composite)
16
+ ].join(" | ").prepend("| ").concat(" |")
17
+ end
18
+ lines.join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ def score_cell(score)
24
+ return "not measured" unless score&.measured?
25
+
26
+ "#{numeric(score.value)} (#{score.confidence})"
27
+ end
28
+
29
+ def numeric(value)
30
+ value.nil? ? "not measured" : format("%.2f", value)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SixthSense
6
+ module Reporters
7
+ class Sarif
8
+ def render(reports)
9
+ JSON.pretty_generate(
10
+ {
11
+ version: "2.1.0",
12
+ runs: [
13
+ {
14
+ tool: {
15
+ driver: {
16
+ name: "sixth_sense",
17
+ informationUri: "https://github.com/ydah/sixth_sense",
18
+ rules: rules_for(reports)
19
+ }
20
+ },
21
+ results: results_for(reports)
22
+ }
23
+ ]
24
+ }
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def rules_for(reports)
31
+ reports.flat_map(&:findings).uniq { |finding| rule_id(finding) }.map do |finding|
32
+ {
33
+ id: rule_id(finding),
34
+ name: finding.rule_id,
35
+ shortDescription: { text: finding.message }
36
+ }
37
+ end
38
+ end
39
+
40
+ def results_for(reports)
41
+ reports.flat_map(&:findings).map do |finding|
42
+ {
43
+ ruleId: rule_id(finding),
44
+ level: sarif_level(finding.severity),
45
+ message: { text: finding.message },
46
+ locations: [location_for(finding)]
47
+ }
48
+ end
49
+ end
50
+
51
+ def location_for(finding)
52
+ location = finding.location
53
+ {
54
+ physicalLocation: {
55
+ artifactLocation: { uri: location&.path.to_s },
56
+ region: {
57
+ startLine: location&.line || 1,
58
+ startColumn: location&.column || 1
59
+ }
60
+ }
61
+ }
62
+ end
63
+
64
+ def rule_id(finding)
65
+ "#{finding.analyzer_id}:#{finding.rule_id}"
66
+ end
67
+
68
+ def sarif_level(severity)
69
+ case severity
70
+ when :error, "error" then "error"
71
+ when :warning, "warning" then "warning"
72
+ else "note"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end