active_mutator 0.6.0 → 0.6.1
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 +9 -5
- data/lib/active_mutator/baseline_hooks.rb +24 -2
- data/lib/active_mutator/cli.rb +2 -2
- data/lib/active_mutator/runner.rb +18 -9
- data/lib/active_mutator/since_filter.rb +38 -0
- data/lib/active_mutator/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a334e2726728421ba66d6a08db715587b8e0439b934489857b70d746912e89b8
|
|
4
|
+
data.tar.gz: cdce7a7856fd4b1f68b4cbf9dc5a62d58a1926555b203a01474d64aa95b6fbbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1cfeffd4210a83fcd974a3b175eabf0da02a54b7d253664e3252ed5fc0487fd60fcf4ad3db54d29d12e53363842e0a5116e0602c6ce83a971813e8895a84dbf
|
|
7
|
+
data.tar.gz: d21dae9ed1a6e6b668517ad99130a7a6e2d7de6d6f9ebf4f620de5ce7e0690bc81d105e8f3d78aff72c288034a2366ae6a0dced6a35d2101b63421fde0ef1da0
|
data/README.md
CHANGED
|
@@ -138,8 +138,8 @@ every status at `0` and no `Mutation score:` line (JSON: `score` is `null`,
|
|
|
138
138
|
`exit_reason` is `empty_plan`), then warns with the cause and exits `1`
|
|
139
139
|
unless `--allow-empty` is given. The count block is printed on every run,
|
|
140
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
|
|
142
|
-
no candidate source file; see [Empty plans in CI](#empty-plans-in-ci).
|
|
141
|
+
`--allow-empty` forgives the empty plan only when the `--since` diff changed
|
|
142
|
+
no code in a candidate source file; see [Empty plans in CI](#empty-plans-in-ci).
|
|
143
143
|
|
|
144
144
|
When survivors exist, the summary also prints a per-operator table showing
|
|
145
145
|
how often each operator's mutants survive, to help spot likely-equivalent
|
|
@@ -263,8 +263,12 @@ itself whether that emptiness is fine:
|
|
|
263
263
|
- **Exit 0** with `--no-class-level` when every changed line in the
|
|
264
264
|
candidate files falls inside class-body code that flag dropped. The
|
|
265
265
|
warning names `--no-class-level` as the reason.
|
|
266
|
-
- **Exit
|
|
267
|
-
|
|
266
|
+
- **Exit 0** when the only changes in the candidate files are comments or
|
|
267
|
+
blank lines, added or deleted. A magic comment such as
|
|
268
|
+
`# frozen_string_literal: true` counts as code, and so does a file that
|
|
269
|
+
fails to parse.
|
|
270
|
+
- **Exit 1** otherwise. A candidate file's code changed but produced no
|
|
271
|
+
mutants, and the warning lists the file(s). A deletion-only code edit
|
|
268
272
|
counts as a real change here.
|
|
269
273
|
- With `--subject` and no `--since` there is no diff to judge, so
|
|
270
274
|
`--allow-empty` exits `0` unconditionally.
|
|
@@ -284,7 +288,7 @@ projects used to tell a docs-only PR from a broken `--since` range.
|
|
|
284
288
|
| `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
|
|
285
289
|
| `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
|
|
286
290
|
| `--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
|
|
291
|
+
| `--allow-empty` | off | forgive an empty `--since`/`--subject` plan, but only when the `--since` diff changed no code in a candidate source file (default: warn and exit 1) |
|
|
288
292
|
| `--format terminal\|json\|stryker-json\|github` | terminal | report format |
|
|
289
293
|
| `--accept-survivors` | off | record survivors to the acceptance ledger |
|
|
290
294
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
@@ -9,11 +9,16 @@ module ActiveMutator
|
|
|
9
9
|
RECORDS = {}
|
|
10
10
|
TIMES = {}
|
|
11
11
|
|
|
12
|
-
def self.diff_coverage(before, after, root, spec_paths: ["spec"])
|
|
12
|
+
def self.diff_coverage(before, after, root, spec_paths: ["spec"], gem_dirs: [])
|
|
13
13
|
prefixes = spec_paths.map { |sp| "/#{sp}/" }
|
|
14
|
+
gem_prefixes = gem_dirs.map { |dir| File.join(dir, "") }
|
|
14
15
|
hits = []
|
|
15
16
|
after.each do |path, data|
|
|
16
17
|
next unless path.start_with?(root)
|
|
18
|
+
# Gems installed under the root (e.g. `bundle config path
|
|
19
|
+
# vendor/bundle` on CI) would otherwise be recorded per example and
|
|
20
|
+
# blow up the map's memory.
|
|
21
|
+
next if gem_prefixes.any? { |p| path.start_with?(p) }
|
|
17
22
|
|
|
18
23
|
# Relative to root, not a global substring check: `root` itself may
|
|
19
24
|
# contain "/spec/" (e.g. a fixture nested under this gem's own
|
|
@@ -33,6 +38,20 @@ module ActiveMutator
|
|
|
33
38
|
hits
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# Computed per example, not at load: this file loads via RUBYOPT before
|
|
42
|
+
# Bundler sets up its install path.
|
|
43
|
+
def self.gem_dirs
|
|
44
|
+
dirs = Gem.path.dup
|
|
45
|
+
if defined?(Bundler)
|
|
46
|
+
begin
|
|
47
|
+
dirs << Bundler.bundle_path.to_s
|
|
48
|
+
rescue Bundler::BundlerError
|
|
49
|
+
# No Gemfile, so Gem.path covers it.
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
dirs.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
36
55
|
def self.build_payload(records, times, expected_examples: nil)
|
|
37
56
|
payload = { "version" => 2, "records" => records, "times" => times }
|
|
38
57
|
payload["expected_examples"] = expected_examples if expected_examples
|
|
@@ -56,7 +75,10 @@ if ENV["ACTIVE_MUTATOR_BASELINE_OUT"]
|
|
|
56
75
|
root = ENV.fetch("ACTIVE_MUTATOR_ROOT")
|
|
57
76
|
spec_paths = ENV.fetch("ACTIVE_MUTATOR_SPEC_PATHS", "spec").split(":")
|
|
58
77
|
ActiveMutator::BaselineHooks::RECORDS[example.id] =
|
|
59
|
-
ActiveMutator::BaselineHooks.diff_coverage(
|
|
78
|
+
ActiveMutator::BaselineHooks.diff_coverage(
|
|
79
|
+
before, after, root,
|
|
80
|
+
spec_paths: spec_paths, gem_dirs: ActiveMutator::BaselineHooks.gem_dirs
|
|
81
|
+
)
|
|
60
82
|
# NOT example.execution_result.run_time: that is nil until after
|
|
61
83
|
# around hooks complete.
|
|
62
84
|
ActiveMutator::BaselineHooks::TIMES[example.id] = elapsed
|
data/lib/active_mutator/cli.rb
CHANGED
|
@@ -67,8 +67,8 @@ module ActiveMutator
|
|
|
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
69
|
o.on("--allow-empty",
|
|
70
|
-
"Exit 0 when --since/--subject plan no mutants and the --since diff
|
|
71
|
-
"source file (default: exit 1)") { options[:allow_empty] = true }
|
|
70
|
+
"Exit 0 when --since/--subject plan no mutants and the --since diff changed no code " \
|
|
71
|
+
"in a mutable source file (default: exit 1)") { options[:allow_empty] = true }
|
|
72
72
|
o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
|
|
73
73
|
raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
|
|
74
74
|
options[:fail_at] = v
|
|
@@ -6,8 +6,9 @@ module ActiveMutator
|
|
|
6
6
|
# root-relative source files after path expansion and excludes, minus
|
|
7
7
|
# spec_paths; `since_candidates` are the --since diff's files among them;
|
|
8
8
|
# `since_matched_all` are the since-covered subjects before --no-class-level
|
|
9
|
-
# drops class bodies
|
|
10
|
-
|
|
9
|
+
# drops class bodies; `since_filter` is the --since SinceFilter (nil
|
|
10
|
+
# without --since). The last three feed the --allow-empty verdict (#46).
|
|
11
|
+
Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all, :since_filter)
|
|
11
12
|
|
|
12
13
|
def initialize(config, reporter: nil)
|
|
13
14
|
@config = config
|
|
@@ -177,15 +178,22 @@ module ActiveMutator
|
|
|
177
178
|
end
|
|
178
179
|
|
|
179
180
|
# --allow-empty forgives an empty plan only when the --since diff touched no
|
|
180
|
-
# candidate source file (docs-only, spec-only, excluded paths)
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
181
|
+
# candidate source file (docs-only, spec-only, excluded paths) or changed
|
|
182
|
+
# only comments in them. A candidate whose code changed but planned
|
|
183
|
+
# nothing is the case worth failing on, unless the only code it touched
|
|
184
|
+
# is class-body code that --no-class-level dropped. --subject alone has no
|
|
185
|
+
# diff to judge, so it stays an unconditional 0.
|
|
184
186
|
def allow_empty_exit(discovery)
|
|
185
187
|
return 0 unless @config.since
|
|
188
|
+
return 0 if discovery.since_candidates.empty?
|
|
186
189
|
|
|
187
|
-
|
|
188
|
-
|
|
190
|
+
# Checked only here, on the empty-plan path: it runs `git show` per file.
|
|
191
|
+
candidates = discovery.since_candidates.reject { |file| discovery.since_filter.comment_only?(file) }
|
|
192
|
+
if candidates.empty?
|
|
193
|
+
warn "active_mutator: forgiving empty plan: only comments changed in " \
|
|
194
|
+
"#{discovery.since_candidates.join(", ")}"
|
|
195
|
+
return 0
|
|
196
|
+
end
|
|
189
197
|
|
|
190
198
|
if !@config.class_level && class_body_only?(discovery.since_matched_all, candidates)
|
|
191
199
|
warn "active_mutator: forgiving empty plan: changed lines are class-body code and --no-class-level is set"
|
|
@@ -322,7 +330,8 @@ module ActiveMutator
|
|
|
322
330
|
since_matched_all = subjects
|
|
323
331
|
subjects = subjects.reject(&:class_body?) unless @config.class_level
|
|
324
332
|
Discovery.new(subjects: subjects, scanned_files: scanned_files,
|
|
325
|
-
since_candidates: since_candidates, since_matched_all: since_matched_all
|
|
333
|
+
since_candidates: since_candidates, since_matched_all: since_matched_all,
|
|
334
|
+
since_filter: filter)
|
|
326
335
|
end
|
|
327
336
|
|
|
328
337
|
def relative(file) = file.delete_prefix(@config.root.chomp("/") + "/")
|
|
@@ -29,7 +29,36 @@ module ActiveMutator
|
|
|
29
29
|
end.uniq
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Tokens that never change what the code does.
|
|
33
|
+
NOISE = %i[IGNORED_NEWLINE EMBDOC_BEGIN EMBDOC_LINE EMBDOC_END EOF].freeze
|
|
34
|
+
|
|
35
|
+
# True when two sources differ only in comments and blank lines. Magic
|
|
36
|
+
# comments (`# frozen_string_literal: true`) change behavior, so they
|
|
37
|
+
# count as code; so does anything that fails to parse.
|
|
38
|
+
def self.same_code?(old_source, new_source)
|
|
39
|
+
old_code = code_of(old_source)
|
|
40
|
+
!old_code.nil? && old_code == code_of(new_source)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.code_of(source)
|
|
44
|
+
result = Prism.parse_lex(source)
|
|
45
|
+
return nil if result.failure?
|
|
46
|
+
|
|
47
|
+
# A trailing comment swallows its line's newline, so comments become a
|
|
48
|
+
# newline instead of vanishing; runs of newlines collapse to one.
|
|
49
|
+
tokens = result.value.last.filter_map do |token, _state|
|
|
50
|
+
next if NOISE.include?(token.type)
|
|
51
|
+
|
|
52
|
+
%i[COMMENT NEWLINE].include?(token.type) ? :NEWLINE : [token.type, token.value]
|
|
53
|
+
end
|
|
54
|
+
tokens = tokens.chunk_while { |a, b| a == :NEWLINE && b == :NEWLINE }.map(&:first)
|
|
55
|
+
tokens.shift if tokens.first == :NEWLINE
|
|
56
|
+
tokens.pop if tokens.last == :NEWLINE
|
|
57
|
+
[tokens, result.magic_comments.map { |c| [c.key, c.value] }]
|
|
58
|
+
end
|
|
59
|
+
|
|
32
60
|
def initialize(ref:, root:)
|
|
61
|
+
@ref = ref
|
|
33
62
|
@root = root
|
|
34
63
|
diff = IO.popen(
|
|
35
64
|
["git", "-C", root, "diff", "--unified=0", ref, "--", "*.rb"], &:read
|
|
@@ -53,6 +82,15 @@ module ActiveMutator
|
|
|
53
82
|
# further git calls.
|
|
54
83
|
def changed_files = @touched
|
|
55
84
|
|
|
85
|
+
# Whether a changed file's edits since the ref are only comments. Reads
|
|
86
|
+
# the old side with `git show`; a file missing there (untracked, renamed)
|
|
87
|
+
# prints nothing, so it compares against an empty source and any code in
|
|
88
|
+
# it counts.
|
|
89
|
+
def comment_only?(path)
|
|
90
|
+
old_source = IO.popen(["git", "-C", @root, "show", "#{@ref}:#{path}"], err: File::NULL, &:read)
|
|
91
|
+
self.class.same_code?(old_source, File.read(File.join(@root, path)))
|
|
92
|
+
end
|
|
93
|
+
|
|
56
94
|
def cover?(subject)
|
|
57
95
|
lines = @changed[subject.file.delete_prefix("#{@root}/")]
|
|
58
96
|
return false unless lines
|