mutaterb 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 +50 -0
- data/exe/mutaterb +6 -0
- data/lib/mutaterb/cli.rb +74 -0
- data/lib/mutaterb/config.rb +112 -0
- data/lib/mutaterb/errors.rb +9 -0
- data/lib/mutaterb/flag_parser.rb +57 -0
- data/lib/mutaterb/mutant.rb +62 -0
- data/lib/mutaterb/mutation_operators/arithmetic_comparison_operator.rb +22 -0
- data/lib/mutaterb/mutation_operators/base_operator.rb +67 -0
- data/lib/mutaterb/mutation_operators/boolean_literal_operator.rb +18 -0
- data/lib/mutaterb/mutation_operators/conditional_boundary_operator.rb +21 -0
- data/lib/mutaterb/mutation_operators/nil_literal_operator.rb +24 -0
- data/lib/mutaterb/mutation_run.rb +57 -0
- data/lib/mutaterb/mutator.rb +148 -0
- data/lib/mutaterb/project_detector.rb +37 -0
- data/lib/mutaterb/reporter.rb +97 -0
- data/lib/mutaterb/test_case.rb +28 -0
- data/lib/mutaterb/test_runner.rb +116 -0
- data/lib/mutaterb/test_suite.rb +44 -0
- data/lib/mutaterb/version.rb +5 -0
- data/lib/mutaterb.rb +23 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e60dd9f48be985c4852b318ee38410f6ce76cb924604c9d37f1f64004e2c27f6
|
|
4
|
+
data.tar.gz: 6ef1b8387ed290681db0ab7480021fe2536782cf57478834d56f1331b3676ecc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1f168ec32386423f8c2beb49856791fed45ce11c80f42d3d7284d1782577b9c55e7f3344b13360ecbcc2b1bbf22932e919d1b79d5af05529fc40d353445581ea
|
|
7
|
+
data.tar.gz: bf83a88519717178e2863c9ec85ef30acaeddb0dac5433ee0b467c4d2d7aa2f14d38ed657173903f74045a84446cb8e465002d7895d0ca69344cd780f72784ca
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MarceloM47
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# MutateRB
|
|
2
|
+
|
|
3
|
+
A mutation testing tool for Ruby and Ruby on Rails projects: it modifies code covered
|
|
4
|
+
by your RSpec tests and checks whether the test suite catches the change. If a mutated
|
|
5
|
+
test does not fail, that test is identified as weak.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install mutaterb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "mutaterb", group: :development
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Run the command from your project root (where your `Gemfile` is):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
mutaterb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Without flags, it automatically detects whether the project is pure Ruby or Rails,
|
|
28
|
+
locates specs in `spec/`, applies mutations to covered code, and displays a summary:
|
|
29
|
+
how many mutations were "killed" (caught by a test) and how many "survived" (no test
|
|
30
|
+
caught them — weak tests), with file, line, and related test(s) for each survivor.
|
|
31
|
+
|
|
32
|
+
### Main flags
|
|
33
|
+
|
|
34
|
+
| Flag | Description |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `--dir PATH` | Target folder to analyze |
|
|
37
|
+
| `--include PATHS` | Restrict analysis to these paths (comma-separated) |
|
|
38
|
+
| `--exclude PATHS` | Exclude these paths (comma-separated) |
|
|
39
|
+
| `--strictness LEVEL` | `low`, `default`, or `high` |
|
|
40
|
+
| `--mutation-types TYPES` | Mutation types to apply, comma-separated |
|
|
41
|
+
| `--exit-zero` | Do not fail (exit 0) even if "survived" mutations exist |
|
|
42
|
+
| `--json-output PATH` | Export the results summary to a JSON file |
|
|
43
|
+
| `--config PATH` | Use a config file other than `.mutaterb.yml` |
|
|
44
|
+
|
|
45
|
+
You can also set these options in a `.mutaterb.yml` file at the project root; CLI
|
|
46
|
+
flags take precedence over the file when both define the same option.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/exe/mutaterb
ADDED
data/lib/mutaterb/cli.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Entry point: parses flags, resolves Config, runs the mutation analysis
|
|
5
|
+
# and returns the process exit code (contracts/cli.md).
|
|
6
|
+
class CLI
|
|
7
|
+
EXIT_OPERATIONAL_ERROR = 2
|
|
8
|
+
EXIT_INTERRUPTED = 130
|
|
9
|
+
|
|
10
|
+
def self.run(argv)
|
|
11
|
+
new.run(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(argv)
|
|
15
|
+
config = resolve_config(argv)
|
|
16
|
+
execute(config)
|
|
17
|
+
rescue ConfigError => e
|
|
18
|
+
warn "mutaterb: #{e.message}"
|
|
19
|
+
EXIT_OPERATIONAL_ERROR
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
# Constitution Principle V: no error may go uncaught.
|
|
22
|
+
# Any unforeseen failure (project detection, baseline run, etc.) is
|
|
23
|
+
# reported in a controlled manner instead of propagating a raw backtrace
|
|
24
|
+
# (contracts/cli.md: exit 2 = operational error, SC-005).
|
|
25
|
+
warn "mutaterb: unexpected error: #{e.message}"
|
|
26
|
+
EXIT_OPERATIONAL_ERROR
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolve_config(argv)
|
|
32
|
+
flags = FlagParser.parse(argv)
|
|
33
|
+
base = Config.load_file(flags.fetch(:config_path, Config::DEFAULT_FILE_NAME))
|
|
34
|
+
base.merge_flags(flags.except(:config_path))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def execute(config)
|
|
38
|
+
interrupted = false
|
|
39
|
+
Signal.trap("INT") { interrupted = true }
|
|
40
|
+
|
|
41
|
+
detection = ProjectDetector.new(config).detect
|
|
42
|
+
if detection.spec_files.empty?
|
|
43
|
+
warn "mutaterb: no tests found in #{config.target_dir}"
|
|
44
|
+
return EXIT_OPERATIONAL_ERROR
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test_suite = build_test_suite(config, detection)
|
|
48
|
+
run = MutationRun.new(config: config, test_suite: test_suite)
|
|
49
|
+
run_mutations(run, test_suite, config) { interrupted }
|
|
50
|
+
|
|
51
|
+
run.finished_at = Time.now
|
|
52
|
+
run.interrupted = interrupted
|
|
53
|
+
Reporter.new(run).report
|
|
54
|
+
|
|
55
|
+
return EXIT_INTERRUPTED if interrupted
|
|
56
|
+
|
|
57
|
+
run.exit_code
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_test_suite(config, detection)
|
|
61
|
+
test_runner = TestRunner.new(config: config, project_type: detection.project_type)
|
|
62
|
+
baseline_examples = test_runner.run_baseline(detection.spec_files)
|
|
63
|
+
TestSuite.from_baseline(project_type: detection.project_type, baseline_examples: baseline_examples)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_mutations(run, test_suite, config)
|
|
67
|
+
mutator = Mutator.new(config: config, test_suite: test_suite)
|
|
68
|
+
mutator.each_mutant do |mutant|
|
|
69
|
+
run.add_mutant(mutant)
|
|
70
|
+
break if yield
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module MutateRB
|
|
6
|
+
# Resolved options for a run, merging .mutaterb.yml and CLI flags
|
|
7
|
+
# (flags win, FR-009). See contracts/config-schema.md for the file format.
|
|
8
|
+
class Config
|
|
9
|
+
ALL_MUTATION_TYPES = %i[conditional_boundary boolean_literal nil_literal arithmetic_comparison].freeze
|
|
10
|
+
VALID_STRICTNESS = %i[low default high].freeze
|
|
11
|
+
DEFAULT_FILE_NAME = ".mutaterb.yml"
|
|
12
|
+
|
|
13
|
+
attr_accessor :target_dir, :include_paths, :exclude_paths, :strictness,
|
|
14
|
+
:mutation_types, :exit_on_survivors, :json_output_path
|
|
15
|
+
|
|
16
|
+
def initialize(target_dir: ".", include_paths: [], exclude_paths: [],
|
|
17
|
+
strictness: :default, mutation_types: ALL_MUTATION_TYPES.dup,
|
|
18
|
+
exit_on_survivors: true, json_output_path: nil)
|
|
19
|
+
@target_dir = target_dir
|
|
20
|
+
@include_paths = include_paths
|
|
21
|
+
@exclude_paths = exclude_paths
|
|
22
|
+
@strictness = strictness
|
|
23
|
+
@mutation_types = mutation_types
|
|
24
|
+
@exit_on_survivors = exit_on_survivors
|
|
25
|
+
@json_output_path = json_output_path
|
|
26
|
+
validate!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.default
|
|
30
|
+
new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Loads .mutaterb.yml (or the path given by --config) if present, validates
|
|
34
|
+
# it, and returns a Config with the file's values applied on top of the
|
|
35
|
+
# defaults (FR-007). Returns Config.default if no file is present.
|
|
36
|
+
def self.load_file(path = DEFAULT_FILE_NAME)
|
|
37
|
+
return default unless File.exist?(path)
|
|
38
|
+
|
|
39
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: false)
|
|
40
|
+
raise ConfigError, "#{path} must contain a YAML mapping" unless raw.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
new(**attributes_from_yaml(raw))
|
|
43
|
+
rescue Psych::SyntaxError => e
|
|
44
|
+
raise ConfigError, "#{path} is not valid YAML: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.attributes_from_yaml(raw)
|
|
48
|
+
known_keys = %w[target_dir include_paths exclude_paths strictness mutation_types
|
|
49
|
+
exit_on_survivors json_output_path]
|
|
50
|
+
raw.each_key do |key|
|
|
51
|
+
warn "mutaterb: ignoring unknown config key #{key.inspect}" unless known_keys.include?(key)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
target_dir: raw.fetch("target_dir", "."),
|
|
56
|
+
include_paths: Array(raw["include_paths"]),
|
|
57
|
+
exclude_paths: Array(raw["exclude_paths"]),
|
|
58
|
+
strictness: raw.key?("strictness") ? symbolize(raw["strictness"], "strictness") : :default,
|
|
59
|
+
mutation_types: if raw.key?("mutation_types")
|
|
60
|
+
Array(raw["mutation_types"]).map do |t|
|
|
61
|
+
symbolize(t, "mutation_types")
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
ALL_MUTATION_TYPES.dup
|
|
65
|
+
end,
|
|
66
|
+
exit_on_survivors: raw.fetch("exit_on_survivors", true),
|
|
67
|
+
json_output_path: raw["json_output_path"]
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
private_class_method :attributes_from_yaml
|
|
71
|
+
|
|
72
|
+
def self.symbolize(value, field)
|
|
73
|
+
raise ConfigError, "#{field} must be a string" unless value.is_a?(String)
|
|
74
|
+
|
|
75
|
+
value.to_sym
|
|
76
|
+
end
|
|
77
|
+
private_class_method :symbolize
|
|
78
|
+
|
|
79
|
+
# Applies CLI flag overrides on top of this config, field by field
|
|
80
|
+
# (FR-009: a flag always wins over the config file).
|
|
81
|
+
def merge_flags(flags)
|
|
82
|
+
merged = dup
|
|
83
|
+
flags.each do |key, value|
|
|
84
|
+
next if value.nil?
|
|
85
|
+
|
|
86
|
+
merged.public_send("#{key}=", value)
|
|
87
|
+
end
|
|
88
|
+
merged.validate!
|
|
89
|
+
merged
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate!
|
|
93
|
+
raise ConfigError, "target_dir must be a String" unless target_dir.is_a?(String)
|
|
94
|
+
raise ConfigError, "target_dir #{target_dir.inspect} does not exist" unless Dir.exist?(target_dir)
|
|
95
|
+
raise ConfigError, "include_paths must be an Array" unless include_paths.is_a?(Array)
|
|
96
|
+
raise ConfigError, "exclude_paths must be an Array" unless exclude_paths.is_a?(Array)
|
|
97
|
+
unless VALID_STRICTNESS.include?(strictness)
|
|
98
|
+
raise ConfigError, "strictness must be one of #{VALID_STRICTNESS.join(', ')}, got #{strictness.inspect}"
|
|
99
|
+
end
|
|
100
|
+
raise ConfigError, "mutation_types must be an Array" unless mutation_types.is_a?(Array)
|
|
101
|
+
|
|
102
|
+
unknown = mutation_types - ALL_MUTATION_TYPES
|
|
103
|
+
raise ConfigError, "unknown mutation_types: #{unknown.join(', ')}" unless unknown.empty?
|
|
104
|
+
raise ConfigError, "exit_on_survivors must be true or false" unless [true, false].include?(exit_on_survivors)
|
|
105
|
+
|
|
106
|
+
return unless json_output_path
|
|
107
|
+
|
|
108
|
+
parent = File.dirname(File.expand_path(json_output_path))
|
|
109
|
+
raise ConfigError, "directory for json_output_path does not exist: #{parent}" unless Dir.exist?(parent)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Raised when the config file or CLI flags fail validation (FR-011).
|
|
5
|
+
class ConfigError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a mutation cannot be applied or reverted safely.
|
|
8
|
+
class MutationError < StandardError; end
|
|
9
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module MutateRB
|
|
6
|
+
# Parses CLI flags into the hash consumed by Config#merge_flags (FR-008,
|
|
7
|
+
# contracts/cli.md). Only flags actually passed appear in the result, so
|
|
8
|
+
# CLI flags win over the config file field by field (FR-009).
|
|
9
|
+
class FlagParser
|
|
10
|
+
def self.parse(argv)
|
|
11
|
+
new.parse(argv)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(argv)
|
|
15
|
+
flags = { config_path: Config::DEFAULT_FILE_NAME }
|
|
16
|
+
|
|
17
|
+
parser = build_parser(flags)
|
|
18
|
+
parser.parse!(argv.dup)
|
|
19
|
+
flags
|
|
20
|
+
rescue OptionParser::ParseError => e
|
|
21
|
+
raise ConfigError, e.message
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_parser(flags)
|
|
27
|
+
OptionParser.new do |opts|
|
|
28
|
+
opts.banner = "Usage: mutaterb [flags]"
|
|
29
|
+
|
|
30
|
+
opts.on("--dir PATH", "Target folder to analyze") { |v| flags[:target_dir] = v }
|
|
31
|
+
opts.on("--include PATHS", "Subdirectories/files to include, comma-separated") do |v|
|
|
32
|
+
flags[:include_paths] = v.split(",")
|
|
33
|
+
end
|
|
34
|
+
opts.on("--exclude PATHS", "Subdirectories/files to exclude, comma-separated") do |v|
|
|
35
|
+
flags[:exclude_paths] = v.split(",")
|
|
36
|
+
end
|
|
37
|
+
opts.on("--strictness LEVEL", "low|default|high") { |v| flags[:strictness] = v.to_sym }
|
|
38
|
+
opts.on("--mutation-types TYPES", "Mutation types to apply, comma-separated") do |v|
|
|
39
|
+
flags[:mutation_types] = v.split(",").map(&:to_sym)
|
|
40
|
+
end
|
|
41
|
+
opts.on("--exit-zero", "Do not fail even if survived mutations exist") do
|
|
42
|
+
flags[:exit_on_survivors] = false
|
|
43
|
+
end
|
|
44
|
+
opts.on("--json-output PATH", "Export summary to a JSON file") do |v|
|
|
45
|
+
flags[:json_output_path] = v
|
|
46
|
+
end
|
|
47
|
+
opts.on("--config PATH", "Use a config file other than .mutaterb.yml") do |v|
|
|
48
|
+
flags[:config_path] = v
|
|
49
|
+
end
|
|
50
|
+
opts.on("-h", "--help", "Show this help") do
|
|
51
|
+
puts opts
|
|
52
|
+
exit 0
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# A single mutation applied to one location of a source file.
|
|
5
|
+
#
|
|
6
|
+
# Status starts at :pending and transitions exactly once to :killed,
|
|
7
|
+
# :survived or :error (data-model.md).
|
|
8
|
+
class Mutant
|
|
9
|
+
VALID_STATUSES = %i[pending killed survived error].freeze
|
|
10
|
+
VALID_KILL_REASONS = %i[assertion_failure timeout].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :operator_type, :file_path, :line, :column_range,
|
|
13
|
+
:original_fragment, :mutated_fragment, :status, :kill_reason,
|
|
14
|
+
:related_tests, :failing_tests, :error_message
|
|
15
|
+
|
|
16
|
+
def initialize(id:, operator_type:, file_path:, line:, column_range:,
|
|
17
|
+
original_fragment:, mutated_fragment:)
|
|
18
|
+
raise ArgumentError, "id must be a String" unless id.is_a?(String)
|
|
19
|
+
raise ArgumentError, "operator_type must be a Symbol" unless operator_type.is_a?(Symbol)
|
|
20
|
+
raise ArgumentError, "file_path must be a String" unless file_path.is_a?(String)
|
|
21
|
+
raise ArgumentError, "line must be an Integer" unless line.is_a?(Integer)
|
|
22
|
+
raise ArgumentError, "column_range must be a Range" unless column_range.is_a?(Range)
|
|
23
|
+
|
|
24
|
+
@id = id
|
|
25
|
+
@operator_type = operator_type
|
|
26
|
+
@file_path = file_path
|
|
27
|
+
@line = line
|
|
28
|
+
@column_range = column_range
|
|
29
|
+
@original_fragment = original_fragment
|
|
30
|
+
@mutated_fragment = mutated_fragment
|
|
31
|
+
@status = :pending
|
|
32
|
+
@kill_reason = nil
|
|
33
|
+
@related_tests = []
|
|
34
|
+
@failing_tests = []
|
|
35
|
+
@error_message = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def related_tests=(test_cases)
|
|
39
|
+
@related_tests = Array(test_cases)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Records the single, final outcome of running this mutant (FR-004).
|
|
43
|
+
def finish!(status:, kill_reason: nil, failing_tests: [], error_message: nil)
|
|
44
|
+
raise MutationError, "Mutant #{id} already finished as #{@status}" unless @status == :pending
|
|
45
|
+
raise ArgumentError, "invalid status #{status.inspect}" unless VALID_STATUSES.include?(status)
|
|
46
|
+
raise ArgumentError, "status must not be :pending" if status == :pending
|
|
47
|
+
if kill_reason && !VALID_KILL_REASONS.include?(kill_reason)
|
|
48
|
+
raise ArgumentError, "invalid kill_reason #{kill_reason.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@status = status
|
|
52
|
+
@kill_reason = kill_reason
|
|
53
|
+
@failing_tests = Array(failing_tests)
|
|
54
|
+
@error_message = error_message
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def killed? = status == :killed
|
|
59
|
+
def survived? = status == :survived
|
|
60
|
+
def error? = status == :error
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
module MutationOperators
|
|
5
|
+
# Swaps an arithmetic or equality operator for its common typo/off-by-logic
|
|
6
|
+
# counterpart: + <-> -, * <-> /, == <-> !=.
|
|
7
|
+
class ArithmeticComparisonOperator < BaseOperator
|
|
8
|
+
SWAPS = { "+" => "-", "-" => "+", "*" => "/", "/" => "*", "==" => "!=", "!=" => "==" }.freeze
|
|
9
|
+
|
|
10
|
+
def self.operator_type = :arithmetic_comparison
|
|
11
|
+
|
|
12
|
+
def self.applicable?(node)
|
|
13
|
+
node.type == :OPCALL && SWAPS.key?(node.children[1].to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.replacement_for(node, fragment)
|
|
17
|
+
op = node.children[1].to_s
|
|
18
|
+
fragment.sub(op, SWAPS.fetch(op))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
module MutationOperators
|
|
5
|
+
# Walks a file's AST via RubyVM::AbstractSyntaxTree (research.md #1) and
|
|
6
|
+
# turns each matching node into a Mutant, without reserializing the file:
|
|
7
|
+
# only the exact node span gets patched as a text substitution.
|
|
8
|
+
#
|
|
9
|
+
# ponytail: multi-line expressions are skipped (first_lineno != last_lineno)
|
|
10
|
+
# to keep the text-patch approach simple; revisit with the `parser` gem if
|
|
11
|
+
# the operator catalog needs to mutate across line breaks.
|
|
12
|
+
class BaseOperator
|
|
13
|
+
class << self
|
|
14
|
+
def operator_type
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def applicable?(_node)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def replacement_for(_node, _fragment)
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def candidates(file_path)
|
|
27
|
+
source = File.read(file_path)
|
|
28
|
+
root = RubyVM::AbstractSyntaxTree.parse(source)
|
|
29
|
+
lines = source.lines
|
|
30
|
+
mutants = []
|
|
31
|
+
walk(root) { |node| mutants << build_mutant(file_path, lines, node) if applicable?(node) }
|
|
32
|
+
mutants.compact
|
|
33
|
+
rescue SyntaxError
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def walk(node, &block)
|
|
40
|
+
return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
|
|
41
|
+
|
|
42
|
+
block.call(node) if node.first_lineno == node.last_lineno
|
|
43
|
+
node.children.each { |child| walk(child, &block) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_mutant(file_path, lines, node)
|
|
47
|
+
range = node.first_column...node.last_column
|
|
48
|
+
original = lines[node.first_lineno - 1][range]
|
|
49
|
+
return nil if original.nil?
|
|
50
|
+
|
|
51
|
+
mutated = replacement_for(node, original)
|
|
52
|
+
return nil if mutated.nil? || mutated == original
|
|
53
|
+
|
|
54
|
+
Mutant.new(
|
|
55
|
+
id: "#{file_path}:#{node.first_lineno}:#{range.begin}:#{operator_type}",
|
|
56
|
+
operator_type: operator_type,
|
|
57
|
+
file_path: file_path,
|
|
58
|
+
line: node.first_lineno,
|
|
59
|
+
column_range: range,
|
|
60
|
+
original_fragment: original,
|
|
61
|
+
mutated_fragment: mutated
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
module MutationOperators
|
|
5
|
+
# Flips a literal `true`/`false`.
|
|
6
|
+
class BooleanLiteralOperator < BaseOperator
|
|
7
|
+
def self.operator_type = :boolean_literal
|
|
8
|
+
|
|
9
|
+
def self.applicable?(node)
|
|
10
|
+
%i[TRUE FALSE].include?(node.type)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.replacement_for(_node, fragment)
|
|
14
|
+
fragment == "true" ? "false" : "true"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
module MutationOperators
|
|
5
|
+
# Swaps a comparison operator for its off-by-one boundary: < <-> <=, > <-> >=.
|
|
6
|
+
class ConditionalBoundaryOperator < BaseOperator
|
|
7
|
+
SWAPS = { "<" => "<=", "<=" => "<", ">" => ">=", ">=" => ">" }.freeze
|
|
8
|
+
|
|
9
|
+
def self.operator_type = :conditional_boundary
|
|
10
|
+
|
|
11
|
+
def self.applicable?(node)
|
|
12
|
+
node.type == :OPCALL && SWAPS.key?(node.children[1].to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.replacement_for(node, fragment)
|
|
16
|
+
op = node.children[1].to_s
|
|
17
|
+
fragment.sub(op, SWAPS.fetch(op))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
module MutationOperators
|
|
5
|
+
# Replaces a literal `nil` with `false` — distinguishes code/tests that
|
|
6
|
+
# depend on `nil` specifically (`.nil?`, `== nil`) from merely-falsy checks.
|
|
7
|
+
#
|
|
8
|
+
# ponytail: a bare `nil` as the sole/last expression of a method body is
|
|
9
|
+
# optimized away by Ruby (implicit nil return, no NIL node at all), so it
|
|
10
|
+
# is never mutated. Upgrade path: only relevant if that trailing-position
|
|
11
|
+
# case turns out to matter in practice.
|
|
12
|
+
class NilLiteralOperator < BaseOperator
|
|
13
|
+
def self.operator_type = :nil_literal
|
|
14
|
+
|
|
15
|
+
def self.applicable?(node)
|
|
16
|
+
node.type == :NIL
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.replacement_for(_node, _fragment)
|
|
20
|
+
"false"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Aggregates a complete run: the config used, the detected test suite, and
|
|
5
|
+
# every mutant generated with its outcome (data-model.md).
|
|
6
|
+
class MutationRun
|
|
7
|
+
attr_reader :config, :test_suite, :mutants, :started_at
|
|
8
|
+
attr_accessor :finished_at, :interrupted
|
|
9
|
+
|
|
10
|
+
def initialize(config:, test_suite:)
|
|
11
|
+
@config = config
|
|
12
|
+
@test_suite = test_suite
|
|
13
|
+
@mutants = []
|
|
14
|
+
@started_at = Time.now
|
|
15
|
+
@finished_at = nil
|
|
16
|
+
@interrupted = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add_mutant(mutant)
|
|
20
|
+
raise ArgumentError, "expected a Mutant" unless mutant.is_a?(Mutant)
|
|
21
|
+
|
|
22
|
+
@mutants << mutant
|
|
23
|
+
mutant
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def baseline_broken_tests
|
|
27
|
+
test_suite.baseline_broken_tests
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def survived?
|
|
31
|
+
mutants.any?(&:survived?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def survived_mutants
|
|
35
|
+
mutants.select(&:survived?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def summary
|
|
39
|
+
{
|
|
40
|
+
total_mutants: mutants.size,
|
|
41
|
+
killed: mutants.count(&:killed?),
|
|
42
|
+
survived: mutants.count(&:survived?),
|
|
43
|
+
errors: mutants.count(&:error?),
|
|
44
|
+
baseline_broken_tests: baseline_broken_tests.size
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exit code for a normal (non-interrupted) completion. SIGINT is handled
|
|
49
|
+
# separately by the CLI's signal trap (contracts/cli.md: exit 130).
|
|
50
|
+
def exit_code
|
|
51
|
+
return 0 unless survived?
|
|
52
|
+
return 0 unless config.exit_on_survivors
|
|
53
|
+
|
|
54
|
+
1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Orchestrates one mutation at a time: patches the file, runs the related
|
|
5
|
+
# tests, classifies the outcome, and always restores the original file
|
|
6
|
+
# (FR-003, FR-005, FR-010).
|
|
7
|
+
class Mutator
|
|
8
|
+
OPERATORS = {
|
|
9
|
+
conditional_boundary: MutationOperators::ConditionalBoundaryOperator,
|
|
10
|
+
boolean_literal: MutationOperators::BooleanLiteralOperator,
|
|
11
|
+
nil_literal: MutationOperators::NilLiteralOperator,
|
|
12
|
+
arithmetic_comparison: MutationOperators::ArithmeticComparisonOperator
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(config:, test_suite:, test_runner: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@test_suite = test_suite
|
|
18
|
+
@test_runner = test_runner || TestRunner.new(config: config, project_type: test_suite.project_type)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Yields each finished Mutant, one at a time.
|
|
22
|
+
def each_mutant
|
|
23
|
+
source_files.each do |file|
|
|
24
|
+
candidates_for(file).each { |mutant| yield run_one(mutant) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :config, :test_suite, :test_runner
|
|
31
|
+
|
|
32
|
+
def source_files
|
|
33
|
+
Dir.glob(File.join(config.target_dir, "**", "*.rb"))
|
|
34
|
+
.reject { |f| f.include?("/spec/") || f.start_with?(File.join(config.target_dir, "spec")) }
|
|
35
|
+
.select { |f| in_scope?(f) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def in_scope?(file)
|
|
39
|
+
included = config.include_paths.empty? || config.include_paths.any? { |p| file.start_with?(p) }
|
|
40
|
+
excluded = config.exclude_paths.any? { |p| file.start_with?(p) }
|
|
41
|
+
included && !excluded
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def candidates_for(file)
|
|
45
|
+
config.mutation_types.flat_map { |type| OPERATORS.fetch(type).candidates(file) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run_one(mutant)
|
|
49
|
+
related = related_tests_for(mutant.file_path)
|
|
50
|
+
mutant.related_tests = related
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
original_content = File.read(mutant.file_path)
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
mutant.finish!(status: :error, error_message: "could not read #{mutant.file_path}: #{e.message}")
|
|
56
|
+
return mutant
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
apply_patch(mutant, original_content)
|
|
61
|
+
classify(mutant, related)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
mutant.finish!(status: :error, error_message: e.message) if mutant.status == :pending
|
|
64
|
+
ensure
|
|
65
|
+
File.write(mutant.file_path, original_content)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
mutant
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply_patch(mutant, original_content)
|
|
72
|
+
lines = original_content.lines
|
|
73
|
+
line = lines[mutant.line - 1]
|
|
74
|
+
range = mutant.column_range
|
|
75
|
+
lines[mutant.line - 1] = line[0...range.begin] + mutant.mutated_fragment + line[range.end..]
|
|
76
|
+
patched = lines.join
|
|
77
|
+
|
|
78
|
+
begin
|
|
79
|
+
RubyVM::AbstractSyntaxTree.parse(patched)
|
|
80
|
+
rescue SyntaxError => e
|
|
81
|
+
mutant.finish!(status: :error, error_message: "mutation produced invalid code: #{e.message}")
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
File.write(mutant.file_path, patched)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def classify(mutant, related_tests)
|
|
89
|
+
return unless mutant.status == :pending # apply_patch already marked :error
|
|
90
|
+
|
|
91
|
+
if related_tests.empty?
|
|
92
|
+
finish_inconclusive(mutant, "no related tests")
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
result = test_runner.run_for_mutant(related_tests)
|
|
97
|
+
case result[:status]
|
|
98
|
+
when :timeout
|
|
99
|
+
mutant.finish!(status: :killed, kill_reason: :timeout)
|
|
100
|
+
when :error
|
|
101
|
+
finish_inconclusive(mutant, "failed to run tests")
|
|
102
|
+
else
|
|
103
|
+
classify_from_examples(mutant, related_tests, result[:examples])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# An inconclusive result (no tests exercised the mutation, or test
|
|
108
|
+
# execution failed) does not prove the mutation is detected. With
|
|
109
|
+
# strictness "high" this counts as evidence of weakness ("survived");
|
|
110
|
+
# at other levels it is reported separately as "error" without
|
|
111
|
+
# affecting the killed/survived count (US3/AC2, FR-008).
|
|
112
|
+
def finish_inconclusive(mutant, message)
|
|
113
|
+
if config.strictness == :high
|
|
114
|
+
mutant.finish!(status: :survived)
|
|
115
|
+
else
|
|
116
|
+
mutant.finish!(status: :error, error_message: message)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def classify_from_examples(mutant, related_tests, examples)
|
|
121
|
+
failing_ids = examples.select { |e| e[:status] == :failed }.map { |e| e[:id] }
|
|
122
|
+
failing_tests = related_tests.select { |t| failing_ids.include?(t.id) }
|
|
123
|
+
if failing_tests.any?
|
|
124
|
+
mutant.finish!(status: :killed, kill_reason: :assertion_failure, failing_tests: failing_tests)
|
|
125
|
+
else
|
|
126
|
+
mutant.finish!(status: :survived)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Convention-based coverage mapping: lib/foo/bar.rb -> spec/foo/bar_spec.rb.
|
|
131
|
+
# ponytail: no real coverage tracking yet; falls back to the whole suite
|
|
132
|
+
# when no matching spec file exists. Upgrade path: integrate SimpleCov
|
|
133
|
+
# coverage data if this heuristic proves too coarse for real projects.
|
|
134
|
+
def related_tests_for(source_file)
|
|
135
|
+
mapped = mapped_spec_file(source_file)
|
|
136
|
+
matches = test_suite.test_cases.select { |t| t.file_path == mapped }
|
|
137
|
+
matches = test_suite.test_cases.dup if matches.empty?
|
|
138
|
+
matches.reject(&:baseline_broken?)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def mapped_spec_file(source_file)
|
|
142
|
+
relative = source_file.sub(%r{\A#{Regexp.escape(config.target_dir)}/?}, "")
|
|
143
|
+
relative = relative.sub(%r{\A(lib|app)/}, "")
|
|
144
|
+
spec_relative = relative.sub(/\.rb\z/, "_spec.rb")
|
|
145
|
+
File.join(config.target_dir, "spec", spec_relative)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# Detects whether the target project is Ruby or Rails and locates its RSpec
|
|
5
|
+
# files (FR-001, FR-002).
|
|
6
|
+
class ProjectDetector
|
|
7
|
+
Detection = Struct.new(:project_type, :spec_files)
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def detect
|
|
14
|
+
Detection.new(project_type, discover_spec_files)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :config
|
|
20
|
+
|
|
21
|
+
def project_type
|
|
22
|
+
rails_marker = File.join(config.target_dir, "config", "application.rb")
|
|
23
|
+
rails_bin = File.join(config.target_dir, "bin", "rails")
|
|
24
|
+
File.exist?(rails_marker) || File.exist?(rails_bin) ? :rails : :ruby
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def discover_spec_files
|
|
28
|
+
pattern = File.join(config.target_dir, "spec", "**", "*_spec.rb")
|
|
29
|
+
apply_scope(Dir.glob(pattern))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def apply_scope(files)
|
|
33
|
+
files = files.select { |f| config.include_paths.any? { |p| f.start_with?(p) } } unless config.include_paths.empty?
|
|
34
|
+
files.reject { |f| config.exclude_paths.any? { |p| f.start_with?(p) } }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module MutateRB
|
|
7
|
+
# Prints the console summary (FR-006) and, if configured, exports the same
|
|
8
|
+
# result to JSON (FR-015, contracts/json-report-schema.md).
|
|
9
|
+
class Reporter
|
|
10
|
+
def initialize(run)
|
|
11
|
+
@run = run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report
|
|
15
|
+
print_console
|
|
16
|
+
write_json if run.config.json_output_path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :run
|
|
22
|
+
|
|
23
|
+
def print_console
|
|
24
|
+
summary = run.summary
|
|
25
|
+
puts "MutateRB — #{summary[:total_mutants]} mutations: " \
|
|
26
|
+
"#{summary[:killed]} killed, #{summary[:survived]} survived, #{summary[:errors]} errors"
|
|
27
|
+
print_baseline_broken
|
|
28
|
+
print_survived
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def print_baseline_broken
|
|
32
|
+
broken = run.baseline_broken_tests
|
|
33
|
+
return if broken.empty?
|
|
34
|
+
|
|
35
|
+
puts "Pre-broken tests before mutation (excluded from count): #{broken.size}"
|
|
36
|
+
broken.each { |t| puts " - #{t.id} #{t.description}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def print_survived
|
|
40
|
+
survived = run.survived_mutants
|
|
41
|
+
return if survived.empty?
|
|
42
|
+
|
|
43
|
+
puts "\nSurvived mutations (weak tests):"
|
|
44
|
+
survived.each do |mutant|
|
|
45
|
+
related = mutant.related_tests.map(&:id).join(", ")
|
|
46
|
+
puts " - #{mutant.file_path}:#{mutant.line} [#{mutant.operator_type}] " \
|
|
47
|
+
"'#{mutant.original_fragment}' -> '#{mutant.mutated_fragment}' (tests: #{related})"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_json
|
|
52
|
+
File.write(run.config.json_output_path, JSON.pretty_generate(to_h))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{
|
|
57
|
+
mutaterb_version: MutateRB::VERSION,
|
|
58
|
+
started_at: run.started_at.utc.iso8601,
|
|
59
|
+
finished_at: run.finished_at&.utc&.iso8601,
|
|
60
|
+
interrupted: run.interrupted,
|
|
61
|
+
config: config_h,
|
|
62
|
+
summary: run.summary,
|
|
63
|
+
baseline_broken_tests: run.baseline_broken_tests.map { |t| test_case_h(t) },
|
|
64
|
+
survived_mutants: run.survived_mutants.map { |m| mutant_h(m) },
|
|
65
|
+
exit_code: run.exit_code
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def config_h
|
|
70
|
+
c = run.config
|
|
71
|
+
{
|
|
72
|
+
target_dir: c.target_dir,
|
|
73
|
+
include_paths: c.include_paths,
|
|
74
|
+
exclude_paths: c.exclude_paths,
|
|
75
|
+
strictness: c.strictness.to_s,
|
|
76
|
+
mutation_types: c.mutation_types.map(&:to_s),
|
|
77
|
+
exit_on_survivors: c.exit_on_survivors
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mutant_h(mutant)
|
|
82
|
+
{
|
|
83
|
+
id: mutant.id,
|
|
84
|
+
operator_type: mutant.operator_type.to_s,
|
|
85
|
+
file_path: mutant.file_path,
|
|
86
|
+
line: mutant.line,
|
|
87
|
+
original_fragment: mutant.original_fragment,
|
|
88
|
+
mutated_fragment: mutant.mutated_fragment,
|
|
89
|
+
related_tests: mutant.related_tests.map { |t| test_case_h(t) }
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_case_h(test_case)
|
|
94
|
+
{ id: test_case.id, description: test_case.description }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# A single test detected in the target project (data-model.md).
|
|
5
|
+
class TestCase
|
|
6
|
+
VALID_BASELINE_STATUSES = %i[passed failed].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :id, :description, :file_path
|
|
9
|
+
attr_accessor :baseline_status, :baseline_duration_seconds
|
|
10
|
+
|
|
11
|
+
def initialize(id:, description:, file_path:, baseline_status: nil,
|
|
12
|
+
baseline_duration_seconds: nil)
|
|
13
|
+
raise ArgumentError, "id must be a String" unless id.is_a?(String)
|
|
14
|
+
raise ArgumentError, "file_path must be a String" unless file_path.is_a?(String)
|
|
15
|
+
if baseline_status && !VALID_BASELINE_STATUSES.include?(baseline_status)
|
|
16
|
+
raise ArgumentError, "invalid baseline_status #{baseline_status.inspect}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@id = id
|
|
20
|
+
@description = description.to_s
|
|
21
|
+
@file_path = file_path
|
|
22
|
+
@baseline_status = baseline_status
|
|
23
|
+
@baseline_duration_seconds = baseline_duration_seconds
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def baseline_broken? = baseline_status == :failed
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "bundler"
|
|
6
|
+
|
|
7
|
+
module MutateRB
|
|
8
|
+
# Runs the target project's own RSpec suite as a subprocess (research.md #2):
|
|
9
|
+
# MutateRB never bundles its own RSpec, it shells out to `bundle exec rspec`
|
|
10
|
+
# inside the target project so the project's own Gemfile.lock decides the
|
|
11
|
+
# RSpec version.
|
|
12
|
+
class TestRunner
|
|
13
|
+
MIN_TIMEOUT_SECONDS = 5
|
|
14
|
+
|
|
15
|
+
def initialize(config:, project_type:)
|
|
16
|
+
@config = config
|
|
17
|
+
@project_type = project_type
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Runs the given spec files once, unmutated, and returns one Hash per
|
|
21
|
+
# RSpec example: { id:, description:, file_path:, status:, duration: }.
|
|
22
|
+
# Used to build TestCase instances (FR-012's baseline_status) and as the
|
|
23
|
+
# basis for the per-mutant timeout (FR-014).
|
|
24
|
+
def run_baseline(spec_files)
|
|
25
|
+
spawn_rspec(spec_files, timeout_seconds: nil).fetch(:examples, [])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Runs the given test cases against a mutation. Timeout = 2x the slowest
|
|
29
|
+
# related test's baseline duration, floor MIN_TIMEOUT_SECONDS (FR-014).
|
|
30
|
+
# On timeout the child process is killed explicitly (research.md #3) so no
|
|
31
|
+
# hung process survives the run.
|
|
32
|
+
def run_for_mutant(test_cases)
|
|
33
|
+
return { status: :error, examples: [] } if test_cases.empty?
|
|
34
|
+
|
|
35
|
+
slowest_baseline = test_cases.filter_map(&:baseline_duration_seconds).max || 0
|
|
36
|
+
timeout_seconds = [slowest_baseline * 2, MIN_TIMEOUT_SECONDS].max
|
|
37
|
+
files = test_cases.map(&:file_path).uniq
|
|
38
|
+
spawn_rspec(files, timeout_seconds: timeout_seconds)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :config, :project_type
|
|
44
|
+
|
|
45
|
+
# Runs inside Bundler.with_unbundled_env (research.md #2): when `mutaterb`
|
|
46
|
+
# itself is invoked via `bundle exec`, BUNDLE_GEMFILE/RUBYOPT point at
|
|
47
|
+
# MutateRB's own Gemfile and would otherwise leak into this child process,
|
|
48
|
+
# making `bundle exec rspec` resolve the target project's suite against
|
|
49
|
+
# the wrong bundle. with_unbundled_env restores the pre-bundler
|
|
50
|
+
# environment for the duration of the spawn.
|
|
51
|
+
def spawn_rspec(files, timeout_seconds:)
|
|
52
|
+
env = project_type == :rails ? { "RAILS_ENV" => "test" } : {}
|
|
53
|
+
stdout_read, stdout_write = IO.pipe
|
|
54
|
+
pid = Bundler.with_unbundled_env do
|
|
55
|
+
Process.spawn(env, *rspec_command(files), out: stdout_write, err: File::NULL,
|
|
56
|
+
chdir: File.expand_path(config.target_dir))
|
|
57
|
+
end
|
|
58
|
+
stdout_write.close
|
|
59
|
+
|
|
60
|
+
output = wait_with_timeout(pid, stdout_read, timeout_seconds)
|
|
61
|
+
stdout_read.close unless stdout_read.closed?
|
|
62
|
+
output
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def wait_with_timeout(pid, stdout_read, timeout_seconds)
|
|
66
|
+
if timeout_seconds
|
|
67
|
+
Timeout.timeout(timeout_seconds) { read_and_wait(pid, stdout_read) }
|
|
68
|
+
else
|
|
69
|
+
read_and_wait(pid, stdout_read)
|
|
70
|
+
end
|
|
71
|
+
rescue Timeout::Error
|
|
72
|
+
kill(pid)
|
|
73
|
+
{ status: :timeout, examples: [] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_and_wait(pid, stdout_read)
|
|
77
|
+
raw = stdout_read.read
|
|
78
|
+
Process.wait(pid)
|
|
79
|
+
parse_output(raw)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Kills a hung child process: TERM first, KILL if it ignores TERM for 1s
|
|
83
|
+
# (research.md #3 — Timeout alone never touches the child process).
|
|
84
|
+
def kill(pid)
|
|
85
|
+
Process.kill("TERM", pid)
|
|
86
|
+
begin
|
|
87
|
+
Timeout.timeout(1) { Process.wait(pid) }
|
|
88
|
+
rescue Timeout::Error
|
|
89
|
+
Process.kill("KILL", pid)
|
|
90
|
+
Process.wait(pid)
|
|
91
|
+
end
|
|
92
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rspec_command(files)
|
|
97
|
+
["bundle", "exec", "rspec", "--format", "json", *files]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_output(raw)
|
|
101
|
+
data = JSON.parse(raw)
|
|
102
|
+
examples = data.fetch("examples", []).map do |example|
|
|
103
|
+
{
|
|
104
|
+
id: example["id"],
|
|
105
|
+
description: example["full_description"],
|
|
106
|
+
file_path: example["file_path"],
|
|
107
|
+
status: example["status"] == "passed" ? :passed : :failed,
|
|
108
|
+
duration: example["run_time"].to_f
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
{ status: :completed, examples: examples }
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
{ status: :error, examples: [] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MutateRB
|
|
4
|
+
# The collection of tests detected for the target project (data-model.md).
|
|
5
|
+
class TestSuite
|
|
6
|
+
VALID_PROJECT_TYPES = %i[ruby rails].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :framework, :project_type, :test_cases
|
|
9
|
+
|
|
10
|
+
def initialize(project_type:, test_cases: [])
|
|
11
|
+
unless VALID_PROJECT_TYPES.include?(project_type)
|
|
12
|
+
raise ArgumentError,
|
|
13
|
+
"invalid project_type #{project_type.inspect}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@framework = :rspec
|
|
17
|
+
@project_type = project_type
|
|
18
|
+
@test_cases = Array(test_cases)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builds a TestSuite from the raw example hashes returned by
|
|
22
|
+
# TestRunner#run_baseline (FR-012's baseline_status comes from here).
|
|
23
|
+
def self.from_baseline(project_type:, baseline_examples:)
|
|
24
|
+
test_cases = baseline_examples.map do |example|
|
|
25
|
+
TestCase.new(
|
|
26
|
+
id: example.fetch(:id),
|
|
27
|
+
description: example.fetch(:description),
|
|
28
|
+
file_path: example.fetch(:file_path),
|
|
29
|
+
baseline_status: example.fetch(:status),
|
|
30
|
+
baseline_duration_seconds: example.fetch(:duration)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
new(project_type: project_type, test_cases: test_cases)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find(id)
|
|
37
|
+
test_cases.find { |test_case| test_case.id == id }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def baseline_broken_tests
|
|
41
|
+
test_cases.select(&:baseline_broken?)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/mutaterb.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mutaterb/version"
|
|
4
|
+
require_relative "mutaterb/errors"
|
|
5
|
+
require_relative "mutaterb/mutant"
|
|
6
|
+
require_relative "mutaterb/test_case"
|
|
7
|
+
require_relative "mutaterb/test_suite"
|
|
8
|
+
require_relative "mutaterb/config"
|
|
9
|
+
require_relative "mutaterb/mutation_run"
|
|
10
|
+
require_relative "mutaterb/flag_parser"
|
|
11
|
+
require_relative "mutaterb/project_detector"
|
|
12
|
+
require_relative "mutaterb/test_runner"
|
|
13
|
+
require_relative "mutaterb/mutation_operators/base_operator"
|
|
14
|
+
require_relative "mutaterb/mutation_operators/conditional_boundary_operator"
|
|
15
|
+
require_relative "mutaterb/mutation_operators/boolean_literal_operator"
|
|
16
|
+
require_relative "mutaterb/mutation_operators/nil_literal_operator"
|
|
17
|
+
require_relative "mutaterb/mutation_operators/arithmetic_comparison_operator"
|
|
18
|
+
require_relative "mutaterb/mutator"
|
|
19
|
+
require_relative "mutaterb/reporter"
|
|
20
|
+
require_relative "mutaterb/cli"
|
|
21
|
+
|
|
22
|
+
module MutateRB
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mutaterb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- MarceloM47
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rake
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rspec
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.13'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.13'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rubocop
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.65'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.65'
|
|
54
|
+
description: Detects weak tests by mutating source code covered by an RSpec suite
|
|
55
|
+
and checking whether the suite catches the mutation.
|
|
56
|
+
email:
|
|
57
|
+
- marcelo.esteche@proton.me
|
|
58
|
+
executables:
|
|
59
|
+
- mutaterb
|
|
60
|
+
extensions: []
|
|
61
|
+
extra_rdoc_files: []
|
|
62
|
+
files:
|
|
63
|
+
- LICENSE.txt
|
|
64
|
+
- README.md
|
|
65
|
+
- exe/mutaterb
|
|
66
|
+
- lib/mutaterb.rb
|
|
67
|
+
- lib/mutaterb/cli.rb
|
|
68
|
+
- lib/mutaterb/config.rb
|
|
69
|
+
- lib/mutaterb/errors.rb
|
|
70
|
+
- lib/mutaterb/flag_parser.rb
|
|
71
|
+
- lib/mutaterb/mutant.rb
|
|
72
|
+
- lib/mutaterb/mutation_operators/arithmetic_comparison_operator.rb
|
|
73
|
+
- lib/mutaterb/mutation_operators/base_operator.rb
|
|
74
|
+
- lib/mutaterb/mutation_operators/boolean_literal_operator.rb
|
|
75
|
+
- lib/mutaterb/mutation_operators/conditional_boundary_operator.rb
|
|
76
|
+
- lib/mutaterb/mutation_operators/nil_literal_operator.rb
|
|
77
|
+
- lib/mutaterb/mutation_run.rb
|
|
78
|
+
- lib/mutaterb/mutator.rb
|
|
79
|
+
- lib/mutaterb/project_detector.rb
|
|
80
|
+
- lib/mutaterb/reporter.rb
|
|
81
|
+
- lib/mutaterb/test_case.rb
|
|
82
|
+
- lib/mutaterb/test_runner.rb
|
|
83
|
+
- lib/mutaterb/test_suite.rb
|
|
84
|
+
- lib/mutaterb/version.rb
|
|
85
|
+
homepage: https://github.com/MarceloM47/mutateRB
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
homepage_uri: https://github.com/MarceloM47/mutateRB
|
|
90
|
+
source_code_uri: https://github.com/MarceloM47/mutateRB
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.0'
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 4.0.16
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Mutation testing CLI for Ruby and Ruby on Rails projects
|
|
109
|
+
test_files: []
|