active_mutator 0.2.0 → 0.4.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 +4 -4
- data/README.md +79 -14
- data/lib/active_mutator/baseline.rb +44 -6
- data/lib/active_mutator/baseline_delta.rb +36 -16
- data/lib/active_mutator/baseline_hooks.rb +15 -6
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +22 -2
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +4 -2
- data/lib/active_mutator/config_file.rb +15 -5
- data/lib/active_mutator/engine.rb +106 -5
- data/lib/active_mutator/reporter/stryker_json.rb +13 -2
- data/lib/active_mutator/reporter/terminal.rb +13 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +136 -14
- data/lib/active_mutator/subject.rb +6 -2
- data/lib/active_mutator/subject_finder.rb +45 -4
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/worker.rb +50 -8
- data/lib/active_mutator.rb +2 -0
- metadata +12 -5
|
@@ -10,9 +10,11 @@ module ActiveMutator
|
|
|
10
10
|
def call
|
|
11
11
|
ENV["ACTIVE_MUTATOR"] = "1"
|
|
12
12
|
load_operators
|
|
13
|
+
ClosureReload.cap = @config.class_level_closure_cap
|
|
13
14
|
preload!
|
|
14
15
|
preload_spec_helper!
|
|
15
|
-
map = Baseline.new(root: @config.root
|
|
16
|
+
map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
|
|
17
|
+
.coverage_map(force: @config.force_baseline)
|
|
16
18
|
@reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
|
|
17
19
|
subjects = discover_subjects
|
|
18
20
|
analyses = subjects.map { |s| Engine.new.analyze(s) }
|
|
@@ -25,7 +27,7 @@ module ActiveMutator
|
|
|
25
27
|
scanned_files = prune_scope(subjects)
|
|
26
28
|
warn_stale(ledger, fingerprints.values, scanned_files)
|
|
27
29
|
|
|
28
|
-
items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
30
|
+
items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
29
31
|
return debug_plan(items, pre_results) if @config.debug_plan
|
|
30
32
|
|
|
31
33
|
pre_results.each { |r| @reporter.on_result(r) }
|
|
@@ -35,6 +37,8 @@ module ActiveMutator
|
|
|
35
37
|
scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
|
|
36
38
|
calibrators: calibrators)
|
|
37
39
|
results = scheduler.run(items) + pre_results
|
|
40
|
+
# Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
|
|
41
|
+
results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
|
|
38
42
|
|
|
39
43
|
accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
|
|
40
44
|
|
|
@@ -42,7 +46,9 @@ module ActiveMutator
|
|
|
42
46
|
exit_code(results)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
# Returns [work_items, pre_results].
|
|
49
|
+
# Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
|
|
50
|
+
# planned mutation to the example ids it was scheduled against, so phase 2
|
|
51
|
+
# escalation can subtract what was already run. Public for unit testing.
|
|
46
52
|
def plan_work(mutations, map, ledger: nil, fingerprints: {})
|
|
47
53
|
items = []
|
|
48
54
|
pre_results = []
|
|
@@ -51,19 +57,75 @@ module ActiveMutator
|
|
|
51
57
|
pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
|
|
52
58
|
next
|
|
53
59
|
end
|
|
54
|
-
example_ids =
|
|
60
|
+
example_ids = examples_for_mutation(mutation, map)
|
|
55
61
|
if example_ids.empty?
|
|
56
62
|
pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
|
|
57
63
|
else
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
items << build_work_item(mutation, example_ids, map)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
|
|
68
|
+
[items, pre_results, phase1_ids]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Phase 2 of the class-body kill pipeline (public for unit testing).
|
|
72
|
+
# A class-body survivor is only DECLARED after every spec file that
|
|
73
|
+
# references the constant has had its shot: re-enqueue against the
|
|
74
|
+
# referencing files phase 1 didn't run, and take the escalated verdict.
|
|
75
|
+
#
|
|
76
|
+
# `scheduler` is injectable for unit tests; in the normal run it is nil and
|
|
77
|
+
# a dedicated escalation scheduler is built lazily (only when there is
|
|
78
|
+
# phase-2 work) with NO on_result — escalation is a refinement pass, and
|
|
79
|
+
# reporting through the live callback would print a second status char for a
|
|
80
|
+
# mutant already streamed in phase 1. The final summary reflects the
|
|
81
|
+
# escalated verdicts regardless.
|
|
82
|
+
def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
|
|
83
|
+
candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
|
|
84
|
+
# Perf gate: skip reading the whole spec suite into memory in the common
|
|
85
|
+
# case of no class-body survivors. (Deleting this line is a behavioral
|
|
86
|
+
# no-op — the later `items.empty?` return still guards correctness — so
|
|
87
|
+
# its mutant is a known equivalent.)
|
|
88
|
+
return results if candidates.empty?
|
|
89
|
+
|
|
90
|
+
spec_contents = BaselineDelta.spec_file_contents(root: @config.root, spec_paths: @config.spec_paths)
|
|
91
|
+
patterns = {} # subject file => constant-reference pattern (parsed once per file)
|
|
92
|
+
items = {}
|
|
93
|
+
candidates.each do |r|
|
|
94
|
+
file = r.mutation.subject.file
|
|
95
|
+
pattern = patterns.fetch(file) do
|
|
96
|
+
patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
|
|
97
|
+
end
|
|
98
|
+
next unless pattern
|
|
99
|
+
|
|
100
|
+
ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
|
|
101
|
+
next if ids.empty?
|
|
102
|
+
|
|
103
|
+
items[r.mutation] = build_work_item(r.mutation, ids, map)
|
|
104
|
+
end
|
|
105
|
+
return results if items.empty?
|
|
106
|
+
|
|
107
|
+
scheduler ||= Scheduler.new(jobs: @config.jobs)
|
|
108
|
+
escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
|
|
109
|
+
results.map do |r|
|
|
110
|
+
# A replacement only ever exists for a survived candidate (items is
|
|
111
|
+
# built solely from those), so no redundant status re-check is needed.
|
|
112
|
+
replacement = escalated[r.mutation]
|
|
113
|
+
next r unless replacement
|
|
114
|
+
|
|
115
|
+
case replacement.status
|
|
116
|
+
when :killed
|
|
117
|
+
replacement
|
|
118
|
+
when :survived
|
|
119
|
+
extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
|
|
120
|
+
replacement.with(details: "escalated (+#{extra} spec files)")
|
|
121
|
+
else
|
|
122
|
+
# A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
|
|
123
|
+
# already survived phase 1, so keep that verdict rather than letting
|
|
124
|
+
# an inconclusive escalation inflate the score (a :timeout counts as
|
|
125
|
+
# detected in exit_code/score).
|
|
126
|
+
r
|
|
64
127
|
end
|
|
65
128
|
end
|
|
66
|
-
[items, pre_results]
|
|
67
129
|
end
|
|
68
130
|
|
|
69
131
|
def exit_code(results)
|
|
@@ -78,12 +140,45 @@ module ActiveMutator
|
|
|
78
140
|
|
|
79
141
|
private
|
|
80
142
|
|
|
143
|
+
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
144
|
+
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
145
|
+
def build_work_item(mutation, example_ids, map)
|
|
146
|
+
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
147
|
+
variable = map.time_for(example_ids) * @config.timeout_factor
|
|
148
|
+
boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
|
|
149
|
+
timeout = variable + @config.timeout_floor + boot_extra
|
|
150
|
+
WorkItem.new(mutation: mutation, example_ids: example_ids,
|
|
151
|
+
timeout: timeout, lane: lane, variable: variable)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Spec files that textually match `pattern` (a constant-reference pattern
|
|
155
|
+
# for the subject's file, built via BaselineDelta.constant_reference_pattern
|
|
156
|
+
# so the escaping/word-boundary rules stay shared), minus everything phase 1
|
|
157
|
+
# already ran; returned as example ids.
|
|
158
|
+
#
|
|
159
|
+
# Two deliberate choices: (a) matching is TEXTUAL, so a constant named in a
|
|
160
|
+
# comment or string still counts — intentional, since the worst case is a
|
|
161
|
+
# wasted run and the verdict stays correct; (b) unlike
|
|
162
|
+
# BaselineDelta.newly_covering_candidates there is intentionally NO fan-out
|
|
163
|
+
# ceiling here — a class-body survivor gets every referencing spec its shot
|
|
164
|
+
# before being declared.
|
|
165
|
+
def escalation_examples(map, spec_contents, phase1_example_ids, pattern)
|
|
166
|
+
phase1_files = phase1_example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq
|
|
167
|
+
spec_contents.filter_map do |abs, content|
|
|
168
|
+
rel = abs.delete_prefix(@config.root.chomp("/") + "/")
|
|
169
|
+
next if phase1_files.include?(rel)
|
|
170
|
+
next unless content.match?(pattern)
|
|
171
|
+
|
|
172
|
+
map.examples_for_spec_file(rel)
|
|
173
|
+
end.flatten.uniq.sort
|
|
174
|
+
end
|
|
175
|
+
|
|
81
176
|
# Custom operators must exist in the PARENT before Engine analysis:
|
|
82
177
|
# subclassing Operators::Base self-registers, and forks inherit the
|
|
83
178
|
# loaded class. `requires` can't serve — those load inside the fork's
|
|
84
179
|
# setup, after mutations are already planned.
|
|
85
180
|
def load_operators
|
|
86
|
-
@config.
|
|
181
|
+
@config.operators.each do |f|
|
|
87
182
|
require File.expand_path(f, @config.root)
|
|
88
183
|
rescue LoadError, SyntaxError => e
|
|
89
184
|
raise Error, "operator file not loadable: #{f}: #{e.message}"
|
|
@@ -98,6 +193,25 @@ module ActiveMutator
|
|
|
98
193
|
mutation.lines.to_a | mutation.subject.line_range.to_a
|
|
99
194
|
end
|
|
100
195
|
|
|
196
|
+
# Class-body lines execute at load time, so line coverage never
|
|
197
|
+
# attributes examples to them. Substitute: every example that covers ANY
|
|
198
|
+
# line of the file (it must have loaded the class), plus the convention
|
|
199
|
+
# spec file's examples. Phase 2 (escalation) widens further before a
|
|
200
|
+
# survivor is declared.
|
|
201
|
+
def examples_for_mutation(mutation, map)
|
|
202
|
+
return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
|
|
203
|
+
|
|
204
|
+
convention_examples = convention_spec_rels(mutation.subject.file)
|
|
205
|
+
.flat_map { |rel| map.examples_for_spec_file(rel) }
|
|
206
|
+
(map.examples_covering_file(mutation.subject.file) | convention_examples).sort
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def convention_spec_rels(file)
|
|
210
|
+
rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
|
|
211
|
+
rest = rel.sub(%r{\A[^/]+/}, "")
|
|
212
|
+
@config.spec_paths.map { |sp| "#{sp}/#{rest}_spec.rb" }
|
|
213
|
+
end
|
|
214
|
+
|
|
101
215
|
def build_reporter
|
|
102
216
|
case @config.format
|
|
103
217
|
when :json then Reporter::Json.new
|
|
@@ -126,6 +240,7 @@ module ActiveMutator
|
|
|
126
240
|
.uniq
|
|
127
241
|
.reject { |file| excluded?(file) }
|
|
128
242
|
.sort.flat_map { |file| SubjectFinder.call(file) }
|
|
243
|
+
subjects = subjects.reject(&:class_body?) unless @config.class_level
|
|
129
244
|
if @config.subject_filter
|
|
130
245
|
matcher = SubjectMatcher.new(@config.subject_filter)
|
|
131
246
|
subjects = subjects.select { |s| matcher.match?(s.name) }
|
|
@@ -180,7 +295,11 @@ module ActiveMutator
|
|
|
180
295
|
helper = if @config.preload_helper
|
|
181
296
|
File.expand_path(@config.preload_helper, @config.root)
|
|
182
297
|
else
|
|
183
|
-
|
|
298
|
+
# Precedence: within each spec path rails_helper wins over
|
|
299
|
+
# spec_helper; earlier spec paths win over later ones — same
|
|
300
|
+
# as today for the default ["spec"].
|
|
301
|
+
@config.spec_paths
|
|
302
|
+
.flat_map { |sp| ["#{sp}/rails_helper.rb", "#{sp}/spec_helper.rb"] }
|
|
184
303
|
.map { |p| File.join(@config.root, p) }
|
|
185
304
|
.find { |p| File.exist?(p) }
|
|
186
305
|
end
|
|
@@ -210,8 +329,11 @@ module ActiveMutator
|
|
|
210
329
|
# MAINTENANCE: any future flag that narrows the mutant set below "every
|
|
211
330
|
# subject in the scanned files" MUST be added to this nil-trigger list,
|
|
212
331
|
# or scoped accept runs will clobber out-of-scope ledger entries (#24).
|
|
332
|
+
# --no-class-level drops every class_body subject (discover_subjects), so a
|
|
333
|
+
# file's class-body fingerprint is absent even though the file is scanned;
|
|
334
|
+
# without this guard its accepted ledger entry looks stale and gets pruned.
|
|
213
335
|
def prune_scope(subjects)
|
|
214
|
-
return nil if @config.subject_filter || @config.since || @config.max_mutants
|
|
336
|
+
return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
|
|
215
337
|
|
|
216
338
|
subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
|
|
217
339
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# A mutable unit: one method definition
|
|
3
|
-
# byte_range/line_range cover the whole `def ... end
|
|
2
|
+
# A mutable unit. kind :instance/:singleton = one method definition
|
|
3
|
+
# (byte_range/line_range cover the whole `def ... end`). kind :class_body =
|
|
4
|
+
# the class-level code of one class/module (byte_range covers the whole
|
|
5
|
+
# class/module node; Engine only mutates non-def body statements).
|
|
4
6
|
# sclass: def lives inside `class << self` — its source slice is `def foo`,
|
|
5
7
|
# so Inserter must target the singleton class, not the constant itself.
|
|
6
8
|
Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
|
|
@@ -9,5 +11,7 @@ module ActiveMutator
|
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def singleton? = kind == :singleton
|
|
14
|
+
|
|
15
|
+
def class_body? = kind == :class_body
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -11,16 +11,25 @@ module ActiveMutator
|
|
|
11
11
|
skip_lines = result.comments
|
|
12
12
|
.select { |c| c.slice.match?(SKIP_MARKER) }
|
|
13
13
|
.to_set { |c| c.location.start_line }
|
|
14
|
-
finder = new(file, skip_lines: skip_lines
|
|
14
|
+
finder = new(file, skip_lines: skip_lines,
|
|
15
|
+
class_level: zeitwerk_shaped?(result.value))
|
|
15
16
|
finder.visit(result.value)
|
|
16
17
|
finder.subjects
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
# Class-body subjects only for Zeitwerk-shaped files: exactly one
|
|
21
|
+
# top-level constant. Multi-constant files and core-class reopens have no
|
|
22
|
+
# safe remove_const + re-eval story (issue #32). Shared with ClosureReload.
|
|
23
|
+
def self.zeitwerk_shaped?(program)
|
|
24
|
+
ClassShape.single_top_level_constant?(program)
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
attr_reader :subjects
|
|
20
28
|
|
|
21
|
-
def initialize(file, skip_lines: Set.new)
|
|
29
|
+
def initialize(file, skip_lines: Set.new, class_level: true)
|
|
22
30
|
@file = file
|
|
23
31
|
@skip_lines = skip_lines
|
|
32
|
+
@class_level = class_level
|
|
24
33
|
@stack = []
|
|
25
34
|
@subjects = []
|
|
26
35
|
@sclass_depth = 0
|
|
@@ -33,13 +42,19 @@ module ActiveMutator
|
|
|
33
42
|
def visit_class_node(node)
|
|
34
43
|
return if @sclass_depth.positive?
|
|
35
44
|
|
|
36
|
-
with_scope(node.constant_path.slice)
|
|
45
|
+
with_scope(node.constant_path.slice) do
|
|
46
|
+
add_class_body_subject(node)
|
|
47
|
+
super
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
def visit_module_node(node)
|
|
40
52
|
return if @sclass_depth.positive?
|
|
41
53
|
|
|
42
|
-
with_scope(node.constant_path.slice)
|
|
54
|
+
with_scope(node.constant_path.slice) do
|
|
55
|
+
add_class_body_subject(node)
|
|
56
|
+
super
|
|
57
|
+
end
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# `class << self` inside a constant scope: defs there are singleton
|
|
@@ -85,6 +100,32 @@ module ActiveMutator
|
|
|
85
100
|
|
|
86
101
|
private
|
|
87
102
|
|
|
103
|
+
# One subject for the class-level code of this class/module. Only if the
|
|
104
|
+
# body has at least one statement the class-body walk can mutate: defs
|
|
105
|
+
# and nested class/modules are owned by other subjects.
|
|
106
|
+
def add_class_body_subject(node)
|
|
107
|
+
return unless @class_level
|
|
108
|
+
return if @skip_lines.include?(node.location.start_line - 1)
|
|
109
|
+
|
|
110
|
+
body = node.body
|
|
111
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
112
|
+
return if body.body.all? { |s| owned_by_other_subject?(s) }
|
|
113
|
+
|
|
114
|
+
scope = @stack.join("::")
|
|
115
|
+
loc = node.location
|
|
116
|
+
@subjects << Subject.new(
|
|
117
|
+
name: "#{scope} (class body)",
|
|
118
|
+
file: @file,
|
|
119
|
+
byte_range: loc.start_offset...loc.end_offset,
|
|
120
|
+
line_range: loc.start_line..loc.end_line,
|
|
121
|
+
constant_scope: scope,
|
|
122
|
+
kind: :class_body,
|
|
123
|
+
sclass: false
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def owned_by_other_subject?(node) = ClassShape.owned_by_other_subject?(node)
|
|
128
|
+
|
|
88
129
|
def with_scope(name)
|
|
89
130
|
@stack.push(name)
|
|
90
131
|
yield
|
|
@@ -2,12 +2,32 @@ require "json"
|
|
|
2
2
|
require "set"
|
|
3
3
|
|
|
4
4
|
module ActiveMutator
|
|
5
|
-
# Runs INSIDE a fork.
|
|
6
|
-
# files,
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# the
|
|
5
|
+
# Runs INSIDE a fork. Insertion order relative to RSpec's setup (which loads
|
|
6
|
+
# the spec files, and with them the app) DIFFERS by mutant kind:
|
|
7
|
+
#
|
|
8
|
+
# Class-body: insert BEFORE setup. `RSpec.describe SomeClass` binds
|
|
9
|
+
# `metadata[:described_class]` to the constant AT LOAD TIME, and a class-body
|
|
10
|
+
# mutant reloads the constant to a NEW object via ClosureReload; a group
|
|
11
|
+
# loaded first would keep the pre-mutation object and falsely survive. So we
|
|
12
|
+
# require the subject file, reload, THEN let setup load the groups — every
|
|
13
|
+
# group binds to the mutated object.
|
|
14
|
+
#
|
|
15
|
+
# Def: insert AFTER setup. A def mutant class_evals the live method in
|
|
16
|
+
# place, so it must be the LAST thing to touch that method. Inserting before
|
|
17
|
+
# setup let a file loaded during spec-load (a concern/decorator/monkeypatch
|
|
18
|
+
# that reopens the class but isn't transitively required by the subject
|
|
19
|
+
# file) silently redefine the method back to the original, reporting a false
|
|
20
|
+
# survivor. Loading everything first, then inserting, closes that window.
|
|
21
|
+
# Requiring the subject after setup also means spec_helper's load-time setup
|
|
22
|
+
# runs first, so a subject that depends on it still loads in non-preloaded
|
|
23
|
+
# projects.
|
|
24
|
+
#
|
|
25
|
+
# The explicit `require` of the subject file guarantees the target constant
|
|
26
|
+
# exists before insertion regardless of preload: preloaded projects
|
|
27
|
+
# (Rails/Zeitwerk, or a preloaded spec helper) already have it in
|
|
28
|
+
# $LOADED_FEATURES so it's a no-op, while non-preloaded projects (plain
|
|
29
|
+
# gems whose spec files require the lib themselves, or --no-preload-helper)
|
|
30
|
+
# get it loaded rather than relying on spec-load to define it.
|
|
11
31
|
class Worker
|
|
12
32
|
def self.run(mutation, example_ids, writer)
|
|
13
33
|
new(mutation, example_ids, writer).run
|
|
@@ -23,20 +43,42 @@ module ActiveMutator
|
|
|
23
43
|
require "rspec/core"
|
|
24
44
|
devnull = File.open(File::NULL, "w")
|
|
25
45
|
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
|
|
26
|
-
|
|
46
|
+
if @mutation.subject.class_body?
|
|
47
|
+
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
48
|
+
insert_mutation # BEFORE setup: groups bind described_class to the mutated object
|
|
49
|
+
runner.setup(devnull, devnull) # loads spec files
|
|
50
|
+
else
|
|
51
|
+
runner.setup(devnull, devnull) # loads spec files -> the app, in dependency order
|
|
52
|
+
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
53
|
+
insert_mutation # AFTER load: nothing left can redefine the method back
|
|
54
|
+
end
|
|
27
55
|
# One failure kills the mutant; running the rest of the covering set
|
|
28
56
|
# is pure waste inside the fork.
|
|
29
57
|
RSpec.configuration.fail_fast = 1
|
|
30
|
-
Inserter.new.insert(@mutation) # now the target constant exists
|
|
31
58
|
after_fork_hygiene
|
|
32
59
|
code = runner.run_specs(covering_groups)
|
|
33
60
|
emit(code.zero? ? "survived" : "killed")
|
|
61
|
+
rescue ClosureReload::Skip => e
|
|
62
|
+
emit("skipped", details: e.message)
|
|
63
|
+
rescue ClosureReload::MutantLoadError => e
|
|
64
|
+
# The mutation made the class unloadable; a real suite would fail on it.
|
|
65
|
+
emit("killed", details: "mutated class failed to load: #{e.message}")
|
|
34
66
|
rescue StandardError, ScriptError => e
|
|
35
67
|
emit("error", details: "#{e.class}: #{e.message}")
|
|
36
68
|
end
|
|
37
69
|
|
|
38
70
|
private
|
|
39
71
|
|
|
72
|
+
# Def mutants class_eval over the live constant; class-body mutants
|
|
73
|
+
# cannot (macros accumulate) and go through whole-file closure reload.
|
|
74
|
+
def insert_mutation
|
|
75
|
+
if @mutation.subject.class_body?
|
|
76
|
+
ClosureReload.new(@mutation.subject, @mutation.mutated_file_source).call
|
|
77
|
+
else
|
|
78
|
+
Inserter.new.insert(@mutation)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
40
82
|
def after_fork_hygiene
|
|
41
83
|
srand
|
|
42
84
|
if defined?(ActiveRecord::Base)
|
data/lib/active_mutator.rb
CHANGED
|
@@ -10,6 +10,7 @@ end
|
|
|
10
10
|
require_relative "active_mutator/edit"
|
|
11
11
|
require_relative "active_mutator/splicer"
|
|
12
12
|
require_relative "active_mutator/subject"
|
|
13
|
+
require_relative "active_mutator/class_shape"
|
|
13
14
|
require_relative "active_mutator/subject_finder"
|
|
14
15
|
require_relative "active_mutator/subject_matcher"
|
|
15
16
|
require_relative "active_mutator/operators/base"
|
|
@@ -30,6 +31,7 @@ require_relative "active_mutator/baseline"
|
|
|
30
31
|
require_relative "active_mutator/baseline_delta"
|
|
31
32
|
require_relative "active_mutator/defined_constants"
|
|
32
33
|
require_relative "active_mutator/inserter"
|
|
34
|
+
require_relative "active_mutator/closure_reload"
|
|
33
35
|
require_relative "active_mutator/worker"
|
|
34
36
|
require_relative "active_mutator/result"
|
|
35
37
|
require_relative "active_mutator/work_item"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_mutator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel John
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -17,6 +17,9 @@ dependencies:
|
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '0.30'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '2'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -24,18 +27,21 @@ dependencies:
|
|
|
24
27
|
- - ">="
|
|
25
28
|
- !ruby/object:Gem::Version
|
|
26
29
|
version: '0.30'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2'
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
34
|
name: rspec-core
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
30
36
|
requirements:
|
|
31
|
-
- - "
|
|
37
|
+
- - "~>"
|
|
32
38
|
- !ruby/object:Gem::Version
|
|
33
39
|
version: '3.12'
|
|
34
40
|
type: :runtime
|
|
35
41
|
prerelease: false
|
|
36
42
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
43
|
requirements:
|
|
38
|
-
- - "
|
|
44
|
+
- - "~>"
|
|
39
45
|
- !ruby/object:Gem::Version
|
|
40
46
|
version: '3.12'
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
@@ -72,7 +78,9 @@ files:
|
|
|
72
78
|
- lib/active_mutator/baseline.rb
|
|
73
79
|
- lib/active_mutator/baseline_delta.rb
|
|
74
80
|
- lib/active_mutator/baseline_hooks.rb
|
|
81
|
+
- lib/active_mutator/class_shape.rb
|
|
75
82
|
- lib/active_mutator/cli.rb
|
|
83
|
+
- lib/active_mutator/closure_reload.rb
|
|
76
84
|
- lib/active_mutator/config.rb
|
|
77
85
|
- lib/active_mutator/config_file.rb
|
|
78
86
|
- lib/active_mutator/coverage_map.rb
|
|
@@ -114,7 +122,6 @@ licenses:
|
|
|
114
122
|
- MIT
|
|
115
123
|
metadata:
|
|
116
124
|
rubygems_mfa_required: 'true'
|
|
117
|
-
homepage_uri: https://github.com/drj613/active_mutator
|
|
118
125
|
source_code_uri: https://github.com/drj613/active_mutator
|
|
119
126
|
changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
|
|
120
127
|
bug_tracker_uri: https://github.com/drj613/active_mutator/issues
|