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.
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Risky
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -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