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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f70f4709b98df150e006e7fd0240e4b33841e6806889314c3f3c8b3070541479
4
+ data.tar.gz: 17e15803e40837a3935bd78ea762bf0c67123bf080e731d26a2349e7ac1cc903
5
+ SHA512:
6
+ metadata.gz: f996c9021c462dae5e6ba97fd76e31efd1db6bc73718f8ce67bc221e0bb40590d30dd13d711f8b92f5e6d21a796dc08dd8249da4bd7bec6575c5a0550e2836eb
7
+ data.tar.gz: 95cda2c88b42ff0ab60bc1cfb1afda22f76644c7782eb1d2ef3e52efc9ed488cf95816ca676b63489bbf260dafcc3fc7af2f162b566aabb4dba81fef8280abcf
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # rspec-risky
2
+
3
+ Runtime risky-test detection for RSpec.
4
+
5
+ `rspec-risky` reports examples that pass without executing any expectations, and examples that write unexpected output to `$stdout` or `$stderr`.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "rspec-risky"
13
+ ```
14
+
15
+ Then require and configure it from your spec helper:
16
+
17
+ ```ruby
18
+ require "rspec/risky"
19
+
20
+ RSpec.configure do |config|
21
+ RSpec::Risky.configure(config) do |risky|
22
+ risky.rules = %i[unknown_test redundant_print]
23
+ risky.unknown_test.severity = :risky
24
+ risky.unknown_test.count_mocks = true
25
+ risky.unknown_test.adapters << ->(_example) { 0 }
26
+ risky.redundant_print.severity = :risky
27
+ risky.redundant_print.capture = :both
28
+ risky.redundant_print.allow_warn = false
29
+ risky.redundant_print.strict = false
30
+ risky.redundant_print.capture_loggers = false
31
+ end
32
+ end
33
+ ```
34
+
35
+ You can also require `rspec/risky/autorun` to install the default configuration.
36
+
37
+ Use the formatter when you want risky examples to appear as `R` in progress output:
38
+
39
+ ```sh
40
+ bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::Formatter
41
+ ```
42
+
43
+ To use `--risky-exit-code`, use the wrapper executable or preload the gem before RSpec parses options:
44
+
45
+ ```sh
46
+ ruby -S rspec-risky --risky-exit-code 7 --format RSpec::Risky::Formatter
47
+ ruby -rrspec/risky/autorun -S rspec --risky-exit-code 7 --format RSpec::Risky::Formatter
48
+ ```
49
+
50
+ Use `RSpec::Risky::JsonFormatter` to emit machine-readable verdicts:
51
+
52
+ ```sh
53
+ bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::JsonFormatter
54
+ ```
55
+
56
+ Use `RSpec::Risky::JsonEventFormatter` to emit newline-delimited verdict events:
57
+
58
+ ```sh
59
+ bundle exec rspec --require rspec/risky/autorun --format RSpec::Risky::JsonEventFormatter
60
+ ```
61
+
62
+ Intentional smoke tests can opt out per example:
63
+
64
+ ```ruby
65
+ it "boots", risky: { allow: [:unknown_test] } do
66
+ App.boot!
67
+ end
68
+ ```
69
+
70
+ Output that is explicitly asserted with RSpec's `output` matcher is not reported:
71
+
72
+ ```ruby
73
+ it { expect { task.run }.to output(/done/).to_stdout }
74
+ ```
75
+
76
+ Custom expectation libraries can call `RSpec::Risky.record_expectation` when they run a check.
77
+
78
+ ## Minitest
79
+
80
+ Require the plugin from a Minitest suite:
81
+
82
+ ```ruby
83
+ require "minitest/autorun"
84
+ require "minitest/risky_plugin"
85
+ ```
86
+
87
+ Use `--risky-fail` to fail the process for risky Minitest results. Use `--risky-allow-warn` to ignore `Kernel#warn` output.
88
+
89
+ ## Evaluation Tasks
90
+
91
+ The gem includes local evaluation helpers:
92
+
93
+ ```sh
94
+ bundle exec rake risky:static[spec]
95
+ bundle exec rake risky:compare[static.json,dynamic.json]
96
+ bundle exec rake risky:label[dynamic.json]
97
+ bundle exec rake risky:precision[labels.json]
98
+ bundle exec rake risky:study[dynamic.json,mutation.json]
99
+ ```
100
+
101
+ ## License
102
+
103
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/exe/rspec-risky ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rspec/risky/autorun"
5
+ require "rspec/core"
6
+
7
+ exit RSpec::Core::Runner.run(ARGV, $stderr, $stdout)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require "rspec/risky/minitest/plugin"
5
+
6
+ module Minitest
7
+ register_plugin :risky
8
+
9
+ def self.plugin_risky_options(opts, options)
10
+ options[:risky] = {
11
+ allow_warn: false,
12
+ fail: false,
13
+ redundant_print: true,
14
+ unknown_test: true
15
+ }
16
+
17
+ opts.on("--risky-fail", "Fail the run when risky tests are detected.") do
18
+ options[:risky][:fail] = true
19
+ end
20
+
21
+ opts.on("--risky-no-output", "Disable stdout/stderr risky detection.") do
22
+ options[:risky][:redundant_print] = false
23
+ end
24
+
25
+ opts.on("--risky-allow-warn", "Do not flag Kernel#warn output.") do
26
+ options[:risky][:allow_warn] = true
27
+ end
28
+ end
29
+
30
+ def self.plugin_risky_init(options)
31
+ RSpec::Risky::Minitest::Plugin.install(options.fetch(:risky))
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../risky"
4
+
5
+ RSpec::Risky.configure(RSpec.configuration)
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Risky
5
+ class Configuration
6
+ VALID_RULES = %i[unknown_test redundant_print].freeze
7
+
8
+ attr_reader :unknown_test, :redundant_print
9
+
10
+ def initialize
11
+ @rules = VALID_RULES.dup
12
+ @unknown_test = UnknownTestConfig.new
13
+ @redundant_print = RedundantPrintConfig.new
14
+ end
15
+
16
+ def rules
17
+ @rules.dup
18
+ end
19
+
20
+ def rules=(rules)
21
+ selected_rules = Array(rules).map(&:to_sym)
22
+ unknown_rules = selected_rules - VALID_RULES
23
+ raise ArgumentError, "unknown risky rules: #{unknown_rules.join(", ")}" if unknown_rules.any?
24
+
25
+ @rules = selected_rules
26
+ end
27
+
28
+ def rule_enabled?(rule)
29
+ @rules.include?(rule.to_sym)
30
+ end
31
+ end
32
+
33
+ class RuleConfig
34
+ VALID_SEVERITIES = %i[risky fail].freeze
35
+
36
+ attr_reader :severity
37
+
38
+ def initialize
39
+ @severity = :risky
40
+ end
41
+
42
+ def severity=(severity)
43
+ value = severity.to_sym
44
+ unless VALID_SEVERITIES.include?(value)
45
+ raise ArgumentError, "severity must be one of: #{VALID_SEVERITIES.join(", ")}"
46
+ end
47
+
48
+ @severity = value
49
+ end
50
+ end
51
+
52
+ class UnknownTestConfig < RuleConfig
53
+ attr_accessor :adapters, :count_mocks
54
+
55
+ def initialize
56
+ super
57
+ @adapters = []
58
+ @count_mocks = true
59
+ end
60
+
61
+ def adapter_count(example)
62
+ adapters.sum do |adapter|
63
+ Integer(adapter.call(example))
64
+ rescue StandardError
65
+ 0
66
+ end
67
+ end
68
+ end
69
+
70
+ class RedundantPrintConfig < RuleConfig
71
+ VALID_CAPTURE = %i[stdout stderr both].freeze
72
+
73
+ attr_accessor :allow_warn, :capture_loggers, :logger_ignore_paths, :passthrough, :strict
74
+ attr_reader :capture
75
+
76
+ def initialize
77
+ super
78
+ @capture = :both
79
+ @allow_warn = false
80
+ @capture_loggers = false
81
+ @logger_ignore_paths = ["log/test.log"]
82
+ @passthrough = true
83
+ @strict = false
84
+ end
85
+
86
+ def capture=(capture)
87
+ value = capture.to_sym
88
+ unless VALID_CAPTURE.include?(value)
89
+ raise ArgumentError, "capture must be one of: #{VALID_CAPTURE.join(", ")}"
90
+ end
91
+
92
+ @capture = value
93
+ end
94
+
95
+ def captures?(stream_name)
96
+ capture == :both || capture == stream_name.to_sym
97
+ end
98
+
99
+ def captures_logger?(path)
100
+ capture_loggers && !ignored_logger_path?(path)
101
+ end
102
+
103
+ private
104
+
105
+ def ignored_logger_path?(path)
106
+ return false unless path
107
+
108
+ logger_ignore_paths.any? { |ignored_path| path.end_with?(ignored_path) }
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RSpec
6
+ module Risky
7
+ module Evaluation
8
+ class << self
9
+ def compare(static_findings:, dynamic_payload:)
10
+ static = static_findings.map { |finding| normalize_static(finding) }
11
+ dynamic = dynamic_payload.fetch("examples", []).flat_map { |example| normalize_dynamic(example) }
12
+
13
+ {
14
+ dynamic_only: dynamic - static,
15
+ dynamic_total: dynamic.length,
16
+ overlap: dynamic & static,
17
+ static_only: static - dynamic,
18
+ static_total: static.length
19
+ }
20
+ end
21
+
22
+ def assertion_density(dynamic_payload)
23
+ counts = dynamic_payload.fetch("examples", []).map do |example|
24
+ example.fetch("expectation_count", 0) +
25
+ example.fetch("mock_expectation_count", 0) +
26
+ example.fetch("custom_expectation_count", 0)
27
+ end
28
+ return {} if counts.empty?
29
+
30
+ sorted = counts.sort
31
+ {
32
+ average: counts.sum.fdiv(counts.length),
33
+ max: sorted.last,
34
+ min: sorted.first,
35
+ p50: percentile(sorted, 0.50),
36
+ p90: percentile(sorted, 0.90)
37
+ }
38
+ end
39
+
40
+ def label_template(dynamic_payload)
41
+ dynamic_payload.fetch("examples", []).flat_map do |example|
42
+ example.fetch("verdicts", []).map do |verdict|
43
+ {
44
+ location: example.fetch("location"),
45
+ rule: verdict.fetch("rule").to_s,
46
+ label: nil,
47
+ allowed_labels: %w[intentional_smoke true_missing_oracle false_positive],
48
+ description: example["description"]
49
+ }
50
+ end
51
+ end
52
+ end
53
+
54
+ def mutation_study(dynamic_payload, mutation_payload = nil)
55
+ density = assertion_density(dynamic_payload)
56
+ scores = mutation_payload ? mutation_scores(dynamic_payload, mutation_payload) : []
57
+ return { assertion_density: density, mutation_correlation: nil, mutation_samples: 0 } if scores.empty?
58
+
59
+ {
60
+ assertion_density: density,
61
+ mutation_correlation: pearson(scores.map(&:first), scores.map(&:last)),
62
+ mutation_samples: scores.length
63
+ }
64
+ end
65
+
66
+ def precision(labels_payload)
67
+ labels = labels_payload.fetch("labels", labels_payload)
68
+ total = labels.length
69
+ return { total: 0, precision: nil } if total.zero?
70
+
71
+ false_positives = labels.count { |label| label.fetch("label", label[:label]) == "false_positive" }
72
+ true_positives = total - false_positives
73
+ {
74
+ false_positives: false_positives,
75
+ precision: true_positives.fdiv(total),
76
+ total: total,
77
+ true_positives: true_positives
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ def normalize_static(finding)
84
+ hash = finding.respond_to?(:to_h) ? finding.to_h : finding
85
+ { location: hash.fetch(:location, hash["location"]), rule: hash.fetch(:rule, hash["rule"]).to_s }
86
+ end
87
+
88
+ def normalize_dynamic(example)
89
+ example.fetch("verdicts", []).map do |verdict|
90
+ { location: example.fetch("location"), rule: verdict.fetch("rule").to_s }
91
+ end
92
+ end
93
+
94
+ def percentile(sorted, quantile)
95
+ sorted[((sorted.length - 1) * quantile).ceil]
96
+ end
97
+
98
+ def mutation_scores(dynamic_payload, mutation_payload)
99
+ by_location = mutation_payload.fetch("examples", mutation_payload).to_h do |entry|
100
+ [entry.fetch("location"), entry.fetch("mutation_score").to_f]
101
+ end
102
+
103
+ dynamic_payload.fetch("examples", []).filter_map do |example|
104
+ score = by_location[example.fetch("location")]
105
+ next unless score
106
+
107
+ [
108
+ example.fetch("expectation_count", 0) +
109
+ example.fetch("mock_expectation_count", 0) +
110
+ example.fetch("custom_expectation_count", 0),
111
+ score
112
+ ]
113
+ end
114
+ end
115
+
116
+ def pearson(xs, ys)
117
+ x_mean = xs.sum.fdiv(xs.length)
118
+ y_mean = ys.sum.fdiv(ys.length)
119
+ numerator = xs.zip(ys).sum { |x, y| (x - x_mean) * (y - y_mean) }
120
+ x_denominator = Math.sqrt(xs.sum { |x| (x - x_mean)**2 })
121
+ y_denominator = Math.sqrt(ys.sum { |y| (y - y_mean)**2 })
122
+ return nil if x_denominator.zero? || y_denominator.zero?
123
+
124
+ numerator / (x_denominator * y_denominator)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "rspec/core/formatters/base_text_formatter"
5
+ require "rspec/core/formatters/console_codes"
6
+
7
+ module RSpec
8
+ module Risky
9
+ class Formatter < ::RSpec::Core::Formatters::BaseTextFormatter
10
+ ::RSpec::Core::Formatters.register self,
11
+ :example_passed,
12
+ :example_pending,
13
+ :example_failed,
14
+ :start_dump,
15
+ :dump_summary
16
+
17
+ def initialize(output)
18
+ super
19
+ @examples = []
20
+ end
21
+
22
+ def example_passed(notification)
23
+ @examples << notification.example
24
+ marker = risky?(notification.example) ? "R" : "."
25
+ color = risky?(notification.example) ? :pending : :success
26
+ output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap(marker, color)
27
+ end
28
+
29
+ def example_pending(notification)
30
+ @examples << notification.example
31
+ output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap("*", :pending)
32
+ end
33
+
34
+ def example_failed(notification)
35
+ @examples << notification.example
36
+ output.print ::RSpec::Core::Formatters::ConsoleCodes.wrap("F", :failure)
37
+ end
38
+
39
+ def start_dump(_notification)
40
+ output.puts
41
+ end
42
+
43
+ def dump_summary(summary)
44
+ dump_risky_examples
45
+ dump_assertion_density
46
+ super
47
+ end
48
+
49
+ private
50
+
51
+ def risky?(example)
52
+ risky_verdicts(example).any?
53
+ end
54
+
55
+ def risky_verdicts(example)
56
+ example.metadata.dig(:rspec_risky, :verdicts) || []
57
+ end
58
+
59
+ def dump_risky_examples
60
+ risky_examples = @examples.select { |example| risky?(example) }
61
+ return if risky_examples.empty?
62
+
63
+ output.puts
64
+ output.puts "Risky examples:"
65
+ risky_examples.each do |example|
66
+ risky_verdicts(example).each do |verdict|
67
+ output.puts " #{example.location} #{verdict.message}"
68
+ output.puts " #{format_evidence(verdict)}"
69
+ end
70
+ end
71
+ output.puts "Risky summary: #{format_rule_counts(risky_examples)}"
72
+ end
73
+
74
+ def dump_assertion_density
75
+ counts = @examples.filter_map do |example|
76
+ result = example.metadata[:rspec_risky]
77
+ next unless result
78
+
79
+ result[:expectation_count] + result[:mock_expectation_count]
80
+ end
81
+ return if counts.empty?
82
+
83
+ sorted = counts.sort
84
+ average = counts.sum.fdiv(counts.length)
85
+
86
+ output.puts
87
+ output.puts format(
88
+ "Assertion density: min=%<min>d p50=%<p50>d p90=%<p90>d max=%<max>d avg=%<avg>.2f",
89
+ min: sorted.first,
90
+ p50: percentile(sorted, 0.50),
91
+ p90: percentile(sorted, 0.90),
92
+ max: sorted.last,
93
+ avg: average
94
+ )
95
+ end
96
+
97
+ def percentile(sorted, quantile)
98
+ sorted[((sorted.length - 1) * quantile).ceil]
99
+ end
100
+
101
+ def format_rule_counts(examples)
102
+ counts = Hash.new(0)
103
+ examples.each do |example|
104
+ risky_verdicts(example).each { |verdict| counts[verdict.rule] += 1 }
105
+ end
106
+
107
+ counts.sort_by { |rule, _count| rule.to_s }.map { |rule, count| "#{rule}=#{count}" }.join(", ")
108
+ end
109
+
110
+ def format_evidence(verdict)
111
+ case verdict.rule
112
+ when :unknown_test
113
+ "expectations=#{verdict.evidence.fetch(:expectation_count)}, " \
114
+ "mocks=#{verdict.evidence.fetch(:mock_expectation_count)}, " \
115
+ "custom=#{verdict.evidence.fetch(:custom_expectation_count)}"
116
+ when :redundant_print
117
+ verdict.evidence.map do |stream_name, evidence|
118
+ location = evidence[:first_location] || "unknown"
119
+ sample = evidence[:first_sample].to_s.inspect
120
+ "#{stream_name} #{evidence[:write_count]} writes, #{evidence[:byte_count]} bytes; first at #{location} #{sample}"
121
+ end.join("; ")
122
+ else
123
+ verdict.evidence.inspect
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rspec/core"
5
+
6
+ module RSpec
7
+ module Risky
8
+ class JsonEventFormatter
9
+ ::RSpec::Core::Formatters.register self, :example_finished
10
+
11
+ def initialize(output)
12
+ @output = output
13
+ end
14
+
15
+ def example_finished(notification)
16
+ result = notification.example.metadata[:rspec_risky]
17
+ return unless result
18
+
19
+ result[:verdicts].each do |verdict|
20
+ @output.write(JSON.generate(event_payload(notification.example, result, verdict)))
21
+ @output.write("\n")
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def event_payload(example, result, verdict)
28
+ {
29
+ event: "rspec_risky.verdict",
30
+ example_id: example.id,
31
+ description: example.full_description,
32
+ location: example.location,
33
+ expectation_count: result[:expectation_count],
34
+ mock_expectation_count: result[:mock_expectation_count],
35
+ custom_expectation_count: result[:custom_expectation_count],
36
+ verdict: verdict.to_h
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rspec/core"
5
+
6
+ module RSpec
7
+ module Risky
8
+ class JsonFormatter
9
+ ::RSpec::Core::Formatters.register self, :dump_summary
10
+
11
+ def initialize(output)
12
+ @output = output
13
+ end
14
+
15
+ def dump_summary(summary)
16
+ @output.write(JSON.pretty_generate(payload(summary)))
17
+ @output.write("\n")
18
+ end
19
+
20
+ private
21
+
22
+ def payload(summary)
23
+ risky_examples = RSpec::Risky.risky_results.select { |_example, result| result[:verdicts].any? }
24
+
25
+ {
26
+ summary: summary_payload(summary, risky_examples),
27
+ examples: risky_examples.map { |example, result| example_payload(example, result) }
28
+ }
29
+ end
30
+
31
+ def summary_payload(summary, risky_examples)
32
+ {
33
+ duration: summary.duration,
34
+ example_count: summary.example_count,
35
+ failure_count: summary.failure_count,
36
+ pending_count: summary.pending_count,
37
+ risky_count: risky_examples.length,
38
+ risky_rules: rule_counts(risky_examples)
39
+ }
40
+ end
41
+
42
+ def example_payload(example, result)
43
+ {
44
+ id: example.id,
45
+ description: example.full_description,
46
+ location: example.location,
47
+ custom_expectation_count: result[:custom_expectation_count],
48
+ expectation_count: result[:expectation_count],
49
+ mock_expectation_count: result[:mock_expectation_count],
50
+ verdicts: result[:verdicts].map(&:to_h)
51
+ }
52
+ end
53
+
54
+ def rule_counts(risky_examples)
55
+ risky_examples.each_with_object(Hash.new(0)) do |(_example, result), counts|
56
+ result[:verdicts].each { |verdict| counts[verdict.rule] += 1 }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end