active_mutator 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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +244 -0
  4. data/exe/active_mutator +4 -0
  5. data/lib/active_mutator/accepted_ledger.rb +45 -0
  6. data/lib/active_mutator/analysis.rb +3 -0
  7. data/lib/active_mutator/atomic_file.rb +15 -0
  8. data/lib/active_mutator/baseline.rb +125 -0
  9. data/lib/active_mutator/baseline_delta.rb +57 -0
  10. data/lib/active_mutator/baseline_hooks.rb +68 -0
  11. data/lib/active_mutator/cli.rb +53 -0
  12. data/lib/active_mutator/config.rb +8 -0
  13. data/lib/active_mutator/coverage_map.rb +55 -0
  14. data/lib/active_mutator/edit.rb +5 -0
  15. data/lib/active_mutator/engine.rb +75 -0
  16. data/lib/active_mutator/fingerprint.rb +21 -0
  17. data/lib/active_mutator/inserter.rb +18 -0
  18. data/lib/active_mutator/mutation.rb +11 -0
  19. data/lib/active_mutator/operators/base.rb +25 -0
  20. data/lib/active_mutator/operators/call_swap.rb +28 -0
  21. data/lib/active_mutator/operators/condition_forcing.rb +17 -0
  22. data/lib/active_mutator/operators/conditional_boundary.rb +16 -0
  23. data/lib/active_mutator/operators/early_return.rb +16 -0
  24. data/lib/active_mutator/operators/literal.rb +37 -0
  25. data/lib/active_mutator/operators/logical_operator.rb +24 -0
  26. data/lib/active_mutator/operators/negation_removal.rb +11 -0
  27. data/lib/active_mutator/operators/statement_deletion.rb +15 -0
  28. data/lib/active_mutator/reporter/json.rb +40 -0
  29. data/lib/active_mutator/reporter/terminal.rb +50 -0
  30. data/lib/active_mutator/result.rb +6 -0
  31. data/lib/active_mutator/runner.rb +143 -0
  32. data/lib/active_mutator/scheduler.rb +125 -0
  33. data/lib/active_mutator/since_filter.rb +48 -0
  34. data/lib/active_mutator/splicer.rb +13 -0
  35. data/lib/active_mutator/subject.rb +7 -0
  36. data/lib/active_mutator/subject_finder.rb +56 -0
  37. data/lib/active_mutator/version.rb +3 -0
  38. data/lib/active_mutator/work_item.rb +4 -0
  39. data/lib/active_mutator/worker.rb +64 -0
  40. data/lib/active_mutator.rb +42 -0
  41. metadata +132 -0
