active_mutator 0.4.1 → 0.6.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 +53 -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 +4 -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/github.rb +2 -2
- data/lib/active_mutator/reporter/json.rb +14 -4
- data/lib/active_mutator/reporter/stryker_json.rb +6 -1
- data/lib/active_mutator/reporter/terminal.rb +25 -12
- data/lib/active_mutator/runner.rb +95 -14
- data/lib/active_mutator/scheduler.rb +29 -4
- data/lib/active_mutator/since_filter.rb +17 -1
- 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: 60c123ba92745825d5041728a14144e2cad95ba814d54fcb72bba40e1d5c0089
|
|
4
|
+
data.tar.gz: b7f96c322c455aa01d4ef19e04aa017f61fecb82347b1da7e315cfdc4316708e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 134766964d548d79002d0364046d6a46cb9a3b3d14b334c7d7ee67b3581865c31f10184bd7666d59f5732122dbc74f8c8ffdd7ee14664cd610c38ba64675fc31
|
|
7
|
+
data.tar.gz: 22bd7813d2a1d061a045a3bc3c7cc2d21c31b3d34f6c8b26e0f2433921b131c124f0f652e3367657bfcc26e987dd7416763c33e05d3e074cfa688703047ec8e2
|
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,25 @@ 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`, `empty_plan`) is
|
|
135
|
+
independent of the `--fail-at` gate. A `--since` or `--subject` run that
|
|
136
|
+
plans zero mutants skips the baseline, prints the usual count block with
|
|
137
|
+
every status at `0` and no `Mutation score:` line (JSON: `score` is `null`,
|
|
138
|
+
`exit_reason` is `empty_plan`), then warns with the cause and exits `1`
|
|
139
|
+
unless `--allow-empty` is given. The count block is printed on every run,
|
|
140
|
+
whatever the exit code, so log-scraping never has to handle a missing block.
|
|
141
|
+
`--allow-empty` forgives the empty plan only when the `--since` diff touched
|
|
142
|
+
no candidate source file; see [Empty plans in CI](#empty-plans-in-ci).
|
|
135
143
|
|
|
136
144
|
When survivors exist, the summary also prints a per-operator table showing
|
|
137
145
|
how often each operator's mutants survive, to help spot likely-equivalent
|
|
@@ -198,7 +206,7 @@ score is below the threshold). Mistyped positional paths (a file that
|
|
|
198
206
|
doesn't exist, or a non-`.rb` file) are an error (exit 2) instead of a
|
|
199
207
|
vacuous green run.
|
|
200
208
|
|
|
201
|
-
Score = (killed + timeout) / (killed + timeout + survived).
|
|
209
|
+
Score = (killed + timeout) / (killed + timeout + survived + error).
|
|
202
210
|
|
|
203
211
|
## The dev loop
|
|
204
212
|
|
|
@@ -242,6 +250,29 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
|
242
250
|
residual blind spot — constant-reference detection handles the common
|
|
243
251
|
newly-covering-example case since 0.2)
|
|
244
252
|
|
|
253
|
+
### Empty plans in CI
|
|
254
|
+
|
|
255
|
+
A `--since` PR run that plans zero mutants skips the baseline, prints the
|
|
256
|
+
zero-count block, and exits `1`. Pass `--allow-empty` to let it decide for
|
|
257
|
+
itself whether that emptiness is fine:
|
|
258
|
+
|
|
259
|
+
- **Exit 0** when the diff contains no candidate file. A candidate is a
|
|
260
|
+
changed or untracked `.rb` file inside the scanned paths (positional args
|
|
261
|
+
or the default `app`/`lib`), not under a spec path, and not matching an
|
|
262
|
+
`--exclude` pattern. Docs-only, spec-only, and excluded-path PRs pass.
|
|
263
|
+
- **Exit 0** with `--no-class-level` when every changed line in the
|
|
264
|
+
candidate files falls inside class-body code that flag dropped. The
|
|
265
|
+
warning names `--no-class-level` as the reason.
|
|
266
|
+
- **Exit 1** otherwise. A candidate source file changed but produced no
|
|
267
|
+
mutants, and the warning lists the file(s). A comment-only edit in `lib/`
|
|
268
|
+
counts as a real change here.
|
|
269
|
+
- With `--subject` and no `--since` there is no diff to judge, so
|
|
270
|
+
`--allow-empty` exits `0` unconditionally.
|
|
271
|
+
|
|
272
|
+
`--changed` follows the same rule, and an untracked candidate `.rb` file
|
|
273
|
+
counts as a candidate. This replaces the repo-side log-scraping script some
|
|
274
|
+
projects used to tell a docs-only PR from a broken `--since` range.
|
|
275
|
+
|
|
245
276
|
## Flags
|
|
246
277
|
|
|
247
278
|
| Flag | Default | Meaning |
|
|
@@ -253,6 +284,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
|
253
284
|
| `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
|
|
254
285
|
| `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
|
|
255
286
|
| `--debug-plan` | off | print planned mutants as JSON and exit without running |
|
|
287
|
+
| `--allow-empty` | off | forgive an empty `--since`/`--subject` plan, but only when the `--since` diff touched no candidate source file (default: warn and exit 1) |
|
|
256
288
|
| `--format terminal\|json\|stryker-json\|github` | terminal | report format |
|
|
257
289
|
| `--accept-survivors` | off | record survivors to the acceptance ledger |
|
|
258
290
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
@@ -401,6 +433,19 @@ change. Also run `bundle exec active_mutator --changed` on your own diff
|
|
|
401
433
|
before sending a change that touches `lib/`. This is a good idea for the
|
|
402
434
|
same reason you'd want it run on any other codebase.
|
|
403
435
|
|
|
436
|
+
If a run dies with `baseline suite failed` and a `LoadError` mentioning
|
|
437
|
+
`bundler-2.x/lib/gems/bundler-2.x/exe/bundle`, your Ruby manager (seen with
|
|
438
|
+
mise) breaks nested `bundle exec`: the baseline shells out to `bundle exec
|
|
439
|
+
rspec`, and bundler's exported `RUBYLIB` makes the inner binstub resolve the
|
|
440
|
+
wrong path. Skip the outer bundler instead:
|
|
441
|
+
|
|
442
|
+
```sh
|
|
443
|
+
ruby -Ilib exe/active_mutator lib --since origin/main # same as the CI mutation job
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
The `:e2e` specs nest `bundle exec` on their own inside the fixture project,
|
|
447
|
+
so on such a machine they fail either way; rely on the CI `e2e` job for those.
|
|
448
|
+
|
|
404
449
|
## License
|
|
405
450
|
|
|
406
451
|
[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,9 @@ 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",
|
|
70
|
+
"Exit 0 when --since/--subject plan no mutants and the --since diff touched no mutable " \
|
|
71
|
+
"source file (default: exit 1)") { options[:allow_empty] = true }
|
|
69
72
|
o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
|
|
70
73
|
raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
|
|
71
74
|
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
|
|
@@ -12,8 +12,8 @@ module ActiveMutator
|
|
|
12
12
|
|
|
13
13
|
def on_result(result) = @terminal.on_result(result)
|
|
14
14
|
|
|
15
|
-
def summary(results, invalid_count:)
|
|
16
|
-
@terminal.summary(results, invalid_count: invalid_count)
|
|
15
|
+
def summary(results, invalid_count:, empty_plan: false)
|
|
16
|
+
@terminal.summary(results, invalid_count: invalid_count, empty_plan: empty_plan)
|
|
17
17
|
results.select { |r| r.status == :survived }.each { |r| annotate(r) }
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -9,20 +9,30 @@ module ActiveMutator
|
|
|
9
9
|
|
|
10
10
|
def on_result(result); end
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# An empty plan has no score (null) and its own exit_reason, so a CI
|
|
13
|
+
# consumer can tell "nothing to mutate" from "everything was killed" (#45).
|
|
14
|
+
def summary(results, invalid_count:, empty_plan: false)
|
|
15
|
+
counts = Terminal.counts(results)
|
|
14
16
|
@out.puts JSON.pretty_generate(
|
|
15
|
-
"score" => Terminal.score(counts),
|
|
17
|
+
"score" => empty_plan ? nil : Terminal.score(counts),
|
|
16
18
|
"counts" => counts.transform_keys(&:to_s),
|
|
17
19
|
"invalid" => invalid_count,
|
|
18
20
|
"operators" => OperatorStats.call(results),
|
|
19
21
|
"results" => results.map { |r| serialize(r) },
|
|
20
|
-
"exit_reason" => counts
|
|
22
|
+
"exit_reason" => exit_reason(counts, empty_plan)
|
|
21
23
|
)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
private
|
|
25
27
|
|
|
28
|
+
def exit_reason(counts, empty_plan)
|
|
29
|
+
return "empty_plan" if empty_plan
|
|
30
|
+
return "unaccepted_survivors" if counts[:survived].positive?
|
|
31
|
+
return "worker_errors" if counts[:error].positive?
|
|
32
|
+
|
|
33
|
+
"clean"
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
def serialize(result)
|
|
27
37
|
m = result.mutation
|
|
28
38
|
{
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require "fileutils"
|
|
2
3
|
|
|
3
4
|
module ActiveMutator
|
|
4
5
|
module Reporter
|
|
@@ -31,9 +32,13 @@ module ActiveMutator
|
|
|
31
32
|
@out.print(Terminal::CHARS.fetch(result.status))
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
# A zero-mutant report is valid schema output, so an empty plan writes the
|
|
36
|
+
# same file with no files/mutants; the flag is accepted for contract parity.
|
|
37
|
+
def summary(results, invalid_count:, empty_plan: false)
|
|
35
38
|
report = build_report(results, invalid_count)
|
|
36
39
|
path = File.join(@root, REPORT_PATH)
|
|
40
|
+
# An empty plan skips the baseline, which used to create this dir.
|
|
41
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
37
42
|
AtomicFile.write(path, JSON.pretty_generate(report))
|
|
38
43
|
@out.puts "", "", "Stryker report written to #{REPORT_PATH}"
|
|
39
44
|
end
|
|
@@ -12,16 +12,24 @@ module ActiveMutator
|
|
|
12
12
|
@out.print(CHARS.fetch(result.status))
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# Every status key is present (zero when absent) so the block has the same
|
|
16
|
+
# shape on every run, including an empty plan.
|
|
17
|
+
def self.counts(results)
|
|
18
|
+
tallies = results.group_by(&:status).transform_values(&:size)
|
|
19
|
+
CHARS.keys.to_h { |status| [status, tallies.fetch(status, 0)] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `empty_plan: true` means a --since/--subject scope planned nothing: the
|
|
23
|
+
# count block still prints, but there is no score to report (#45).
|
|
24
|
+
def summary(results, invalid_count:, empty_plan: false)
|
|
25
|
+
counts = self.class.counts(results)
|
|
17
26
|
@out.puts "", ""
|
|
18
|
-
|
|
19
|
-
@out.puts "#{status}: #{counts.fetch(status, 0)}"
|
|
20
|
-
end
|
|
27
|
+
counts.each { |status, count| @out.puts "#{status}: #{count}" }
|
|
21
28
|
@out.puts "invalid (discarded): #{invalid_count}"
|
|
22
|
-
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
@out.puts format("Mutation score: %.1f%%", score(counts) * 100) unless empty_plan
|
|
30
|
+
print_group("Surviving mutants:", results.select { |r| r.status == :survived })
|
|
31
|
+
print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
|
|
32
|
+
print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
|
|
25
33
|
skipped = results.select { |r| r.status == :skipped }
|
|
26
34
|
print_skipped(skipped) unless skipped.empty?
|
|
27
35
|
stats = OperatorStats.call(results)
|
|
@@ -29,9 +37,12 @@ module ActiveMutator
|
|
|
29
37
|
print_operator_stats(noisy) unless noisy.empty?
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
# A timeout is a detection (the mutant changed behavior enough to hang,
|
|
41
|
+
# the same convention as Stryker and PIT). An error is a non-verdict:
|
|
42
|
+
# scoring it as a pass let a broken worker read as 100%.
|
|
32
43
|
def self.score(counts)
|
|
33
44
|
detected = counts.fetch(:killed, 0) + counts.fetch(:timeout, 0)
|
|
34
|
-
denominator = detected + counts.fetch(:survived, 0)
|
|
45
|
+
denominator = detected + counts.fetch(:survived, 0) + counts.fetch(:error, 0)
|
|
35
46
|
return 1.0 if denominator.zero?
|
|
36
47
|
|
|
37
48
|
detected.to_f / denominator
|
|
@@ -49,9 +60,11 @@ module ActiveMutator
|
|
|
49
60
|
end
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
def print_group(title, group)
|
|
64
|
+
return if group.empty?
|
|
65
|
+
|
|
66
|
+
@out.puts "", title
|
|
67
|
+
group.each do |result|
|
|
55
68
|
m = result.mutation
|
|
56
69
|
@out.puts "", " #{m.subject.name} (#{m.subject.file}:#{m.line})"
|
|
57
70
|
@out.puts " #{m.description}"
|
|
@@ -2,6 +2,13 @@ require "json"
|
|
|
2
2
|
|
|
3
3
|
module ActiveMutator
|
|
4
4
|
class Runner
|
|
5
|
+
# What discovery saw, beyond the final subject list. `scanned_files` are
|
|
6
|
+
# root-relative source files after path expansion and excludes, minus
|
|
7
|
+
# spec_paths; `since_candidates` are the --since diff's files among them;
|
|
8
|
+
# `since_matched_all` are the since-covered subjects before --no-class-level
|
|
9
|
+
# drops class bodies. The last two feed the --allow-empty verdict (#46).
|
|
10
|
+
Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all)
|
|
11
|
+
|
|
5
12
|
def initialize(config, reporter: nil)
|
|
6
13
|
@config = config
|
|
7
14
|
@reporter = reporter || build_reporter
|
|
@@ -13,14 +20,24 @@ module ActiveMutator
|
|
|
13
20
|
ClosureReload.cap = @config.class_level_closure_cap
|
|
14
21
|
preload!
|
|
15
22
|
preload_spec_helper!
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
|
|
19
|
-
subjects = discover_subjects
|
|
23
|
+
discovery = discover
|
|
24
|
+
subjects = discovery.subjects
|
|
20
25
|
analyses = subjects.map { |s| Engine.new.analyze(s) }
|
|
21
26
|
mutations = analyses.flat_map(&:mutations)
|
|
22
27
|
mutations = mutations.first(@config.max_mutants) if @config.max_mutants
|
|
23
28
|
invalid_count = analyses.sum(&:invalid_count)
|
|
29
|
+
# Decide emptiness before the baseline: a scoped run that plans nothing
|
|
30
|
+
# has no use for a coverage map, and building one spawns the whole spec
|
|
31
|
+
# suite (#47).
|
|
32
|
+
if mutations.empty? && (@config.since || @config.subject_filter)
|
|
33
|
+
return debug_plan([], []) if @config.debug_plan
|
|
34
|
+
|
|
35
|
+
return empty_plan_exit(invalid_count, discovery)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
|
|
39
|
+
.coverage_map(force: @config.force_baseline)
|
|
40
|
+
@reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
|
|
24
41
|
|
|
25
42
|
fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
|
|
26
43
|
ledger = AcceptedLedger.load(@config.root)
|
|
@@ -119,7 +136,7 @@ module ActiveMutator
|
|
|
119
136
|
extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
|
|
120
137
|
replacement.with(details: "escalated (+#{extra} spec files)")
|
|
121
138
|
else
|
|
122
|
-
# A timeout/error/skip in phase 2 did NOT prove a kill
|
|
139
|
+
# A timeout/error/skip in phase 2 did NOT prove a kill; the mutant
|
|
123
140
|
# already survived phase 1, so keep that verdict rather than letting
|
|
124
141
|
# an inconclusive escalation inflate the score (a :timeout counts as
|
|
125
142
|
# detected in exit_code/score).
|
|
@@ -129,17 +146,66 @@ module ActiveMutator
|
|
|
129
146
|
end
|
|
130
147
|
|
|
131
148
|
def exit_code(results)
|
|
132
|
-
|
|
133
|
-
return 0 if
|
|
149
|
+
undetected = results.count { |r| %i[survived error].include?(r.status) }
|
|
150
|
+
return 0 if undetected.zero?
|
|
134
151
|
return 1 unless @config.fail_at
|
|
135
152
|
|
|
136
153
|
detected = results.count { |r| %i[killed timeout].include?(r.status) }
|
|
137
|
-
score = detected * 100.0 / (detected +
|
|
154
|
+
score = detected * 100.0 / (detected + undetected)
|
|
138
155
|
score >= @config.fail_at ? 0 : 1
|
|
139
156
|
end
|
|
140
157
|
|
|
141
158
|
private
|
|
142
159
|
|
|
160
|
+
# A scoped run that plans nothing must not report "100%" and pass --fail-at:
|
|
161
|
+
# the usual cause is a --since range or --subject filter that matched no
|
|
162
|
+
# mutable code, or class-body code dropped by --no-class-level (#23 covers
|
|
163
|
+
# the zero-subject case for explicit paths).
|
|
164
|
+
def empty_plan_exit(invalid_count, discovery)
|
|
165
|
+
@reporter.summary([], invalid_count: invalid_count, empty_plan: true)
|
|
166
|
+
causes = []
|
|
167
|
+
causes << "--since #{@config.since} matched no mutable code" if @config.since
|
|
168
|
+
causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
|
|
169
|
+
causes << "--no-class-level excludes class-body code" unless @config.class_level
|
|
170
|
+
warn "active_mutator: no mutants planned (#{causes.join("; ")})"
|
|
171
|
+
unless @config.allow_empty
|
|
172
|
+
warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
|
|
173
|
+
return 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
allow_empty_exit(discovery)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# --allow-empty forgives an empty plan only when the --since diff touched no
|
|
180
|
+
# candidate source file (docs-only, spec-only, excluded paths). A changed
|
|
181
|
+
# candidate that planned nothing is the case worth failing on, unless the
|
|
182
|
+
# only code it touched is class-body code that --no-class-level dropped.
|
|
183
|
+
# --subject alone has no diff to judge, so it stays an unconditional 0.
|
|
184
|
+
def allow_empty_exit(discovery)
|
|
185
|
+
return 0 unless @config.since
|
|
186
|
+
|
|
187
|
+
candidates = discovery.since_candidates
|
|
188
|
+
return 0 if candidates.empty?
|
|
189
|
+
|
|
190
|
+
if !@config.class_level && class_body_only?(discovery.since_matched_all, candidates)
|
|
191
|
+
warn "active_mutator: forgiving empty plan: changed lines are class-body code and --no-class-level is set"
|
|
192
|
+
return 0
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
warn "active_mutator: exiting 1; --allow-empty forgives an empty plan only when no candidate " \
|
|
196
|
+
"source file changed. Changed: #{candidates.join(", ")}"
|
|
197
|
+
1
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Every matched subject is a class body AND every candidate file has one:
|
|
201
|
+
# a candidate that matched nothing (comment-only edit, deletion) must not
|
|
202
|
+
# hide behind a class-body change in a different file. Candidates are
|
|
203
|
+
# non-empty here, so no matches at all fails the second test on its own.
|
|
204
|
+
def class_body_only?(subjects, candidates)
|
|
205
|
+
matched_files = subjects.map { |s| relative(s.file) }
|
|
206
|
+
subjects.all?(&:class_body?) && candidates.all? { |file| matched_files.include?(file) }
|
|
207
|
+
end
|
|
208
|
+
|
|
143
209
|
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
144
210
|
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
145
211
|
def build_work_item(mutation, example_ids, map)
|
|
@@ -233,23 +299,38 @@ module ActiveMutator
|
|
|
233
299
|
end
|
|
234
300
|
end
|
|
235
301
|
|
|
236
|
-
def
|
|
302
|
+
def discover
|
|
237
303
|
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
238
|
-
|
|
304
|
+
files = paths
|
|
239
305
|
.flat_map { |p| expand_path_arg(p) }
|
|
240
306
|
.uniq
|
|
241
307
|
.reject { |file| excluded?(file) }
|
|
242
|
-
.sort
|
|
243
|
-
|
|
308
|
+
.sort
|
|
309
|
+
scanned_files = files.map { |f| relative(f) }.reject { |rel| under_spec_paths?(rel) }
|
|
310
|
+
subjects = files.flat_map { |file| SubjectFinder.call(file) }
|
|
244
311
|
if @config.subject_filter
|
|
245
312
|
matcher = SubjectMatcher.new(@config.subject_filter)
|
|
246
313
|
subjects = subjects.select { |s| matcher.match?(s.name) }
|
|
247
314
|
end
|
|
315
|
+
since_candidates = []
|
|
248
316
|
if @config.since
|
|
249
317
|
filter = SinceFilter.new(ref: @config.since, root: @config.root)
|
|
250
318
|
subjects = subjects.select { |s| filter.cover?(s) }
|
|
319
|
+
since_candidates = filter.changed_files & scanned_files
|
|
251
320
|
end
|
|
252
|
-
|
|
321
|
+
# Class bodies drop out LAST so since_matched_all still knows about them.
|
|
322
|
+
since_matched_all = subjects
|
|
323
|
+
subjects = subjects.reject(&:class_body?) unless @config.class_level
|
|
324
|
+
Discovery.new(subjects: subjects, scanned_files: scanned_files,
|
|
325
|
+
since_candidates: since_candidates, since_matched_all: since_matched_all)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def relative(file) = file.delete_prefix(@config.root.chomp("/") + "/")
|
|
329
|
+
|
|
330
|
+
# Scanned entries are files, so only the "inside this directory" test
|
|
331
|
+
# matters; the trailing slash keeps `spec` from swallowing `spec_tools/`.
|
|
332
|
+
def under_spec_paths?(rel)
|
|
333
|
+
@config.spec_paths.any? { |sp| rel.start_with?("#{sp.chomp("/")}/") }
|
|
253
334
|
end
|
|
254
335
|
|
|
255
336
|
# Positional args may be files or directories. Anything else is an error:
|
|
@@ -329,7 +410,7 @@ module ActiveMutator
|
|
|
329
410
|
# MAINTENANCE: any future flag that narrows the mutant set below "every
|
|
330
411
|
# subject in the scanned files" MUST be added to this nil-trigger list,
|
|
331
412
|
# or scoped accept runs will clobber out-of-scope ledger entries (#24).
|
|
332
|
-
# --no-class-level drops every class_body subject (
|
|
413
|
+
# --no-class-level drops every class_body subject (discover), so a
|
|
333
414
|
# file's class-body fingerprint is absent even though the file is scanned;
|
|
334
415
|
# without this guard its accepted ledger entry looks stale and gets pruned.
|
|
335
416
|
def prune_scope(subjects)
|
|
@@ -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
|
|
@@ -20,6 +20,15 @@ module ActiveMutator
|
|
|
20
20
|
changed.reject { |_, lines| lines.empty? }
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Every file the diff touched on the new side, including deletion-only
|
|
24
|
+
# files that `parse` drops (they add no lines, so nothing to cover, but
|
|
25
|
+
# the file still changed and must count as a --since candidate).
|
|
26
|
+
def self.touched_files(diff_text)
|
|
27
|
+
diff_text.each_line.filter_map do |line|
|
|
28
|
+
line.delete_prefix("+++ b/").strip if line.start_with?("+++ b/")
|
|
29
|
+
end.uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
def initialize(ref:, root:)
|
|
24
33
|
@root = root
|
|
25
34
|
diff = IO.popen(
|
|
@@ -28,15 +37,22 @@ module ActiveMutator
|
|
|
28
37
|
raise Error, "git diff #{ref} failed" unless $?.success?
|
|
29
38
|
|
|
30
39
|
@changed = self.class.parse(diff)
|
|
40
|
+
@touched = self.class.touched_files(diff)
|
|
31
41
|
untracked = IO.popen(
|
|
32
42
|
["git", "-C", root, "ls-files", "--others", "--exclude-standard", "--", "*.rb"], &:read
|
|
33
43
|
)
|
|
34
44
|
# Untracked files are invisible to `git diff` but are agentic TDD's most
|
|
35
45
|
# common case (brand-new file + spec). Whole-file sentinel: every line
|
|
36
46
|
# counts as changed.
|
|
37
|
-
untracked.
|
|
47
|
+
untracked.split("\n").each { |l| @changed[l] = :all }
|
|
48
|
+
@touched |= @changed.keys
|
|
38
49
|
end
|
|
39
50
|
|
|
51
|
+
# Root-relative paths of every .rb file the diff touched (tracked files,
|
|
52
|
+
# including deletion-only ones, plus untracked files). Pure accessor: no
|
|
53
|
+
# further git calls.
|
|
54
|
+
def changed_files = @touched
|
|
55
|
+
|
|
40
56
|
def cover?(subject)
|
|
41
57
|
lines = @changed[subject.file.delete_prefix("#{@root}/")]
|
|
42
58
|
return false unless lines
|
|
@@ -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.6.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: []
|