active_mutator 0.4.1 → 0.5.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 +25 -8
- data/lib/active_mutator/baseline_delta.rb +6 -1
- data/lib/active_mutator/class_shape.rb +19 -0
- data/lib/active_mutator/cli.rb +2 -1
- data/lib/active_mutator/config.rb +1 -1
- data/lib/active_mutator/config_file.rb +2 -1
- data/lib/active_mutator/engine.rb +22 -16
- data/lib/active_mutator/reporter/json.rb +8 -1
- data/lib/active_mutator/reporter/terminal.rb +12 -6
- data/lib/active_mutator/runner.rb +21 -4
- data/lib/active_mutator/scheduler.rb +29 -4
- data/lib/active_mutator/subject.rb +7 -2
- data/lib/active_mutator/subject_finder.rb +36 -4
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/worker.rb +4 -3
- metadata +3 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45e58d5b8880a6716b7a78ee4011f0e5529600b3daa222ad39854723d08bd9d3
|
|
4
|
+
data.tar.gz: d9c9067771e0596e3241d8a0bb408c3c9f041e98eac33f08066b1118feedb5b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e6881dce81b23302b40220a9b593b290b22b08eb5f6c4426da96e2f5557e369cd635525f32aa357078c6ae10f289c05d216b025992ce23ff607841b42b71b1b
|
|
7
|
+
data.tar.gz: 25fbb644044f506fcc4663ed26be6898187a4205a91778fdfc049240ecf7c1bb54a52f81b38e2ab9f59f75c00f0bbcd5d93b8ad6bc0d7c7a149d90cdc4f69cf4
|
data/README.md
CHANGED
|
@@ -58,7 +58,7 @@ changed and nothing noticed. A survivor is not a hypothetical. It is the
|
|
|
58
58
|
exact line, the exact before and after diff, and proof that no assertion
|
|
59
59
|
depends on the difference.
|
|
60
60
|
|
|
61
|
-
Mutation score is `(killed + timeout) / (killed + timeout + survived)`.
|
|
61
|
+
Mutation score is `(killed + timeout) / (killed + timeout + survived + error)`.
|
|
62
62
|
100% is usually not the right target. Some mutants are behaviorally
|
|
63
63
|
*equivalent* to the original and can never be killed by any test. That is
|
|
64
64
|
why active_mutator has a committed acceptance ledger. It lets you close
|
|
@@ -121,17 +121,20 @@ Each character on the progress line is one mutant, printed as it finishes:
|
|
|
121
121
|
|---|---|---|
|
|
122
122
|
| `.` | `killed` | a covering test failed. Good, the mutant is dead |
|
|
123
123
|
| `S` | `survived` | every covering test passed. This is a test gap |
|
|
124
|
-
| `T` | `timeout` | ran past its time budget.
|
|
125
|
-
| `E` | `error` | the worker crashed, or the mutated code raised outside a test assertion |
|
|
124
|
+
| `T` | `timeout` | ran past its time budget. Counts as detected (the mutant likely made a loop never end), and the summary lists each one with elapsed vs budget so a tight budget is visible |
|
|
125
|
+
| `E` | `error` | the worker crashed, or the mutated code raised outside a test assertion. Not detected: counts against the score and fails the run |
|
|
126
126
|
| `U` | `uncovered` | no test executes the mutated line at all. This is coverage debt, worse than a survivor |
|
|
127
127
|
| `A` | `accepted` | matches a known-equivalent entry in the acceptance ledger. Excluded from the score |
|
|
128
128
|
|
|
129
129
|
`invalid` mutants (edits that don't even re-parse as valid Ruby) are
|
|
130
130
|
discarded before scheduling and reported as a count only. Exit code is `1`
|
|
131
|
-
if unaccepted survivors exist (or, with `--fail-at`, if the score
|
|
132
|
-
the threshold), `0` otherwise, including when there are only
|
|
133
|
-
`
|
|
134
|
-
|
|
131
|
+
if unaccepted survivors or errors exist (or, with `--fail-at`, if the score
|
|
132
|
+
is below the threshold), `0` otherwise, including when there are only
|
|
133
|
+
`uncovered` or `accepted` results. The JSON report's `exit_reason` field
|
|
134
|
+
(`unaccepted_survivors`, `worker_errors`, `clean`) is independent of the
|
|
135
|
+
`--fail-at` gate. A `--since`
|
|
136
|
+
or `--subject` run that plans zero mutants prints no score; it warns with the
|
|
137
|
+
cause and exits `1` unless `--allow-empty` is given.
|
|
135
138
|
|
|
136
139
|
When survivors exist, the summary also prints a per-operator table showing
|
|
137
140
|
how often each operator's mutants survive, to help spot likely-equivalent
|
|
@@ -198,7 +201,7 @@ score is below the threshold). Mistyped positional paths (a file that
|
|
|
198
201
|
doesn't exist, or a non-`.rb` file) are an error (exit 2) instead of a
|
|
199
202
|
vacuous green run.
|
|
200
203
|
|
|
201
|
-
Score = (killed + timeout) / (killed + timeout + survived).
|
|
204
|
+
Score = (killed + timeout) / (killed + timeout + survived + error).
|
|
202
205
|
|
|
203
206
|
## The dev loop
|
|
204
207
|
|
|
@@ -253,6 +256,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
|
253
256
|
| `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
|
|
254
257
|
| `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
|
|
255
258
|
| `--debug-plan` | off | print planned mutants as JSON and exit without running |
|
|
259
|
+
| `--allow-empty` | off | exit 0 when `--since`/`--subject` plan no mutants (default: warn and exit 1) |
|
|
256
260
|
| `--format terminal\|json\|stryker-json\|github` | terminal | report format |
|
|
257
261
|
| `--accept-survivors` | off | record survivors to the acceptance ledger |
|
|
258
262
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
@@ -401,6 +405,19 @@ change. Also run `bundle exec active_mutator --changed` on your own diff
|
|
|
401
405
|
before sending a change that touches `lib/`. This is a good idea for the
|
|
402
406
|
same reason you'd want it run on any other codebase.
|
|
403
407
|
|
|
408
|
+
If a run dies with `baseline suite failed` and a `LoadError` mentioning
|
|
409
|
+
`bundler-2.x/lib/gems/bundler-2.x/exe/bundle`, your Ruby manager (seen with
|
|
410
|
+
mise) breaks nested `bundle exec`: the baseline shells out to `bundle exec
|
|
411
|
+
rspec`, and bundler's exported `RUBYLIB` makes the inner binstub resolve the
|
|
412
|
+
wrong path. Skip the outer bundler instead:
|
|
413
|
+
|
|
414
|
+
```sh
|
|
415
|
+
ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The `:e2e` specs nest `bundle exec` on their own inside the fixture project,
|
|
419
|
+
so on such a machine they fail either way; rely on the CI `e2e` job for those.
|
|
420
|
+
|
|
404
421
|
## License
|
|
405
422
|
|
|
406
423
|
[MIT](LICENSE).
|
|
@@ -64,8 +64,13 @@ module ActiveMutator
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# An example id passed alongside its own file makes RSpec run ONLY the
|
|
68
|
+
# listed ids from that file, so a changed spec file's new examples would
|
|
69
|
+
# never be recorded. A whole-file rerun subsumes the ids.
|
|
70
|
+
rerun_spec_files = rerun_spec_files.uniq.sort
|
|
71
|
+
rerun_example_ids = rerun_example_ids.reject { |id| rerun_spec_files.include?(spec_file_of(id)) }
|
|
67
72
|
Delta.new(full: false,
|
|
68
|
-
rerun_spec_files: rerun_spec_files
|
|
73
|
+
rerun_spec_files: rerun_spec_files,
|
|
69
74
|
rerun_example_ids: rerun_example_ids.uniq.sort,
|
|
70
75
|
drop_example_ids: drop_example_ids.uniq.sort,
|
|
71
76
|
drop_source_files: drop_source_files.uniq.sort)
|
|
@@ -43,5 +43,24 @@ module ActiveMutator
|
|
|
43
43
|
node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) ||
|
|
44
44
|
node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
# ActiveSupport::Concern DSL calls whose block body re-runs as class-level
|
|
48
|
+
# code in the includer (`included`, `prepended`) or is module_eval'd into
|
|
49
|
+
# the concern's ClassMethods module (`class_methods`). SubjectFinder gives
|
|
50
|
+
# the defs inside their own subjects; Engine's class-body walk owns the rest.
|
|
51
|
+
CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
|
|
52
|
+
|
|
53
|
+
def concern_dsl_block?(node)
|
|
54
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
55
|
+
CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Where the search for concern-block defs stops: a def below one of these
|
|
59
|
+
# is not on the concern's scope, so it gets no subject (same limit as
|
|
60
|
+
# SubjectFinder#visit_block_node) and stays with the class-body walk.
|
|
61
|
+
def concern_def_boundary?(node)
|
|
62
|
+
node.is_a?(Prism::BlockNode) || node.is_a?(Prism::ClassNode) ||
|
|
63
|
+
node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
|
|
64
|
+
end
|
|
46
65
|
end
|
|
47
66
|
end
|
data/lib/active_mutator/cli.rb
CHANGED
|
@@ -32,7 +32,7 @@ module ActiveMutator
|
|
|
32
32
|
spec_paths: ["spec"],
|
|
33
33
|
browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
|
|
34
34
|
max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
|
|
35
|
-
operators: [], class_level: true, class_level_closure_cap: 10
|
|
35
|
+
operators: [], class_level: true, class_level_closure_cap: 10, allow_empty: false
|
|
36
36
|
}
|
|
37
37
|
options.merge!(ConfigFile.load(Dir.pwd))
|
|
38
38
|
paths = OptionParser.new do |o|
|
|
@@ -66,6 +66,7 @@ module ActiveMutator
|
|
|
66
66
|
o.on("--exclude PAT", "Skip files matching glob, relative to root (repeatable)") { |v| options[:exclude] << v }
|
|
67
67
|
o.on("--max-mutants N", Integer, "Deterministically sample the first N mutants") { |v| options[:max_mutants] = v }
|
|
68
68
|
o.on("--debug-plan", "Print the planned mutant list as JSON and exit") { options[:debug_plan] = true }
|
|
69
|
+
o.on("--allow-empty", "Exit 0 when --since/--subject plan no mutants (default: exit 1)") { options[:allow_empty] = true }
|
|
69
70
|
o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
|
|
70
71
|
raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
|
|
71
72
|
options[:fail_at] = v
|
|
@@ -25,7 +25,8 @@ module ActiveMutator
|
|
|
25
25
|
"preload_helper" => :preload_helper,
|
|
26
26
|
"adaptive_timeout" => :boolean,
|
|
27
27
|
"class_level" => :boolean,
|
|
28
|
-
"class_level_closure_cap" => :positive_integer
|
|
28
|
+
"class_level_closure_cap" => :positive_integer,
|
|
29
|
+
"allow_empty" => :boolean
|
|
29
30
|
}.freeze
|
|
30
31
|
|
|
31
32
|
def self.load(root)
|
|
@@ -77,14 +77,7 @@ module ActiveMutator
|
|
|
77
77
|
|
|
78
78
|
def owned_statement?(node) = ClassShape.owned_by_other_subject?(node)
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
# code in the includer, so it is in scope for class-body mutation.
|
|
82
|
-
CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
|
|
83
|
-
|
|
84
|
-
def concern_dsl_block?(node)
|
|
85
|
-
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
86
|
-
CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
|
|
87
|
-
end
|
|
80
|
+
def concern_dsl_block?(node) = ClassShape.concern_dsl_block?(node)
|
|
88
81
|
|
|
89
82
|
# No nil guard needed (unlike #walk): the entry node is the class body's
|
|
90
83
|
# StatementsNode, guaranteed present for a class-body subject, and
|
|
@@ -95,20 +88,33 @@ module ActiveMutator
|
|
|
95
88
|
elsif node.is_a?(Prism::BlockNode)
|
|
96
89
|
# Pruned: a block's run-time context is unknown (see collect comment).
|
|
97
90
|
elsif concern_dsl_block?(node)
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
# itself is not yielded: the whole-block deletion edit already comes
|
|
103
|
-
# from the enclosing StatementsNode, and no operator targets a bare
|
|
104
|
-
# receiverless call.
|
|
105
|
-
walk(node.block.body, &blk)
|
|
91
|
+
# The concern call node itself is not yielded: the whole-block deletion
|
|
92
|
+
# edit already comes from the enclosing StatementsNode, and no operator
|
|
93
|
+
# targets a bare receiverless call.
|
|
94
|
+
concern_walk(node.block.body, owned, &blk)
|
|
106
95
|
else
|
|
107
96
|
yield node
|
|
108
97
|
node.compact_child_nodes.each { |child| class_walk(child, owned, &blk) }
|
|
109
98
|
end
|
|
110
99
|
end
|
|
111
100
|
|
|
101
|
+
# Inside a concern block, defs are subjects of their own (SubjectFinder
|
|
102
|
+
# emits them; mirrors ClassShape.concern_dsl_block?), so they are owned
|
|
103
|
+
# here. Everything else mutates like the def-level #walk, including the
|
|
104
|
+
# interior of nested blocks, exactly as before defs got their own subjects.
|
|
105
|
+
def concern_walk(node, owned, &blk)
|
|
106
|
+
return if node.nil?
|
|
107
|
+
|
|
108
|
+
if node.is_a?(Prism::DefNode)
|
|
109
|
+
owned << (node.location.start_offset...node.location.end_offset)
|
|
110
|
+
elsif ClassShape.concern_def_boundary?(node)
|
|
111
|
+
walk(node, &blk)
|
|
112
|
+
else
|
|
113
|
+
yield node
|
|
114
|
+
node.compact_child_nodes.each { |child| concern_walk(child, owned, &blk) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
112
118
|
# The mutant is the whole file. The def-shaped fields are filled with the
|
|
113
119
|
# file source so the Mutation shape stays uniform; Worker routes
|
|
114
120
|
# class-body mutants through ClosureReload (whole-file re-eval), never
|
|
@@ -17,12 +17,19 @@ module ActiveMutator
|
|
|
17
17
|
"invalid" => invalid_count,
|
|
18
18
|
"operators" => OperatorStats.call(results),
|
|
19
19
|
"results" => results.map { |r| serialize(r) },
|
|
20
|
-
"exit_reason" => counts
|
|
20
|
+
"exit_reason" => exit_reason(counts)
|
|
21
21
|
)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
|
+
def exit_reason(counts)
|
|
27
|
+
return "unaccepted_survivors" if counts.fetch(:survived, 0).positive?
|
|
28
|
+
return "worker_errors" if counts.fetch(:error, 0).positive?
|
|
29
|
+
|
|
30
|
+
"clean"
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
def serialize(result)
|
|
27
34
|
m = result.mutation
|
|
28
35
|
{
|
|
@@ -20,8 +20,9 @@ module ActiveMutator
|
|
|
20
20
|
end
|
|
21
21
|
@out.puts "invalid (discarded): #{invalid_count}"
|
|
22
22
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
print_group("Surviving mutants:", results.select { |r| r.status == :survived })
|
|
24
|
+
print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
|
|
25
|
+
print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
|
|
25
26
|
skipped = results.select { |r| r.status == :skipped }
|
|
26
27
|
print_skipped(skipped) unless skipped.empty?
|
|
27
28
|
stats = OperatorStats.call(results)
|
|
@@ -29,9 +30,12 @@ module ActiveMutator
|
|
|
29
30
|
print_operator_stats(noisy) unless noisy.empty?
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# A timeout is a detection (the mutant changed behavior enough to hang,
|
|
34
|
+
# the same convention as Stryker and PIT). An error is a non-verdict:
|
|
35
|
+
# scoring it as a pass let a broken worker read as 100%.
|
|
32
36
|
def self.score(counts)
|
|
33
37
|
detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
|
|
34
|
-
denominator = detected + counts.fetch(:survived, 0)
|
|
38
|
+
denominator = detected + counts.fetch(:survived, 0) + counts.fetch(:error, 0)
|
|
35
39
|
return 1.0 if denominator.zero?
|
|
36
40
|
|
|
37
41
|
detected.to_f / denominator
|
|
@@ -49,9 +53,11 @@ module ActiveMutator
|
|
|
49
53
|
end
|
|
50
54
|
end
|
|
51
55
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
def print_group(title, group)
|
|
57
|
+
return if group.empty?
|
|
58
|
+
|
|
59
|
+
@out.puts "", title
|
|
60
|
+
group.each do |result|
|
|
55
61
|
m = result.mutation
|
|
56
62
|
@out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
|
|
57
63
|
@out.puts " #{m.description}"
|
|
@@ -29,6 +29,7 @@ module ActiveMutator
|
|
|
29
29
|
|
|
30
30
|
items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
31
31
|
return debug_plan(items, pre_results) if @config.debug_plan
|
|
32
|
+
return empty_plan_exit if mutations.empty? && (@config.since || @config.subject_filter)
|
|
32
33
|
|
|
33
34
|
pre_results.each { |r| @reporter.on_result(r) }
|
|
34
35
|
calibrators = if @config.adaptive_timeout
|
|
@@ -119,7 +120,7 @@ module ActiveMutator
|
|
|
119
120
|
extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
|
|
120
121
|
replacement.with(details: "escalated (+#{extra} spec files)")
|
|
121
122
|
else
|
|
122
|
-
# A timeout/error/skip in phase 2 did NOT prove a kill
|
|
123
|
+
# A timeout/error/skip in phase 2 did NOT prove a kill; the mutant
|
|
123
124
|
# already survived phase 1, so keep that verdict rather than letting
|
|
124
125
|
# an inconclusive escalation inflate the score (a :timeout counts as
|
|
125
126
|
# detected in exit_code/score).
|
|
@@ -129,17 +130,33 @@ module ActiveMutator
|
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
def exit_code(results)
|
|
132
|
-
|
|
133
|
-
return 0 if
|
|
133
|
+
undetected = results.count { |r| %i[survived error].include?(r.status) }
|
|
134
|
+
return 0 if undetected.zero?
|
|
134
135
|
return 1 unless @config.fail_at
|
|
135
136
|
|
|
136
137
|
detected = results.count { |r| %i[killed timeout].include?(r.status) }
|
|
137
|
-
score = detected * 100.0 / (detected +
|
|
138
|
+
score = detected * 100.0 / (detected + undetected)
|
|
138
139
|
score >= @config.fail_at ? 0 : 1
|
|
139
140
|
end
|
|
140
141
|
|
|
141
142
|
private
|
|
142
143
|
|
|
144
|
+
# A scoped run that plans nothing must not report "100%" and pass --fail-at:
|
|
145
|
+
# the usual cause is a --since range or --subject filter that matched no
|
|
146
|
+
# mutable code, or class-body code dropped by --no-class-level (#23 covers
|
|
147
|
+
# the zero-subject case for explicit paths).
|
|
148
|
+
def empty_plan_exit
|
|
149
|
+
causes = []
|
|
150
|
+
causes << "--since #{@config.since} matched no mutable code" if @config.since
|
|
151
|
+
causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
|
|
152
|
+
causes << "--no-class-level excludes class-body code" unless @config.class_level
|
|
153
|
+
warn "active_mutator: no mutants planned (#{causes.join("; ")})"
|
|
154
|
+
return 0 if @config.allow_empty
|
|
155
|
+
|
|
156
|
+
warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
|
|
157
|
+
1
|
|
158
|
+
end
|
|
159
|
+
|
|
143
160
|
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
144
161
|
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
145
162
|
def build_work_item(mutation, example_ids, map)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "tempfile"
|
|
2
3
|
|
|
3
4
|
module ActiveMutator
|
|
4
5
|
# Fork pool: one fork per WorkItem, capped at `jobs` concurrent forks.
|
|
@@ -62,13 +63,20 @@ module ActiveMutator
|
|
|
62
63
|
raise OrphanedError, "parent process died; aborting mutation run"
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
STDERR_TAIL_LINES = 20
|
|
67
|
+
|
|
65
68
|
def spawn(item, running)
|
|
66
69
|
reader, writer = IO.pipe
|
|
70
|
+
stderr_file = Tempfile.new("active_mutator-worker")
|
|
67
71
|
pid = fork do
|
|
68
72
|
reader.close
|
|
69
73
|
Process.setpgid(0, 0) # own process group: deadline kill reaps grandchildren too
|
|
70
74
|
$stdout.reopen(File::NULL) # app code that prints must not corrupt parent's report
|
|
71
|
-
$stderr.reopen(
|
|
75
|
+
$stderr.reopen(stderr_file.path, "w") # kept for the crash report, never shown otherwise
|
|
76
|
+
# After fork(), libpq's GSS encryption negotiation touches Apple
|
|
77
|
+
# frameworks and segfaults the child on macOS; disabling it is
|
|
78
|
+
# harmless everywhere else.
|
|
79
|
+
ENV["PGGSSENCMODE"] ||= "disable"
|
|
72
80
|
@worker.call(item.mutation, item.example_ids, writer)
|
|
73
81
|
writer.close
|
|
74
82
|
Process.exit!(0)
|
|
@@ -78,7 +86,7 @@ module ActiveMutator
|
|
|
78
86
|
budget = calibrator ? calibrator.budget_for(item) : item.timeout
|
|
79
87
|
log_scale(calibrator, item.lane)
|
|
80
88
|
started = now
|
|
81
|
-
running[pid] = { reader: reader, item: item, started: started,
|
|
89
|
+
running[pid] = { reader: reader, item: item, started: started, stderr_file: stderr_file,
|
|
82
90
|
budget: budget, deadline: started + budget }
|
|
83
91
|
end
|
|
84
92
|
|
|
@@ -96,7 +104,9 @@ module ActiveMutator
|
|
|
96
104
|
kill(pid)
|
|
97
105
|
running.delete(pid)
|
|
98
106
|
entry[:reader].close
|
|
99
|
-
|
|
107
|
+
entry[:stderr_file].close!
|
|
108
|
+
details = format("timed out after %.1fs (budget %.1fs)", now - entry[:started], entry[:budget])
|
|
109
|
+
results << report(Result.new(mutation: entry[:item].mutation, status: :timeout, details: details))
|
|
100
110
|
end
|
|
101
111
|
end
|
|
102
112
|
end
|
|
@@ -104,13 +114,14 @@ module ActiveMutator
|
|
|
104
114
|
def finish(entry)
|
|
105
115
|
payload = entry[:reader].read.to_s
|
|
106
116
|
entry[:reader].close
|
|
117
|
+
stderr_tail = stderr_tail(entry[:stderr_file])
|
|
107
118
|
data = payload.empty? ? nil : JSON.parse(payload)
|
|
108
119
|
# A self-mutation of Worker#emit can produce well-formed JSON without a
|
|
109
120
|
# "status" key (or with a non-Hash root); treat any unusable payload as
|
|
110
121
|
# a worker error instead of crashing the whole run.
|
|
111
122
|
reported = data.is_a?(Hash) && data.key?("status")
|
|
112
123
|
status = reported ? data["status"].to_sym : :error
|
|
113
|
-
details = reported ? data["details"] :
|
|
124
|
+
details = reported ? data["details"] : unreported_details(stderr_tail)
|
|
114
125
|
rescue JSON::ParserError
|
|
115
126
|
report(Result.new(mutation: entry[:item].mutation, status: :error,
|
|
116
127
|
details: "worker emitted unparseable payload"))
|
|
@@ -123,6 +134,20 @@ module ActiveMutator
|
|
|
123
134
|
result
|
|
124
135
|
end
|
|
125
136
|
|
|
137
|
+
# The child wrote through its own descriptor (reopened by path), so this
|
|
138
|
+
# handle is still at offset 0: no rewind needed.
|
|
139
|
+
def stderr_tail(file)
|
|
140
|
+
file.read.to_s.lines.last(STDERR_TAIL_LINES).join.strip
|
|
141
|
+
ensure
|
|
142
|
+
file.close!
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def unreported_details(stderr_tail)
|
|
146
|
+
return "worker exited without reporting" if stderr_tail.empty?
|
|
147
|
+
|
|
148
|
+
"worker exited without reporting; stderr tail:\n#{stderr_tail}"
|
|
149
|
+
end
|
|
150
|
+
|
|
126
151
|
def kill(pid)
|
|
127
152
|
Process.kill("KILL", -pid) # negative pid = whole process group
|
|
128
153
|
rescue Errno::ESRCH, Errno::EPERM
|
|
@@ -5,13 +5,18 @@ module ActiveMutator
|
|
|
5
5
|
# class/module node; Engine only mutates non-def body statements).
|
|
6
6
|
# sclass: def lives inside `class << self` — its source slice is `def foo`,
|
|
7
7
|
# so Inserter must target the singleton class, not the constant itself.
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# reload: def lives inside a concern's `included`/`prepended` block, so it
|
|
9
|
+
# lands on the includer, not on the constant; the only faithful insertion is
|
|
10
|
+
# the same whole-file closure reload a class-body mutant gets.
|
|
11
|
+
Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass, :reload) do
|
|
12
|
+
def initialize(name:, file:, byte_range:, line_range:, constant_scope:, kind:, sclass: false, reload: false)
|
|
10
13
|
super
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def singleton? = kind == :singleton
|
|
14
17
|
|
|
15
18
|
def class_body? = kind == :class_body
|
|
19
|
+
|
|
20
|
+
def reload? = class_body? || reload
|
|
16
21
|
end
|
|
17
22
|
end
|
|
@@ -79,6 +79,31 @@ module ActiveMutator
|
|
|
79
79
|
def visit_block_node(node); end
|
|
80
80
|
|
|
81
81
|
def visit_def_node(node)
|
|
82
|
+
add_def_subject(node)
|
|
83
|
+
# No `super`: nested defs get no subject of their own -- their bodies
|
|
84
|
+
# are mutated via the OUTER def (Engine#walk descends into them).
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ActiveSupport::Concern blocks are the exception to visit_block_node.
|
|
88
|
+
# `class_methods do` is module_eval'd into `Scope::ClassMethods`, so its
|
|
89
|
+
# defs are plain subjects on that constant, exactly as if written in
|
|
90
|
+
# `module ClassMethods`. `included`/`prepended` defs land on the includer,
|
|
91
|
+
# which Inserter cannot target; they are reload subjects (whole-file
|
|
92
|
+
# closure reload), which needs the same Zeitwerk-shape gate as class-body
|
|
93
|
+
# subjects.
|
|
94
|
+
def visit_call_node(node)
|
|
95
|
+
return super unless @sclass_depth.zero? && !@stack.empty? && ClassShape.concern_dsl_block?(node)
|
|
96
|
+
|
|
97
|
+
if node.name == :class_methods
|
|
98
|
+
with_scope("ClassMethods") { each_concern_def(node.block.body) { |d| add_def_subject(d) } }
|
|
99
|
+
elsif @class_level
|
|
100
|
+
each_concern_def(node.block.body) { |d| add_def_subject(d, reload: true) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def add_def_subject(node, reload: false)
|
|
82
107
|
return if @skip_lines.include?(node.location.start_line - 1)
|
|
83
108
|
|
|
84
109
|
sclass = @sclass_depth.positive?
|
|
@@ -92,13 +117,20 @@ module ActiveMutator
|
|
|
92
117
|
line_range: loc.start_line..loc.end_line,
|
|
93
118
|
constant_scope: scope,
|
|
94
119
|
kind: singleton ? :singleton : :instance,
|
|
95
|
-
sclass: sclass
|
|
120
|
+
sclass: sclass,
|
|
121
|
+
reload: reload
|
|
96
122
|
)
|
|
97
|
-
# No `super`: nested defs get no subject of their own -- their bodies
|
|
98
|
-
# are mutated via the OUTER def (Engine#walk descends into them).
|
|
99
123
|
end
|
|
100
124
|
|
|
101
|
-
|
|
125
|
+
def each_concern_def(node, &blk)
|
|
126
|
+
return if node.nil?
|
|
127
|
+
|
|
128
|
+
if node.is_a?(Prism::DefNode)
|
|
129
|
+
yield node
|
|
130
|
+
elsif !ClassShape.concern_def_boundary?(node)
|
|
131
|
+
node.compact_child_nodes.each { |child| each_concern_def(child, &blk) }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
102
134
|
|
|
103
135
|
# One subject for the class-level code of this class/module. Only if the
|
|
104
136
|
# body has at least one statement the class-body walk can mutate: defs
|
|
@@ -43,7 +43,7 @@ module ActiveMutator
|
|
|
43
43
|
require "rspec/core"
|
|
44
44
|
devnull = File.open(File::NULL, "w")
|
|
45
45
|
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
|
|
46
|
-
if @mutation.subject.
|
|
46
|
+
if @mutation.subject.reload?
|
|
47
47
|
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
48
48
|
insert_mutation # BEFORE setup: groups bind described_class to the mutated object
|
|
49
49
|
runner.setup(devnull, devnull) # loads spec files
|
|
@@ -70,9 +70,10 @@ module ActiveMutator
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
72
|
# Def mutants class_eval over the live constant; class-body mutants
|
|
73
|
-
# cannot (macros accumulate) and go through whole-file closure reload
|
|
73
|
+
# cannot (macros accumulate) and go through whole-file closure reload, as
|
|
74
|
+
# do defs inside `included`/`prepended` blocks (they live on the includer).
|
|
74
75
|
def insert_mutation
|
|
75
|
-
if @mutation.subject.
|
|
76
|
+
if @mutation.subject.reload?
|
|
76
77
|
ClosureReload.new(@mutation.subject, @mutation.mutated_file_source).call
|
|
77
78
|
else
|
|
78
79
|
Inserter.new.insert(@mutation)
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_mutator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel John
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: prism
|
|
@@ -62,7 +61,6 @@ description: Mutation testing for Ruby and Rails. Uses Prism-based source-span m
|
|
|
62
61
|
(no unparser), coverage-mapped test selection, and a fork-per-mutant kill pipeline.
|
|
63
62
|
Scopes to changed methods for a fast dev loop, or to a diff for CI. Includes an
|
|
64
63
|
incremental coverage baseline and a committed acceptance ledger for equivalent mutants.
|
|
65
|
-
email:
|
|
66
64
|
executables:
|
|
67
65
|
- active_mutator
|
|
68
66
|
extensions: []
|
|
@@ -125,7 +123,6 @@ metadata:
|
|
|
125
123
|
source_code_uri: https://github.com/drj613/active_mutator
|
|
126
124
|
changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
|
|
127
125
|
bug_tracker_uri: https://github.com/drj613/active_mutator/issues
|
|
128
|
-
post_install_message:
|
|
129
126
|
rdoc_options: []
|
|
130
127
|
require_paths:
|
|
131
128
|
- lib
|
|
@@ -140,8 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
140
137
|
- !ruby/object:Gem::Version
|
|
141
138
|
version: '0'
|
|
142
139
|
requirements: []
|
|
143
|
-
rubygems_version:
|
|
144
|
-
signing_key:
|
|
140
|
+
rubygems_version: 4.0.19
|
|
145
141
|
specification_version: 4
|
|
146
142
|
summary: Mutation testing for Ruby, built on Prism
|
|
147
143
|
test_files: []
|