@@ -0,0 +1,125 @@
1
+ require "json"
2
+
3
+ module ActiveMutator
4
+ # Fork pool: one fork per WorkItem, capped at `jobs` concurrent forks.
5
+ # Parent enforces per-item deadlines with SIGKILL (worker-side timeouts
6
+ # cannot interrupt all infinite loops).
7
+ class Scheduler
8
+ def initialize(jobs:, worker: Worker.method(:run), on_result: nil)
9
+ @jobs = jobs
10
+ @worker = worker
11
+ @on_result = on_result
12
+ end
13
+
14
+ def run(items)
15
+ previous_traps = nil
16
+ running = {}
17
+ previous_traps = install_signal_handlers(running)
18
+ results = []
19
+ # Browser-covered mutants each boot Chrome + an app server; running them
20
+ # concurrently melts CPUs and manufactures false timeouts. Parallel lane
21
+ # first at full width, then the serial lane one at a time.
22
+ results.concat(run_pool(items.select { |i| i.lane == :parallel }, @jobs, running))
23
+ results.concat(run_pool(items.select { |i| i.lane == :serial }, 1, running))
24
+ results
25
+ ensure
26
+ restore_traps(previous_traps) if previous_traps
27
+ end
28
+
29
+ private
30
+
31
+ def run_pool(items, width, running)
32
+ queue = items.dup
33
+ results = []
34
+ until queue.empty? && running.empty?
35
+ spawn(queue.shift, running) while running.size < width && !queue.empty?
36
+ reap(running, results)
37
+ sleep 0.02 unless running.empty?
38
+ end
39
+ results
40
+ end
41
+
42
+ def spawn(item, running)
43
+ reader, writer = IO.pipe
44
+ pid = fork do
45
+ reader.close
46
+ Process.setpgid(0, 0) # own process group: deadline kill reaps grandchildren too
47
+ $stdout.reopen(File::NULL) # app code that prints must not corrupt parent's report
48
+ $stderr.reopen(File::NULL) # ditto for warnings (RSpec/app noise interleaves with reports)
49
+ @worker.call(item.mutation, item.example_ids, writer)
50
+ writer.close
51
+ Process.exit!(0)
52
+ end
53
+ writer.close
54
+ running[pid] = { reader: reader, item: item, deadline: now + item.timeout }
55
+ end
56
+
57
+ def reap(running, results)
58
+ running.to_a.each do |pid, entry|
59
+ done, _status = Process.waitpid2(pid, Process::WNOHANG)
60
+ if done
61
+ running.delete(pid)
62
+ results << finish(entry)
63
+ elsif now > entry[:deadline]
64
+ kill(pid)
65
+ running.delete(pid)
66
+ entry[:reader].close
67
+ results << report(Result.new(mutation: entry[:item].mutation, status: :timeout, details: nil))
68
+ end
69
+ end
70
+ end
71
+
72
+ def finish(entry)
73
+ payload = entry[:reader].read.to_s
74
+ entry[:reader].close
75
+ data = payload.empty? ? nil : JSON.parse(payload)
76
+ status = data ? data.fetch("status").to_sym : :error
77
+ details = data ? data["details"] : "worker exited without reporting"
78
+ report(Result.new(mutation: entry[:item].mutation, status: status, details: details))
79
+ end
80
+
81
+ def report(result)
82
+ @on_result&.call(result)
83
+ result
84
+ end
85
+
86
+ def kill(pid)
87
+ Process.kill("KILL", -pid) # negative pid = whole process group
88
+ rescue Errno::ESRCH, Errno::EPERM
89
+ # Group not established yet (setpgid race) or already gone — direct kill.
90
+ begin
91
+ Process.kill("KILL", pid)
92
+ rescue Errno::ESRCH
93
+ nil
94
+ end
95
+ ensure
96
+ begin
97
+ Process.waitpid(pid)
98
+ rescue Errno::ECHILD
99
+ nil
100
+ end
101
+ end
102
+
103
+ # Returns {sig => previous_handler} so #run can restore on exit —
104
+ # otherwise our traps permanently replace the host's (e.g. RSpec's Ctrl-C).
105
+ def install_signal_handlers(running)
106
+ %w[INT TERM].to_h do |sig|
107
+ previous = trap(sig) do
108
+ running.each_key do |pid|
109
+ Process.kill("KILL", -pid)
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ exit(130)
114
+ end
115
+ [sig, previous]
116
+ end
117
+ end
118
+
119
+ def restore_traps(previous)
120
+ previous.each { |sig, handler| trap(sig, handler || "DEFAULT") }
121
+ end
122
+
123
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
124
+ end
125
+ end
@@ -0,0 +1,48 @@
1
+ module ActiveMutator
2
+ # Restricts subjects to methods overlapping lines changed since a git ref.
3
+ # Known v1 limit: `git diff <ref>` omits untracked files, so brand-new
4
+ # uncommitted files are skipped.
5
+ class SinceFilter
6
+ HUNK = /\A@@ [^+]*\+(\d+)(?:,(\d+))? @@/
7
+
8
+ def self.parse(diff_text)
9
+ changed = Hash.new { |h, k| h[k] = [] }
10
+ current = nil
11
+ diff_text.each_line do |line|
12
+ if line.start_with?("+++ b/")
13
+ current = line.delete_prefix("+++ b/").strip
14
+ elsif current && (match = HUNK.match(line))
15
+ start = match[1].to_i
16
+ count = (match[2] || "1").to_i
17
+ count.times { |i| changed[current] << start + i }
18
+ end
19
+ end
20
+ changed.reject { |_, lines| lines.empty? }
21
+ end
22
+
23
+ def initialize(ref:, root:)
24
+ @root = root
25
+ diff = IO.popen(
26
+ ["git", "-C", root, "diff", "--unified=0", ref, "--", "*.rb"], &:read
27
+ )
28
+ raise Error, "git diff #{ref} failed" unless $?.success?
29
+
30
+ @changed = self.class.parse(diff)
31
+ untracked = IO.popen(
32
+ ["git", "-C", root, "ls-files", "--others", "--exclude-standard", "--", "*.rb"], &:read
33
+ )
34
+ # Untracked files are invisible to `git diff` but are agentic TDD's most
35
+ # common case (brand-new file + spec). Whole-file sentinel: every line
36
+ # counts as changed.
37
+ untracked.each_line { |l| @changed[l.strip] = :all unless l.strip.empty? }
38
+ end
39
+
40
+ def cover?(subject)
41
+ lines = @changed[subject.file.delete_prefix("#{@root}/")]
42
+ return false unless lines
43
+ return true if lines == :all
44
+
45
+ lines.any? { |line| subject.line_range.cover?(line) }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,13 @@
1
+ module ActiveMutator
2
+ module Splicer
3
+ # Applies edits to source by byte offset. Edits are applied back-to-front
4
+ # so earlier offsets never drift.
5
+ def self.apply(source, edits)
6
+ bytes = source.b
7
+ edits.sort_by { |e| -e.range.begin }.each do |e|
8
+ bytes[e.range] = e.replacement.b
9
+ end
10
+ bytes.force_encoding(source.encoding)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveMutator
2
+ # A mutable unit: one method definition.
3
+ # byte_range/line_range cover the whole `def ... end`.
4
+ Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind) do
5
+ def singleton? = kind == :singleton
6
+ end
7
+ end
@@ -0,0 +1,56 @@
1
+ module ActiveMutator
2
+ class SubjectFinder < Prism::Visitor
3
+ def self.call(file)
4
+ result = Prism.parse(File.read(file))
5
+ return [] unless result.success?
6
+
7
+ finder = new(file)
8
+ finder.visit(result.value)
9
+ finder.subjects
10
+ end
11
+
12
+ attr_reader :subjects
13
+
14
+ def initialize(file)
15
+ @file = file
16
+ @stack = []
17
+ @subjects = []
18
+ super()
19
+ end
20
+
21
+ def visit_class_node(node)
22
+ with_scope(node.constant_path.slice) { super }
23
+ end
24
+
25
+ def visit_module_node(node)
26
+ with_scope(node.constant_path.slice) { super }
27
+ end
28
+
29
+ # `class << self` bodies are a documented v1 limit: not visited.
30
+ def visit_singleton_class_node(node); end
31
+
32
+ def visit_def_node(node)
33
+ singleton = node.receiver.is_a?(Prism::SelfNode)
34
+ scope = @stack.empty? ? nil : @stack.join("::")
35
+ loc = node.location
36
+ @subjects << Subject.new(
37
+ name: "#{scope || "Object"}#{singleton ? "." : "#"}#{node.name}",
38
+ file: @file,
39
+ byte_range: loc.start_offset...loc.end_offset,
40
+ line_range: loc.start_line..loc.end_line,
41
+ constant_scope: scope,
42
+ kind: singleton ? :singleton : :instance
43
+ )
44
+ # No `super`: nested defs are out of scope for v1.
45
+ end
46
+
47
+ private
48
+
49
+ def with_scope(name)
50
+ @stack.push(name)
51
+ yield
52
+ ensure
53
+ @stack.pop
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,3 @@
1
+ module ActiveMutator
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,4 @@
1
+ module ActiveMutator
2
+ # lane: :parallel (default pool) | :serial (browser-covered, one at a time)
3
+ WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane)
4
+ end
@@ -0,0 +1,64 @@
1
+ require "json"
2
+ require "set"
3
+
4
+ module ActiveMutator
5
+ # Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
6
+ # files, whose spec_helper/rails_helper loads the application — only THEN
7
+ # can the mutation be inserted over the loaded original. Insert-first would
8
+ # NameError on any project not preloaded in the parent (all non-Rails
9
+ # projects), and loading app code after insertion would silently restore
10
+ # the original method.
11
+ class Worker
12
+ def self.run(mutation, example_ids, writer)
13
+ new(mutation, example_ids, writer).run
14
+ end
15
+
16
+ def initialize(mutation, example_ids, writer)
17
+ @mutation = mutation
18
+ @example_ids = example_ids
19
+ @writer = writer
20
+ end
21
+
22
+ def run
23
+ require "rspec/core"
24
+ devnull = File.open(File::NULL, "w")
25
+ runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
26
+ runner.setup(devnull, devnull) # loads spec files -> loads the app
27
+ Inserter.new.insert(@mutation) # now the target constant exists
28
+ after_fork_hygiene
29
+ code = runner.run_specs(covering_groups)
30
+ emit(code.zero? ? "survived" : "killed")
31
+ rescue StandardError, ScriptError => e
32
+ emit("error", details: "#{e.class}: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ def after_fork_hygiene
38
+ srand
39
+ if defined?(ActiveRecord::Base)
40
+ ActiveRecord::Base.connection_handler.clear_all_connections!
41
+ ActiveRecord::Base.establish_connection
42
+ end
43
+ end
44
+
45
+ def emit(status, details: nil)
46
+ @writer.puts(JSON.generate("status" => status, "details" => details))
47
+ @writer.flush if @writer.respond_to?(:flush)
48
+ end
49
+
50
+ # RSpec.world holds every group registered in the process — including any
51
+ # top-level groups evaluated while the PARENT preloaded the spec helper
52
+ # (spec/support files with RSpec.describe at load time are common). Those
53
+ # leak into the fork; running them would report their failures as false
54
+ # kills. Run only groups that belong to the covering spec files.
55
+ def covering_groups
56
+ covering = @example_ids
57
+ .map { |id| File.expand_path(id[/\A(.+?)\[/, 1]) }
58
+ .to_set
59
+ RSpec.world.ordered_example_groups.select do |group|
60
+ covering.include?(group.metadata[:absolute_file_path])
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,42 @@
1
+ require "prism"
2
+
3
+ require_relative "active_mutator/version"
4
+
5
+ module ActiveMutator
6
+ Error = Class.new(StandardError)
7
+ BaselineFailed = Class.new(Error)
8
+ end
9
+
10
+ require_relative "active_mutator/edit"
11
+ require_relative "active_mutator/splicer"
12
+ require_relative "active_mutator/subject"
13
+ require_relative "active_mutator/subject_finder"
14
+ require_relative "active_mutator/operators/base"
15
+ require_relative "active_mutator/operators/conditional_boundary"
16
+ require_relative "active_mutator/operators/condition_forcing"
17
+ require_relative "active_mutator/operators/logical_operator"
18
+ require_relative "active_mutator/operators/literal"
19
+ require_relative "active_mutator/operators/statement_deletion"
20
+ require_relative "active_mutator/operators/early_return"
21
+ require_relative "active_mutator/operators/call_swap"
22
+ require_relative "active_mutator/operators/negation_removal"
23
+ require_relative "active_mutator/mutation"
24
+ require_relative "active_mutator/analysis"
25
+ require_relative "active_mutator/engine"
26
+ require_relative "active_mutator/atomic_file"
27
+ require_relative "active_mutator/coverage_map"
28
+ require_relative "active_mutator/baseline"
29
+ require_relative "active_mutator/baseline_delta"
30
+ require_relative "active_mutator/inserter"
31
+ require_relative "active_mutator/worker"
32
+ require_relative "active_mutator/result"
33
+ require_relative "active_mutator/work_item"
34
+ require_relative "active_mutator/scheduler"
35
+ require_relative "active_mutator/reporter/terminal"
36
+ require_relative "active_mutator/reporter/json"
37
+ require_relative "active_mutator/since_filter"
38
+ require_relative "active_mutator/fingerprint"
39
+ require_relative "active_mutator/accepted_ledger"
40
+ require_relative "active_mutator/config"
41
+ require_relative "active_mutator/runner"
42
+ require_relative "active_mutator/cli"
metadata ADDED
@@ -0,0 +1,132 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_mutator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel John
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: prism
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.30'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.30'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ description: 'Mutation testing for Ruby and Rails: Prism-based source-span mutations
56
+ (no unparser), coverage-mapped test selection, and a fork-per-mutant kill pipeline.
57
+ Scopes to changed methods for a fast dev loop, or a diff for CI, with an incremental
58
+ coverage baseline and a committed acceptance ledger for equivalent mutants.'
59
+ email:
60
+ executables:
61
+ - active_mutator
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE
66
+ - README.md
67
+ - exe/active_mutator
68
+ - lib/active_mutator.rb
69
+ - lib/active_mutator/accepted_ledger.rb
70
+ - lib/active_mutator/analysis.rb
71
+ - lib/active_mutator/atomic_file.rb
72
+ - lib/active_mutator/baseline.rb
73
+ - lib/active_mutator/baseline_delta.rb
74
+ - lib/active_mutator/baseline_hooks.rb
75
+ - lib/active_mutator/cli.rb
76
+ - lib/active_mutator/config.rb
77
+ - lib/active_mutator/coverage_map.rb
78
+ - lib/active_mutator/edit.rb
79
+ - lib/active_mutator/engine.rb
80
+ - lib/active_mutator/fingerprint.rb
81
+ - lib/active_mutator/inserter.rb
82
+ - lib/active_mutator/mutation.rb
83
+ - lib/active_mutator/operators/base.rb
84
+ - lib/active_mutator/operators/call_swap.rb
85
+ - lib/active_mutator/operators/condition_forcing.rb
86
+ - lib/active_mutator/operators/conditional_boundary.rb
87
+ - lib/active_mutator/operators/early_return.rb
88
+ - lib/active_mutator/operators/literal.rb
89
+ - lib/active_mutator/operators/logical_operator.rb
90
+ - lib/active_mutator/operators/negation_removal.rb
91
+ - lib/active_mutator/operators/statement_deletion.rb
92
+ - lib/active_mutator/reporter/json.rb
93
+ - lib/active_mutator/reporter/terminal.rb
94
+ - lib/active_mutator/result.rb
95
+ - lib/active_mutator/runner.rb
96
+ - lib/active_mutator/scheduler.rb
97
+ - lib/active_mutator/since_filter.rb
98
+ - lib/active_mutator/splicer.rb
99
+ - lib/active_mutator/subject.rb
100
+ - lib/active_mutator/subject_finder.rb
101
+ - lib/active_mutator/version.rb
102
+ - lib/active_mutator/work_item.rb
103
+ - lib/active_mutator/worker.rb
104
+ homepage: https://github.com/drj613/active_mutator
105
+ licenses:
106
+ - MIT
107
+ metadata:
108
+ rubygems_mfa_required: 'true'
109
+ homepage_uri: https://github.com/drj613/active_mutator
110
+ source_code_uri: https://github.com/drj613/active_mutator
111
+ changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
112
+ bug_tracker_uri: https://github.com/drj613/active_mutator/issues
113
+ post_install_message:
114
+ rdoc_options: []
115
+ require_paths:
116
+ - lib
117
+ required_ruby_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '3.2'
122
+ required_rubygems_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ requirements: []
128
+ rubygems_version: 3.5.16
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Mutation testing for Ruby, built on Prism
132
+ test_files: []