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.
data/lib/moots/cli.rb ADDED
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../moots"
5
+ require_relative "reporter"
6
+
7
+ module Moots
8
+ # Command-line entry point. Survivors and empty runs have a failing exit status.
9
+ class CLI
10
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
11
+ new(argv, out: out, err: err).run
12
+ end
13
+
14
+ def initialize(argv, out:, err:)
15
+ @argv = argv
16
+ @out = out
17
+ @err = err
18
+ @path = ".moots.yml"
19
+ @explicit_path = false
20
+ @format = "text"
21
+ @overrides = {}
22
+ end
23
+
24
+ def run
25
+ return 0 unless run_requested?
26
+
27
+ @config_path = @path if @explicit_path || File.exist?(@path)
28
+ config = @config_path ? Configuration.load(@config_path) : {}
29
+ results = Moots.run(config.merge(@overrides)) { |event| reporter.call(event) }
30
+ reporter.finish(results)
31
+ return 1 if results.empty?
32
+
33
+ results.all?(&:killed) ? 0 : 1
34
+ rescue Error, OptionParser::ParseError, SystemCallError => e
35
+ reporter.error(e.message)
36
+ 2
37
+ rescue Interrupt
38
+ reporter.error("Interrupted")
39
+ 130
40
+ end
41
+
42
+ private
43
+
44
+ def reporter
45
+ @reporter ||= Reporter.new(format: @format, out: @out, err: @err, config_path: @config_path)
46
+ end
47
+
48
+ def run_requested?
49
+ parser = OptionParser.new do |options|
50
+ options.banner = "Usage: moots [--config PATH] [--format text|json] [--mutation ID] [--jobs N]"
51
+ options.on("-c", "--config PATH", "Configuration file (default: .moots.yml)") do |value|
52
+ @path = value
53
+ @explicit_path = true
54
+ end
55
+ options.on("--format FORMAT", %w[text json], "Output format (default: text)") { |value| @format = value }
56
+ options.on("--mutation ID", "Run one mutation by its reported ID") { |value| @overrides[:mutation] = value }
57
+ options.on("-j", "--jobs N", Integer, "Concurrent isolated workers (default: 1)") do |value|
58
+ @overrides[:jobs] = value
59
+ end
60
+ options.on("-v", "--version", "Print version") do
61
+ @out.puts(VERSION)
62
+ return false
63
+ end
64
+ options.on("-h", "--help", "Show help") do
65
+ @out.puts(options)
66
+ return false
67
+ end
68
+ end
69
+ remaining = parser.parse(@argv)
70
+ raise Error, "Unexpected arguments: #{remaining.join(" ")}" unless remaining.empty?
71
+
72
+ true
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "error"
5
+
6
+ module Moots
7
+ # Load safe YAML and validate the options shared by the CLI and Ruby API.
8
+ class Configuration
9
+ DEFAULTS = {
10
+ include: ["app/**/*.rb", "lib/**/*.rb"].freeze,
11
+ exclude: [].freeze,
12
+ test_command: "bundle exec rspec",
13
+ timeout: 60,
14
+ mutation: nil,
15
+ isolation: true,
16
+ jobs: 1,
17
+ workspace_exclude: ["tmp", "coverage", "pkg", ".rspec_status"].freeze
18
+ }.freeze
19
+
20
+ def self.load(path)
21
+ new(YAML.safe_load_file(path)).to_h
22
+ rescue Psych::Exception, SystemCallError => e
23
+ raise Error, "Cannot load configuration #{path}: #{e.message}"
24
+ end
25
+
26
+ def initialize(options = {})
27
+ raise Error, "Configuration must be a mapping" unless options.is_a?(Hash)
28
+
29
+ normalized = options.transform_keys(&:to_s)
30
+ unknown = normalized.keys - DEFAULTS.keys.map(&:to_s)
31
+ raise Error, "Unknown configuration keys: #{unknown.join(", ")}" unless unknown.empty?
32
+
33
+ @options = DEFAULTS.merge(normalized.transform_keys(&:to_sym))
34
+ validate_patterns(:include)
35
+ validate_patterns(:exclude)
36
+ validate_patterns(:workspace_exclude)
37
+ raise Error, "isolation must be true or false" unless [true, false].include?(@options[:isolation])
38
+
39
+ validate_jobs
40
+ validate_command
41
+ validate_mutation
42
+ timeout = @options[:timeout]
43
+ return if timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
44
+
45
+ raise Error, "timeout must be a positive finite number of seconds"
46
+ end
47
+
48
+ def to_h
49
+ @options.dup
50
+ end
51
+
52
+ private
53
+
54
+ def validate_jobs
55
+ jobs = @options[:jobs]
56
+ raise Error, "jobs must be a positive integer" unless jobs.is_a?(Integer) && jobs.positive?
57
+ raise Error, "jobs greater than 1 require isolation: true" if jobs > 1 && !@options[:isolation]
58
+ end
59
+
60
+ def validate_patterns(key)
61
+ patterns = @options[key]
62
+ return if patterns.is_a?(Array) && patterns.all? { |pattern| valid_string?(pattern) }
63
+
64
+ raise Error, "#{key} must be an array of non-empty glob strings"
65
+ end
66
+
67
+ def validate_command
68
+ command = @options[:test_command]
69
+ return if valid_string?(command)
70
+ return if command.is_a?(Array) && !command.empty? && command.all? { |part| valid_string?(part) }
71
+
72
+ raise Error, "test_command must be a non-empty string or argument array"
73
+ end
74
+
75
+ def valid_string?(value)
76
+ value.is_a?(String) && !value.strip.empty? && !value.include?("\0")
77
+ end
78
+
79
+ def validate_mutation
80
+ id = @options[:mutation]
81
+ return if id.nil? || (id.is_a?(String) && /\A[0-9a-f]{16}\z/.match?(id))
82
+
83
+ raise Error, "mutation must be a 16-character lowercase hexadecimal ID or null"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moots
4
+ # Relocate known Bundler and Ruby path variables without editing the parent environment.
5
+ module Environment
6
+ PATH_VARIABLES = %w[BUNDLE_GEMFILE BUNDLE_APP_CONFIG BUNDLE_BIN_PATH BUNDLE_PATH GEM_HOME].freeze
7
+ PATH_LISTS = %w[GEM_PATH RUBYLIB PATH].freeze
8
+
9
+ def self.rebase(source, &mapper)
10
+ (PATH_VARIABLES + PATH_LISTS).each_with_object({}) do |name, overrides|
11
+ [name, "BUNDLER_ORIG_#{name}"].each do |key|
12
+ next unless source.key?(key)
13
+
14
+ overrides[key] = if PATH_LISTS.include?(name)
15
+ source.fetch(key).split(File::PATH_SEPARATOR, -1).map(&mapper).join(File::PATH_SEPARATOR)
16
+ else
17
+ mapper.call(source.fetch(key))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moots
4
+ # An invalid configuration, source file, or test execution.
5
+ class Error < StandardError; end
6
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require "digest"
5
+ require "json"
6
+ require "pathname"
7
+
8
+ module Moots
9
+ # A single expression replacement, located by byte offset in the original source.
10
+ class Mutation
11
+ attr_reader :original_code, :mutated_code, :line_number, :file_path, :start_offset
12
+
13
+ def initialize(original_code:, mutated_code:, line_number:, file_path:, start_offset:)
14
+ @original_code = original_code
15
+ @mutated_code = mutated_code
16
+ @line_number = line_number
17
+ @file_path = file_path
18
+ @start_offset = start_offset
19
+ end
20
+
21
+ def relative_path(root: Dir.pwd)
22
+ Pathname.new(File.expand_path(file_path)).relative_path_from(Pathname.new(File.expand_path(root))).to_s
23
+ end
24
+
25
+ # Stable across equivalent checkouts; source bytes distinguish each replacement.
26
+ def id(root: Dir.pwd)
27
+ identity = [relative_path(root: root), start_offset, original_code.b.unpack1("H*"), mutated_code.b.unpack1("H*")]
28
+ Digest::SHA256.hexdigest(JSON.generate(identity))[0, 16]
29
+ end
30
+
31
+ # Return a changed copy without touching the input or replacing other occurrences.
32
+ def apply(code)
33
+ bytes = code.b
34
+ unless bytes.byteslice(start_offset, original_code.bytesize) == original_code.b
35
+ raise Error, "Source no longer matches mutation at #{file_path}:#{line_number}"
36
+ end
37
+
38
+ bytes[start_offset, original_code.bytesize] = mutated_code.b
39
+ bytes.force_encoding(code.encoding)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "mutation"
5
+
6
+ module Moots
7
+ # Generate one mutation per supported operator using Prism's byte locations.
8
+ class MutationGenerator
9
+ ARITHMETIC_OPERATORS = { "+" => "-", "-" => "+", "*" => "/", "/" => "*" }.freeze
10
+ COMPARISON_OPERATORS = { "==" => "!=", "!=" => "==", ">" => ">=", ">=" => ">", "<" => "<=", "<=" => "<" }.freeze
11
+ CALL_OPERATORS = ARITHMETIC_OPERATORS.merge(COMPARISON_OPERATORS).freeze
12
+ BOOLEAN_OPERATORS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
13
+
14
+ def generate_mutations(code, file_path: "(string)")
15
+ parsed = Prism.parse(code)
16
+ unless parsed.success?
17
+ error = parsed.errors.first
18
+ raise Error, "#{file_path}:#{error.location.start_line}: #{error.message}"
19
+ end
20
+
21
+ mutations = []
22
+ nodes = [parsed.value]
23
+ until nodes.empty?
24
+ node = nodes.pop
25
+ mutation = mutation_for(node, file_path)
26
+ mutations << mutation if mutation
27
+ nodes.concat(node.compact_child_nodes.reverse)
28
+ end
29
+ mutations
30
+ end
31
+
32
+ private
33
+
34
+ def mutation_for(node, file_path)
35
+ case node
36
+ when Prism::CallNode
37
+ replacement = CALL_OPERATORS[node.name.to_s]
38
+ return unless replacement
39
+
40
+ location = node.message_loc
41
+ when Prism::AndNode, Prism::OrNode
42
+ location = node.operator_loc
43
+ replacement = BOOLEAN_OPERATORS.fetch(location.slice)
44
+ else
45
+ return
46
+ end
47
+
48
+ expression = node.location
49
+ mutated = expression.slice.b
50
+ mutated[location.start_offset - expression.start_offset, location.length] = replacement
51
+ Mutation.new(
52
+ original_code: expression.slice,
53
+ mutated_code: mutated.force_encoding(expression.slice.encoding),
54
+ line_number: location.start_line,
55
+ file_path: file_path,
56
+ start_offset: expression.start_offset
57
+ )
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Moots
6
+ # Stream progress independently of the final, versioned machine-readable report.
7
+ class Reporter
8
+ def initialize(format:, out:, err:, config_path: nil, root: Dir.pwd)
9
+ @format = format
10
+ @out = out
11
+ @err = err
12
+ @progress = format == "json" ? err : out
13
+ @config_path = config_path
14
+ @root = root
15
+ @results = []
16
+ end
17
+
18
+ def call(event)
19
+ case event.fetch(:type)
20
+ when :workspace_started
21
+ @progress.puts("Preparing isolated working copy...")
22
+ when :baseline_started
23
+ @progress.puts("Running baseline tests...")
24
+ when :baseline_passed
25
+ @progress.puts("Baseline passed.")
26
+ when :mutation_started
27
+ @progress.puts("[#{event.fetch(:index)}/#{event.fetch(:total)}] Testing #{location(event.fetch(:mutation))}")
28
+ when :mutation_finished
29
+ result = event.fetch(:result)
30
+ @results[event.fetch(:index, @results.size)] = result
31
+ @progress.puts("#{result.killed ? "KILLED" : "SURVIVED"} #{location(result.mutation)}")
32
+ survivor(result.mutation) unless result.killed
33
+ else
34
+ raise ArgumentError, "Unknown progress event: #{event[:type]}"
35
+ end
36
+ @progress.flush
37
+ end
38
+
39
+ def finish(results)
40
+ @results = results
41
+ if @format == "json"
42
+ @out.puts(JSON.generate(document("completed")))
43
+ else
44
+ counts = summary
45
+ @out.puts("#{counts[:mutations]} mutations, #{counts[:killed]} killed, #{counts[:survived]} survived")
46
+ end
47
+ @out.flush
48
+ end
49
+
50
+ def error(message)
51
+ @err.puts("moots: #{text(message)}")
52
+ @err.flush
53
+ return unless @format == "json"
54
+
55
+ @out.puts(JSON.generate(document("error").merge(error: text(message))))
56
+ @out.flush
57
+ end
58
+
59
+ private
60
+
61
+ def text(value)
62
+ value.dup.force_encoding(Encoding::UTF_8).scrub
63
+ end
64
+
65
+ def location(mutation)
66
+ "#{mutation.id(root: @root)} #{text(mutation.relative_path(root: @root))}:#{mutation.line_number}"
67
+ end
68
+
69
+ def rerun(mutation)
70
+ command = %w[bundle exec moots]
71
+ command.push("--config", @config_path) if @config_path
72
+ command + ["--mutation", mutation.id(root: @root)]
73
+ end
74
+
75
+ def survivor(mutation)
76
+ @progress.puts(" Expression diff:")
77
+ text(mutation.original_code).each_line { |line| @progress.puts(" - #{line.chomp}") }
78
+ text(mutation.mutated_code).each_line { |line| @progress.puts(" + #{line.chomp}") }
79
+ @progress.puts(" Rerun argv: #{JSON.generate(rerun(mutation))}")
80
+ end
81
+
82
+ def summary
83
+ completed = @results.compact
84
+ killed = completed.count(&:killed)
85
+ { mutations: completed.size, killed: killed, survived: completed.size - killed }
86
+ end
87
+
88
+ def document(status)
89
+ serialized = @results.compact.map { |result| serialize(result) }
90
+ { schema_version: 1, status: status, summary: summary, results: serialized }
91
+ end
92
+
93
+ def serialize(result)
94
+ mutation = result.mutation
95
+ {
96
+ id: mutation.id(root: @root), file: text(mutation.relative_path(root: @root)),
97
+ line: mutation.line_number, start_offset: mutation.start_offset,
98
+ original: text(mutation.original_code), mutated: text(mutation.mutated_code),
99
+ status: result.killed ? "killed" : "survived", test_output: text(result.test_result.output),
100
+ rerun: rerun(mutation)
101
+ }
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moots
4
+ # The exit status and combined output of one test command.
5
+ class TestResult
6
+ attr_reader :output
7
+
8
+ def initialize(success:, output:)
9
+ @success = success
10
+ @output = output
11
+ end
12
+
13
+ def success?
14
+ @success
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require_relative "error"
6
+ require_relative "test_result"
7
+
8
+ module Moots
9
+ # Execute suites with a deadline and concurrently drain both output streams.
10
+ class TestRunner
11
+ def initialize(chdir: Dir.pwd, env: {})
12
+ @directory = chdir
13
+ @environment = env
14
+ end
15
+
16
+ def run_tests(command, timeout: 60)
17
+ options = Gem.win_platform? ? { new_pgroup: true } : { pgroup: true }
18
+ Open3.popen3(@environment, *Array(command), chdir: @directory, **options) do |stdin, stdout, stderr, process|
19
+ stdin.close
20
+ readers = [stdout, stderr].map { |stream| Thread.new { stream.read } }
21
+ completed = false
22
+ begin
23
+ result = Timeout.timeout(timeout) do
24
+ status = process.value
25
+ output = readers.map(&:value).reject(&:empty?).join("\n").strip
26
+ raise Error, "Test command terminated by signal #{status.termsig}" if status.signaled?
27
+ if [126, 127].include?(status.exitstatus)
28
+ raise Error, "Test command could not execute (exit #{status.exitstatus}): #{output}"
29
+ end
30
+
31
+ TestResult.new(success: status.success?, output: output)
32
+ end
33
+ completed = true
34
+ result
35
+ ensure
36
+ terminate(process.pid) unless completed
37
+ readers.each(&:kill) unless completed
38
+ readers.each(&:join)
39
+ end
40
+ end
41
+ rescue Timeout::Error
42
+ raise Error, "Test command timed out after #{timeout} seconds"
43
+ rescue SystemCallError => e
44
+ raise Error, "Cannot execute test command: #{e.message}"
45
+ end
46
+
47
+ private
48
+
49
+ def terminate(pid)
50
+ if Gem.win_platform?
51
+ system("taskkill", "/PID", pid.to_s, "/T", "/F", out: File::NULL, err: File::NULL)
52
+ else
53
+ Process.kill("KILL", -pid)
54
+ end
55
+ rescue Errno::ESRCH
56
+ # The process may have exited between the timeout and the kill.
57
+ nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moots
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moots
4
+ # Run work concurrently, but deliver callbacks and exceptions on the calling thread.
5
+ class WorkerPool
6
+ def initialize(workers, before:, after:)
7
+ @workers = workers
8
+ @before = before
9
+ @after = after
10
+ end
11
+
12
+ def map(items, &operation)
13
+ pending = Queue.new
14
+ items.each_with_index { |item, index| pending << [item, index] }
15
+ events = Queue.new
16
+ threads = []
17
+ begin
18
+ @workers.each { |worker| threads << start_worker(worker, pending, events, operation) }
19
+ results = collect(items.size, events)
20
+ threads.each(&:join)
21
+ results
22
+ ensure
23
+ threads.each(&:kill)
24
+ threads.each(&:join)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def take(queue)
31
+ queue.pop(true)
32
+ rescue ThreadError
33
+ nil
34
+ end
35
+
36
+ def start_worker(worker, pending, events, operation)
37
+ Thread.new do
38
+ while (entry = take(pending))
39
+ item, index = entry
40
+ ready = Queue.new
41
+ events << [:started, index, item, ready]
42
+ ready.pop
43
+ result = operation.call(worker, item)
44
+ events << [:finished, index, result]
45
+ end
46
+ # Relay every worker exception, including Interrupt, so the caller never waits forever.
47
+ rescue Exception => e # rubocop:disable Lint/RescueException
48
+ events << [:failed, e]
49
+ end
50
+ end
51
+
52
+ def collect(total, events)
53
+ results = Array.new(total)
54
+ total.times do
55
+ index, value = next_result(events)
56
+ results[index] = value
57
+ @after.call(value, index)
58
+ end
59
+ results
60
+ end
61
+
62
+ def next_result(events)
63
+ loop do
64
+ type, index, value, ready = events.pop
65
+ if type == :started
66
+ @before.call(value, index)
67
+ ready << true
68
+ elsif type == :failed
69
+ raise index
70
+ else
71
+ return [index, value]
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end