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,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|