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.
@@ -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
@@ -0,0 +1,4 @@
1
+ module Moots
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []