rspec-risky 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 +103 -0
- data/exe/rspec-risky +7 -0
- data/lib/minitest/risky_plugin.rb +33 -0
- data/lib/rspec/risky/autorun.rb +5 -0
- data/lib/rspec/risky/configuration.rb +112 -0
- data/lib/rspec/risky/evaluation.rb +129 -0
- data/lib/rspec/risky/formatter.rb +128 -0
- data/lib/rspec/risky/json_event_formatter.rb +41 -0
- data/lib/rspec/risky/json_formatter.rb +61 -0
- data/lib/rspec/risky/minitest/output_capture.rb +114 -0
- data/lib/rspec/risky/minitest/plugin.rb +103 -0
- data/lib/rspec/risky/minitest/reporter.rb +46 -0
- data/lib/rspec/risky/probe/expectation_probe.rb +176 -0
- data/lib/rspec/risky/probe/fd_capture.rb +61 -0
- data/lib/rspec/risky/probe/output_probe.rb +167 -0
- data/lib/rspec/risky/probe/output_report.rb +72 -0
- data/lib/rspec/risky/probe/recording_io.rb +108 -0
- data/lib/rspec/risky/rake_tasks.rb +55 -0
- data/lib/rspec/risky/rspec_cli.rb +50 -0
- data/lib/rspec/risky/static_detector.rb +166 -0
- data/lib/rspec/risky/verdict.rb +120 -0
- data/lib/rspec/risky/version.rb +7 -0
- data/lib/rspec/risky.rb +128 -0
- data/sig/rspec/risky.rbs +81 -0
- metadata +125 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
module Probe
|
|
6
|
+
module OutputProbe
|
|
7
|
+
class Report
|
|
8
|
+
attr_reader :streams
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@streams = {
|
|
12
|
+
logger: StreamStats.new(:logger),
|
|
13
|
+
stdout: StreamStats.new(:stdout),
|
|
14
|
+
stderr: StreamStats.new(:stderr)
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(stream_name, data, locations)
|
|
19
|
+
streams.fetch(stream_name).record(data.to_s, first_application_location(locations))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def writes_for(rule_config)
|
|
23
|
+
streams.select do |stream_name, stats|
|
|
24
|
+
captured_stream?(rule_config, stream_name) && stats.written?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def captured_stream?(rule_config, stream_name)
|
|
31
|
+
return rule_config.capture_loggers if stream_name == :logger
|
|
32
|
+
|
|
33
|
+
rule_config.captures?(stream_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def first_application_location(locations)
|
|
37
|
+
locations.find do |location|
|
|
38
|
+
path = location.path
|
|
39
|
+
!path.include?("/rspec/risky/") &&
|
|
40
|
+
!path.include?("/rspec-core-") &&
|
|
41
|
+
!path.include?("/rspec-expectations-") &&
|
|
42
|
+
!path.include?("/rspec-mocks-")
|
|
43
|
+
end || locations.first
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class StreamStats
|
|
48
|
+
attr_reader :stream_name, :write_count, :byte_count, :first_location, :first_sample
|
|
49
|
+
|
|
50
|
+
def initialize(stream_name)
|
|
51
|
+
@stream_name = stream_name
|
|
52
|
+
@write_count = 0
|
|
53
|
+
@byte_count = 0
|
|
54
|
+
@first_location = nil
|
|
55
|
+
@first_sample = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def record(data, location)
|
|
59
|
+
@write_count += 1
|
|
60
|
+
@byte_count += data.bytesize
|
|
61
|
+
@first_location ||= location
|
|
62
|
+
@first_sample ||= data.byteslice(0, 200)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def written?
|
|
66
|
+
write_count.positive?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
module Probe
|
|
6
|
+
module OutputProbe
|
|
7
|
+
class RecordingIO
|
|
8
|
+
def initialize(stream_name, io, report, passthrough:)
|
|
9
|
+
@stream_name = stream_name
|
|
10
|
+
@io = io
|
|
11
|
+
@report = report
|
|
12
|
+
@passthrough = passthrough
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def write(data)
|
|
16
|
+
record(data, caller_locations(1))
|
|
17
|
+
@io.write(data) if @passthrough
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write_nonblock(data, *args)
|
|
21
|
+
record(data, caller_locations(1))
|
|
22
|
+
return data.bytesize unless @passthrough
|
|
23
|
+
|
|
24
|
+
@io.write_nonblock(data, *args)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def <<(data)
|
|
28
|
+
write(data)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def puts(*objects)
|
|
33
|
+
data = objects.empty? ? "\n" : objects.map { |object| "#{format_puts_object(object)}\n" }.join
|
|
34
|
+
record(data, caller_locations(1))
|
|
35
|
+
@io.puts(*objects) if @passthrough
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def print(*objects)
|
|
39
|
+
data = objects.empty? ? $_.to_s : objects.map(&:to_s).join($,)
|
|
40
|
+
data = "#{data}#{$OUTPUT_RECORD_SEPARATOR}" if $OUTPUT_RECORD_SEPARATOR
|
|
41
|
+
record(data, caller_locations(1))
|
|
42
|
+
@io.print(*objects) if @passthrough
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def printf(format_string, *arguments)
|
|
46
|
+
data = format(format_string, *arguments)
|
|
47
|
+
record(data, caller_locations(1))
|
|
48
|
+
@io.printf(format_string, *arguments) if @passthrough
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def flush
|
|
52
|
+
@io.flush if @io.respond_to?(:flush)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sync
|
|
56
|
+
@io.sync if @io.respond_to?(:sync)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sync=(value)
|
|
60
|
+
@io.sync = value if @io.respond_to?(:sync=)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tty?
|
|
64
|
+
@io.tty? if @io.respond_to?(:tty?)
|
|
65
|
+
end
|
|
66
|
+
alias isatty tty?
|
|
67
|
+
|
|
68
|
+
def clone
|
|
69
|
+
@io.clone
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def dup
|
|
73
|
+
@io.dup
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def method_missing(method_name, *args, &block)
|
|
77
|
+
return super unless @io.respond_to?(method_name)
|
|
78
|
+
|
|
79
|
+
@io.public_send(method_name, *args, &block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
83
|
+
@io.respond_to?(method_name, include_private) || super
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def record(data, locations)
|
|
89
|
+
return if OutputProbe.capture_suppressed?
|
|
90
|
+
|
|
91
|
+
@report.record(@stream_name, data, locations)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def format_puts_object(object)
|
|
95
|
+
case object
|
|
96
|
+
when nil
|
|
97
|
+
"nil"
|
|
98
|
+
when Array
|
|
99
|
+
object.map { |item| format_puts_object(item) }.join("\n")
|
|
100
|
+
else
|
|
101
|
+
object.to_s
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rake"
|
|
5
|
+
|
|
6
|
+
require_relative "evaluation"
|
|
7
|
+
require_relative "static_detector"
|
|
8
|
+
|
|
9
|
+
namespace :risky do
|
|
10
|
+
desc "Run the evaluation static detector. Usage: rake risky:static[spec]"
|
|
11
|
+
task :static, [:paths] do |_task, args|
|
|
12
|
+
paths = args[:paths]&.split(",") || ["spec", "test"]
|
|
13
|
+
findings = RSpec::Risky::StaticDetector.scan(paths.select { |path| File.exist?(path) })
|
|
14
|
+
puts JSON.pretty_generate(findings: findings.map(&:to_h))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Compare static findings and runtime JSON. Usage: rake risky:compare[static.json,dynamic.json]"
|
|
18
|
+
task :compare, [:static_json, :dynamic_json] do |_task, args|
|
|
19
|
+
abort "static_json is required" unless args[:static_json]
|
|
20
|
+
abort "dynamic_json is required" unless args[:dynamic_json]
|
|
21
|
+
|
|
22
|
+
static_payload = JSON.parse(File.read(args[:static_json]))
|
|
23
|
+
dynamic_payload = JSON.parse(File.read(args[:dynamic_json]))
|
|
24
|
+
comparison = RSpec::Risky::Evaluation.compare(
|
|
25
|
+
static_findings: static_payload.fetch("findings"),
|
|
26
|
+
dynamic_payload: dynamic_payload
|
|
27
|
+
)
|
|
28
|
+
puts JSON.pretty_generate(comparison)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "Report assertion density and optional mutation correlation. Usage: rake risky:study[dynamic.json,mutation.json]"
|
|
32
|
+
task :study, [:dynamic_json, :mutation_json] do |_task, args|
|
|
33
|
+
abort "dynamic_json is required" unless args[:dynamic_json]
|
|
34
|
+
|
|
35
|
+
payload = JSON.parse(File.read(args[:dynamic_json]))
|
|
36
|
+
mutation_payload = JSON.parse(File.read(args[:mutation_json])) if args[:mutation_json]
|
|
37
|
+
puts JSON.pretty_generate(RSpec::Risky::Evaluation.mutation_study(payload, mutation_payload))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "Create a precision labeling template. Usage: rake risky:label[dynamic.json]"
|
|
41
|
+
task :label, [:dynamic_json] do |_task, args|
|
|
42
|
+
abort "dynamic_json is required" unless args[:dynamic_json]
|
|
43
|
+
|
|
44
|
+
payload = JSON.parse(File.read(args[:dynamic_json]))
|
|
45
|
+
puts JSON.pretty_generate(labels: RSpec::Risky::Evaluation.label_template(payload))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "Calculate precision from labels. Usage: rake risky:precision[labels.json]"
|
|
49
|
+
task :precision, [:labels_json] do |_task, args|
|
|
50
|
+
abort "labels_json is required" unless args[:labels_json]
|
|
51
|
+
|
|
52
|
+
payload = JSON.parse(File.read(args[:labels_json]))
|
|
53
|
+
puts JSON.pretty_generate(RSpec::Risky::Evaluation.precision(payload))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Risky
|
|
7
|
+
module RSpecCli
|
|
8
|
+
module ParserPatch
|
|
9
|
+
def parser(options)
|
|
10
|
+
super.tap do |parser|
|
|
11
|
+
parser.on("--risky-exit-code CODE", Integer, "Override the exit code used when risky examples pass.") do |code|
|
|
12
|
+
options[:risky_exit_code] = code
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module RunnerPatch
|
|
19
|
+
def exit_code(examples_passed = false)
|
|
20
|
+
code = super
|
|
21
|
+
return code unless code.zero? && examples_passed
|
|
22
|
+
return code unless risky_exit_code
|
|
23
|
+
return code unless RSpec::Risky.risky_results.any? { |_example, result| result[:verdicts].any? }
|
|
24
|
+
|
|
25
|
+
risky_exit_code
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def risky_exit_code
|
|
31
|
+
return unless @configuration.respond_to?(:risky_exit_code)
|
|
32
|
+
|
|
33
|
+
@configuration.risky_exit_code
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def install
|
|
39
|
+
return if @installed
|
|
40
|
+
|
|
41
|
+
::RSpec::Core::Configuration.add_setting :risky_exit_code
|
|
42
|
+
::RSpec::Core::Parser.prepend(ParserPatch)
|
|
43
|
+
::RSpec::Core::Runner.prepend(RunnerPatch)
|
|
44
|
+
|
|
45
|
+
@installed = true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module RSpec
|
|
6
|
+
module Risky
|
|
7
|
+
class StaticDetector
|
|
8
|
+
ASSERTION_METHODS = %w[assert expect refute].freeze
|
|
9
|
+
OUTPUT_METHODS = %w[p print printf puts warn].freeze
|
|
10
|
+
TEST_METHODS = %w[it scenario specify test].freeze
|
|
11
|
+
|
|
12
|
+
Finding = Struct.new(:file, :line, :rule, :evidence, keyword_init: true) do
|
|
13
|
+
def to_h
|
|
14
|
+
{
|
|
15
|
+
file: file,
|
|
16
|
+
line: line,
|
|
17
|
+
location: "#{file}:#{line}",
|
|
18
|
+
rule: rule,
|
|
19
|
+
evidence: evidence
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.scan(paths)
|
|
25
|
+
new(paths).scan
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(paths)
|
|
29
|
+
@paths = Array(paths).flat_map { |path| expand_path(path) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scan
|
|
33
|
+
@paths.flat_map { |path| scan_file(path) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def expand_path(path)
|
|
39
|
+
return Dir["#{path}/**/*_spec.rb", "#{path}/**/test_*.rb"] if File.directory?(path)
|
|
40
|
+
|
|
41
|
+
path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scan_file(path)
|
|
45
|
+
ast = Ripper.sexp(File.read(path))
|
|
46
|
+
return [] unless ast
|
|
47
|
+
|
|
48
|
+
test_blocks(ast).flat_map { |block| findings_for(path, block) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_blocks(node)
|
|
52
|
+
return [] unless node.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
blocks = []
|
|
55
|
+
blocks << test_block(node) if node.first == :method_add_block && test_declaration?(node[1])
|
|
56
|
+
node.each { |child| blocks.concat(test_blocks(child)) if child.is_a?(Array) }
|
|
57
|
+
blocks.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_block(node)
|
|
61
|
+
{ body: node[2], line: declaration_line(node[1]) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def test_declaration?(node)
|
|
65
|
+
TEST_METHODS.include?(method_name(node))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def method_name(node)
|
|
69
|
+
return unless node.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
case node.first
|
|
72
|
+
when :command
|
|
73
|
+
token_text(node[1])
|
|
74
|
+
when :method_add_arg
|
|
75
|
+
method_name(node[1])
|
|
76
|
+
when :fcall
|
|
77
|
+
token_text(node[1])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def declaration_line(node)
|
|
82
|
+
token = declaration_token(node)
|
|
83
|
+
token ? token[2].first : 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def declaration_token(node)
|
|
87
|
+
return unless node.is_a?(Array)
|
|
88
|
+
return node[1] if %i[command fcall].include?(node.first)
|
|
89
|
+
|
|
90
|
+
node.each do |child|
|
|
91
|
+
token = declaration_token(child)
|
|
92
|
+
return token if token
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def findings_for(path, block)
|
|
99
|
+
findings = []
|
|
100
|
+
findings << unknown_test(path, block) unless assertion?(block.fetch(:body))
|
|
101
|
+
findings << redundant_print(path, block) if redundant_print?(block.fetch(:body))
|
|
102
|
+
findings.compact
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def assertion?(node)
|
|
106
|
+
method_called?(node) do |name|
|
|
107
|
+
ASSERTION_METHODS.include?(name) ||
|
|
108
|
+
name == "should" ||
|
|
109
|
+
name.start_with?("must_", "wont_", "will_")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def redundant_print?(node)
|
|
114
|
+
method_called?(node) { |name| OUTPUT_METHODS.include?(name) } && !output_matcher?(node)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def output_matcher?(node)
|
|
118
|
+
method_called?(node) { |name| name == "output" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def method_called?(node, &block)
|
|
122
|
+
return false unless node.is_a?(Array)
|
|
123
|
+
|
|
124
|
+
called_method = called_method_name(node)
|
|
125
|
+
return true if called_method && block.call(called_method)
|
|
126
|
+
|
|
127
|
+
node.any? { |child| child.is_a?(Array) && method_called?(child, &block) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def called_method_name(node)
|
|
131
|
+
case node.first
|
|
132
|
+
when :command, :fcall
|
|
133
|
+
token_text(node[1])
|
|
134
|
+
when :command_call, :call
|
|
135
|
+
token_text(node[3])
|
|
136
|
+
when :method_add_arg
|
|
137
|
+
called_method_name(node[1])
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def unknown_test(path, block)
|
|
142
|
+
Finding.new(
|
|
143
|
+
file: path,
|
|
144
|
+
line: block.fetch(:line),
|
|
145
|
+
rule: :unknown_test,
|
|
146
|
+
evidence: { source: "static_ast", reason: "no assertion call found" }
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def redundant_print(path, block)
|
|
151
|
+
Finding.new(
|
|
152
|
+
file: path,
|
|
153
|
+
line: block.fetch(:line),
|
|
154
|
+
rule: :redundant_print,
|
|
155
|
+
evidence: { source: "static_ast", reason: "print call found" }
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def token_text(token)
|
|
160
|
+
return unless token.is_a?(Array)
|
|
161
|
+
|
|
162
|
+
token[1].to_s
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Risky
|
|
5
|
+
class RuleResult
|
|
6
|
+
attr_reader :rule, :severity, :message, :evidence
|
|
7
|
+
|
|
8
|
+
def initialize(rule:, severity:, message:, evidence:)
|
|
9
|
+
@rule = rule
|
|
10
|
+
@severity = severity
|
|
11
|
+
@message = message
|
|
12
|
+
@evidence = evidence
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
{
|
|
17
|
+
rule: rule,
|
|
18
|
+
severity: severity,
|
|
19
|
+
message: message,
|
|
20
|
+
evidence: evidence
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module Verdict
|
|
26
|
+
class << self
|
|
27
|
+
def evaluate(example:, configuration:, expectations:, output:)
|
|
28
|
+
return [] if skip_verdicts?(example)
|
|
29
|
+
|
|
30
|
+
verdicts = []
|
|
31
|
+
verdicts << unknown_test_result(configuration, expectations) if evaluate_unknown_test?(example, configuration)
|
|
32
|
+
verdicts << redundant_print_result(configuration, output) if evaluate_redundant_print?(example, configuration, output)
|
|
33
|
+
verdicts.compact
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def skip_verdicts?(example)
|
|
39
|
+
example.exception || example.pending? || example.skipped?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def evaluate_unknown_test?(example, configuration)
|
|
43
|
+
configuration.rule_enabled?(:unknown_test) && !allowed?(example, :unknown_test)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def evaluate_redundant_print?(example, configuration, output)
|
|
47
|
+
configuration.rule_enabled?(:redundant_print) &&
|
|
48
|
+
output &&
|
|
49
|
+
!allowed?(example, :redundant_print)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def unknown_test_result(configuration, expectations)
|
|
53
|
+
assertions = expectations.expectation_count
|
|
54
|
+
assertions += expectations.mock_expectation_count if configuration.unknown_test.count_mocks
|
|
55
|
+
assertions += expectations.custom_expectation_count
|
|
56
|
+
return unless assertions.zero?
|
|
57
|
+
|
|
58
|
+
RuleResult.new(
|
|
59
|
+
rule: :unknown_test,
|
|
60
|
+
severity: configuration.unknown_test.severity,
|
|
61
|
+
message: "RISKY (unknown_test): no expectations or mock verifications were executed",
|
|
62
|
+
evidence: {
|
|
63
|
+
custom_expectation_count: expectations.custom_expectation_count,
|
|
64
|
+
expectation_count: expectations.expectation_count,
|
|
65
|
+
mock_expectation_count: expectations.mock_expectation_count
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def redundant_print_result(configuration, output)
|
|
71
|
+
writes = output.writes_for(configuration.redundant_print)
|
|
72
|
+
return if writes.empty?
|
|
73
|
+
|
|
74
|
+
RuleResult.new(
|
|
75
|
+
rule: :redundant_print,
|
|
76
|
+
severity: configuration.redundant_print.severity,
|
|
77
|
+
message: "RISKY (redundant_print): example wrote to stdout/stderr",
|
|
78
|
+
evidence: writes.transform_values { |stats| stream_evidence(stats) }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stream_evidence(stats)
|
|
83
|
+
{
|
|
84
|
+
write_count: stats.write_count,
|
|
85
|
+
byte_count: stats.byte_count,
|
|
86
|
+
first_location: format_location(stats.first_location),
|
|
87
|
+
first_sample: stats.first_sample
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def format_location(location)
|
|
92
|
+
return unless location
|
|
93
|
+
|
|
94
|
+
path = location.path
|
|
95
|
+
path = path.delete_prefix("#{Dir.pwd}/")
|
|
96
|
+
"#{path}:#{location.lineno}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def allowed?(example, rule)
|
|
100
|
+
risky_metadata = example.metadata[:risky]
|
|
101
|
+
return false unless risky_metadata
|
|
102
|
+
|
|
103
|
+
allowed_rules =
|
|
104
|
+
case risky_metadata
|
|
105
|
+
when Hash
|
|
106
|
+
Array(risky_metadata[:allow])
|
|
107
|
+
when Array
|
|
108
|
+
risky_metadata
|
|
109
|
+
when Symbol, String
|
|
110
|
+
[risky_metadata]
|
|
111
|
+
else
|
|
112
|
+
[]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
allowed_rules.map(&:to_sym).include?(rule)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/rspec/risky.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
|
|
5
|
+
require_relative "risky/version"
|
|
6
|
+
require_relative "risky/configuration"
|
|
7
|
+
require_relative "risky/probe/expectation_probe"
|
|
8
|
+
require_relative "risky/probe/output_probe"
|
|
9
|
+
require_relative "risky/rspec_cli"
|
|
10
|
+
require_relative "risky/verdict"
|
|
11
|
+
require_relative "risky/formatter"
|
|
12
|
+
require_relative "risky/json_event_formatter"
|
|
13
|
+
require_relative "risky/json_formatter"
|
|
14
|
+
|
|
15
|
+
module RSpec
|
|
16
|
+
module Risky
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
class RiskyExampleError < Error
|
|
20
|
+
attr_reader :verdicts
|
|
21
|
+
|
|
22
|
+
def initialize(verdicts)
|
|
23
|
+
@verdicts = verdicts
|
|
24
|
+
super(verdicts.map(&:message).join("\n"))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
attr_writer :configuration
|
|
30
|
+
|
|
31
|
+
def configuration
|
|
32
|
+
@configuration ||= Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record_expectation(count = 1)
|
|
36
|
+
Probe::ExpectationProbe.record_custom_expectation(count)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configure(rspec_configuration = nil)
|
|
40
|
+
yield configuration if block_given?
|
|
41
|
+
|
|
42
|
+
install!
|
|
43
|
+
integrate!(rspec_configuration || default_rspec_configuration)
|
|
44
|
+
|
|
45
|
+
configuration
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def install!
|
|
49
|
+
return if @installed
|
|
50
|
+
|
|
51
|
+
Probe::ExpectationProbe.install
|
|
52
|
+
Probe::OutputProbe.install
|
|
53
|
+
RSpecCli.install
|
|
54
|
+
|
|
55
|
+
@installed = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run_example(example_procsy)
|
|
59
|
+
example = example_procsy.example
|
|
60
|
+
expectation_state = Probe::ExpectationProbe.start(example)
|
|
61
|
+
output_state = start_output_probe
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
example_procsy.run
|
|
65
|
+
ensure
|
|
66
|
+
output_report = Probe::OutputProbe.finish(output_state)
|
|
67
|
+
expectation_report = Probe::ExpectationProbe.finish(expectation_state)
|
|
68
|
+
expectation_report.custom_expectation_count += configuration.unknown_test.adapter_count(example)
|
|
69
|
+
verdicts = Verdict.evaluate(
|
|
70
|
+
example: example,
|
|
71
|
+
configuration: configuration,
|
|
72
|
+
expectations: expectation_report,
|
|
73
|
+
output: output_report
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
example.metadata[:rspec_risky] = {
|
|
77
|
+
custom_expectation_count: expectation_report.custom_expectation_count,
|
|
78
|
+
expectation_count: expectation_report.expectation_count,
|
|
79
|
+
mock_expectation_count: expectation_report.mock_expectation_count,
|
|
80
|
+
output: output_report,
|
|
81
|
+
verdicts: verdicts
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
fail_verdicts = verdicts.select { |verdict| verdict.severity == :fail }
|
|
86
|
+
raise RiskyExampleError.new(fail_verdicts) if fail_verdicts.any? && !example.exception
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def risky_results
|
|
90
|
+
return [] unless defined?(::RSpec::Core)
|
|
91
|
+
|
|
92
|
+
::RSpec.world.all_examples.filter_map do |example|
|
|
93
|
+
result = example.metadata[:rspec_risky]
|
|
94
|
+
next unless result
|
|
95
|
+
|
|
96
|
+
[example, result]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def default_rspec_configuration
|
|
103
|
+
return unless defined?(::RSpec.configuration)
|
|
104
|
+
|
|
105
|
+
::RSpec.configuration
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def integrate!(rspec_configuration)
|
|
109
|
+
return unless rspec_configuration
|
|
110
|
+
|
|
111
|
+
@integrated_configurations ||= {}
|
|
112
|
+
return if @integrated_configurations[rspec_configuration.object_id]
|
|
113
|
+
|
|
114
|
+
rspec_configuration.around(:example) do |example|
|
|
115
|
+
RSpec::Risky.run_example(example)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@integrated_configurations[rspec_configuration.object_id] = true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def start_output_probe
|
|
122
|
+
return unless configuration.rule_enabled?(:redundant_print)
|
|
123
|
+
|
|
124
|
+
Probe::OutputProbe.start(configuration.redundant_print)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|