moots 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/CHANGELOG.md +34 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +312 -0
- data/docs/isolation.md +89 -0
- data/docs/reporting.md +70 -0
- data/exe/moots +6 -0
- data/lib/moots/cli.rb +75 -0
- data/lib/moots/configuration.rb +86 -0
- data/lib/moots/environment.rb +23 -0
- data/lib/moots/error.rb +6 -0
- data/lib/moots/mutation.rb +42 -0
- data/lib/moots/mutation_generator.rb +60 -0
- data/lib/moots/reporter.rb +104 -0
- data/lib/moots/test_result.rb +17 -0
- data/lib/moots/test_runner.rb +60 -0
- data/lib/moots/version.rb +5 -0
- data/lib/moots/worker_pool.rb +76 -0
- data/lib/moots/workspace.rb +137 -0
- data/lib/moots.rb +111 -0
- data/sig/moots.rbs +4 -0
- metadata +100 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "environment"
|
|
8
|
+
|
|
9
|
+
module Moots
|
|
10
|
+
# A disposable snapshot of the current project, with paths mapped back for reports.
|
|
11
|
+
class Workspace
|
|
12
|
+
METADATA = %w[.git .hg .svn].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :directory
|
|
15
|
+
|
|
16
|
+
def initialize(isolation:, workspace_exclude:, root: Dir.pwd)
|
|
17
|
+
@root = File.realpath(root)
|
|
18
|
+
@directory = @root
|
|
19
|
+
@isolation = isolation
|
|
20
|
+
@exclude = workspace_exclude
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def open
|
|
24
|
+
return yield self unless @isolation
|
|
25
|
+
|
|
26
|
+
if inside?(File.realpath(Dir.tmpdir))
|
|
27
|
+
raise Error, "Temporary directory must be outside the project for isolated runs"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Dir.mktmpdir("moots-workspace-") do |temporary|
|
|
31
|
+
@directory = File.realpath(temporary)
|
|
32
|
+
copy_directory(@root, @directory)
|
|
33
|
+
yield self
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def files(patterns)
|
|
38
|
+
patterns.flat_map do |pattern|
|
|
39
|
+
absolute = File.expand_path(pattern, @root)
|
|
40
|
+
raise Error, "Source pattern escapes the project: #{pattern}" if @isolation && !inside?(absolute)
|
|
41
|
+
|
|
42
|
+
Dir.glob(rebase(absolute)).map { |path| selected_path(path) }
|
|
43
|
+
end.uniq.sort
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def with_workers(count)
|
|
47
|
+
Dir.mktmpdir("moots-workers-") do |temporary|
|
|
48
|
+
directory = File.realpath(temporary)
|
|
49
|
+
workers = Array.new(count) do |index|
|
|
50
|
+
self.class.new(isolation: true, workspace_exclude: [], root: @directory)
|
|
51
|
+
.copy_to(File.join(directory, index.to_s))
|
|
52
|
+
end
|
|
53
|
+
yield workers
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def original_path(path)
|
|
58
|
+
File.expand_path(Pathname.new(path).relative_path_from(Pathname.new(@directory)).to_s, @root)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def command(value)
|
|
62
|
+
return value unless value.is_a?(Array)
|
|
63
|
+
|
|
64
|
+
value.map { |argument| rebase(argument) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def environment(source = ENV)
|
|
68
|
+
Environment.rebase(source) { |path| rebase(path) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
protected
|
|
72
|
+
|
|
73
|
+
def copy_to(destination)
|
|
74
|
+
@directory = destination
|
|
75
|
+
Dir.mkdir(destination)
|
|
76
|
+
copy_directory(@root, destination)
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def inside?(path, root = @root)
|
|
83
|
+
path == root || path.start_with?("#{root}/")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def selected_path(path)
|
|
87
|
+
absolute = File.expand_path(path)
|
|
88
|
+
if @isolation && (!inside?(absolute, @directory) || File.realpath(absolute) != absolute)
|
|
89
|
+
raise Error, "Selected source escapes or links outside the working copy: #{path}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
absolute
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rebase(path)
|
|
96
|
+
return path unless Pathname.new(path).absolute?
|
|
97
|
+
|
|
98
|
+
absolute = File.exist?(path) ? File.realpath(path) : File.expand_path(path)
|
|
99
|
+
return path unless inside?(absolute)
|
|
100
|
+
|
|
101
|
+
File.expand_path(Pathname.new(absolute).relative_path_from(Pathname.new(@root)).to_s, @directory)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def excluded?(relative, name)
|
|
105
|
+
METADATA.include?(name) || @exclude.any? do |pattern|
|
|
106
|
+
File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def copy_directory(source, destination)
|
|
111
|
+
Dir.children(source).each do |name|
|
|
112
|
+
path = File.join(source, name)
|
|
113
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
114
|
+
next if excluded?(relative, name)
|
|
115
|
+
|
|
116
|
+
stat = File.lstat(path)
|
|
117
|
+
if stat.symlink? || File.realpath(path) != path
|
|
118
|
+
raise Error,
|
|
119
|
+
"Cannot isolate linked path #{relative}; exclude it with workspace_exclude or use isolation: false"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
copy_entry(path, File.join(destination, name), stat)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def copy_entry(source, destination, stat)
|
|
127
|
+
if stat.directory?
|
|
128
|
+
Dir.mkdir(destination)
|
|
129
|
+
copy_directory(source, destination)
|
|
130
|
+
elsif stat.file?
|
|
131
|
+
FileUtils.copy_file(source, destination, true)
|
|
132
|
+
else
|
|
133
|
+
raise Error, "Cannot isolate special file #{source}; exclude it with workspace_exclude"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/moots.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "moots/version"
|
|
4
|
+
require_relative "moots/configuration"
|
|
5
|
+
require_relative "moots/mutation_generator"
|
|
6
|
+
require_relative "moots/test_runner"
|
|
7
|
+
require_relative "moots/workspace"
|
|
8
|
+
require_relative "moots/worker_pool"
|
|
9
|
+
|
|
10
|
+
# Mutation testing for Ruby projects with isolated test processes.
|
|
11
|
+
module Moots
|
|
12
|
+
Result = Struct.new(:mutation, :killed, :test_result, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Verify the baseline, then run each mutation in a disposable project snapshot.
|
|
16
|
+
def run(options = {}, &progress)
|
|
17
|
+
config = Configuration.new(options).to_h
|
|
18
|
+
emit(progress, :workspace_started) if config[:isolation]
|
|
19
|
+
Workspace.new(isolation: config[:isolation], workspace_exclude: config[:workspace_exclude]).open do |workspace|
|
|
20
|
+
execute(config.merge(test_command: workspace.command(config[:test_command])), workspace, progress)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def execute(config, workspace, progress)
|
|
27
|
+
runner = TestRunner.new(chdir: workspace.directory, env: workspace.environment)
|
|
28
|
+
verify_baseline(runner, config, progress)
|
|
29
|
+
targets = mutation_targets(config, workspace)
|
|
30
|
+
return run_parallel(targets, config, workspace, progress) if config[:jobs] > 1 && targets.size > 1
|
|
31
|
+
|
|
32
|
+
targets.each_with_index.map do |(file, code, mutation), index|
|
|
33
|
+
position = { index: index + 1, total: targets.size }
|
|
34
|
+
emit(progress, :mutation_started, **position, mutation: mutation)
|
|
35
|
+
test_result = test_mutation(file, code, mutation, runner, config)
|
|
36
|
+
result = Result.new(mutation: mutation, killed: !test_result.success?, test_result: test_result)
|
|
37
|
+
emit(progress, :mutation_finished, **position, result: result)
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_parallel(targets, config, workspace, progress)
|
|
43
|
+
workspace.with_workers([config[:jobs], targets.size].min) do |copies|
|
|
44
|
+
workers = copies.map do |copy|
|
|
45
|
+
runner = TestRunner.new(chdir: copy.directory, env: copy.environment(workspace.environment))
|
|
46
|
+
[copy, runner, config.merge(test_command: copy.command(config[:test_command]))]
|
|
47
|
+
end
|
|
48
|
+
before = lambda do |target, index|
|
|
49
|
+
emit(progress, :mutation_started, index: index + 1, total: targets.size, mutation: target.last)
|
|
50
|
+
end
|
|
51
|
+
after = lambda do |result, index|
|
|
52
|
+
emit(progress, :mutation_finished, index: index + 1, total: targets.size, result: result)
|
|
53
|
+
end
|
|
54
|
+
WorkerPool.new(workers, before: before, after: after).map(targets) do |(copy, runner, options), target|
|
|
55
|
+
file, code, mutation = target
|
|
56
|
+
test_result = test_mutation(copy.command([file]).first, code, mutation, runner, options)
|
|
57
|
+
Result.new(mutation: mutation, killed: !test_result.success?, test_result: test_result)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def emit(progress, type, **payload)
|
|
63
|
+
progress&.call(type: type, **payload)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def verify_baseline(runner, config, progress)
|
|
67
|
+
emit(progress, :baseline_started)
|
|
68
|
+
baseline = runner.run_tests(config[:test_command], timeout: config[:timeout])
|
|
69
|
+
raise Error, "Baseline tests failed; no mutations applied.\n#{baseline.output}" unless baseline.success?
|
|
70
|
+
|
|
71
|
+
emit(progress, :baseline_passed)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def mutation_targets(config, workspace)
|
|
75
|
+
generator = MutationGenerator.new
|
|
76
|
+
targets = find_files(config, workspace).flat_map do |file|
|
|
77
|
+
code = File.binread(file)
|
|
78
|
+
generator.generate_mutations(code, file_path: workspace.original_path(file)).map do |mutation|
|
|
79
|
+
[file, code, mutation]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
return targets unless config[:mutation]
|
|
83
|
+
|
|
84
|
+
selected = targets.select { |_file, _code, mutation| mutation.id == config[:mutation] }
|
|
85
|
+
if selected.empty?
|
|
86
|
+
raise Error,
|
|
87
|
+
"No mutation matches #{config[:mutation]}; source or configuration may have changed."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
selected
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_files(config, workspace)
|
|
94
|
+
included = workspace.files(config[:include])
|
|
95
|
+
excluded = workspace.files(config[:exclude])
|
|
96
|
+
included.reject do |path|
|
|
97
|
+
excluded.include?(path) || !File.file?(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_mutation(file, code, mutation, runner, config)
|
|
102
|
+
mutated_code = mutation.apply(code)
|
|
103
|
+
begin
|
|
104
|
+
File.binwrite(file, mutated_code)
|
|
105
|
+
runner.run_tests(config[:test_command], timeout: config[:timeout])
|
|
106
|
+
ensure
|
|
107
|
+
File.binwrite(file, code)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/sig/moots.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: moots
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dave Russell
|
|
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: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.7'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '2.7'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: prism
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.9'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.9'
|
|
46
|
+
email:
|
|
47
|
+
- dave.kerr@gmail.com
|
|
48
|
+
executables:
|
|
49
|
+
- moots
|
|
50
|
+
extensions: []
|
|
51
|
+
extra_rdoc_files: []
|
|
52
|
+
files:
|
|
53
|
+
- CHANGELOG.md
|
|
54
|
+
- CODE_OF_CONDUCT.md
|
|
55
|
+
- LICENSE.txt
|
|
56
|
+
- README.md
|
|
57
|
+
- docs/isolation.md
|
|
58
|
+
- docs/reporting.md
|
|
59
|
+
- exe/moots
|
|
60
|
+
- lib/moots.rb
|
|
61
|
+
- lib/moots/cli.rb
|
|
62
|
+
- lib/moots/configuration.rb
|
|
63
|
+
- lib/moots/environment.rb
|
|
64
|
+
- lib/moots/error.rb
|
|
65
|
+
- lib/moots/mutation.rb
|
|
66
|
+
- lib/moots/mutation_generator.rb
|
|
67
|
+
- lib/moots/reporter.rb
|
|
68
|
+
- lib/moots/test_result.rb
|
|
69
|
+
- lib/moots/test_runner.rb
|
|
70
|
+
- lib/moots/version.rb
|
|
71
|
+
- lib/moots/worker_pool.rb
|
|
72
|
+
- lib/moots/workspace.rb
|
|
73
|
+
- sig/moots.rbs
|
|
74
|
+
homepage: https://github.com/OkayDave/moots
|
|
75
|
+
licenses:
|
|
76
|
+
- MIT
|
|
77
|
+
metadata:
|
|
78
|
+
allowed_push_host: https://rubygems.org
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
homepage_uri: https://github.com/OkayDave/moots
|
|
81
|
+
source_code_uri: https://github.com/OkayDave/moots
|
|
82
|
+
changelog_uri: https://github.com/OkayDave/moots/blob/main/CHANGELOG.md
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: 3.3.0
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 4.0.20
|
|
98
|
+
specification_version: 4
|
|
99
|
+
summary: Moots is a simple tool to provide mutation testing for Ruby
|
|
100
|
+
test_files: []
|