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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60c123ba92745825d5041728a14144e2cad95ba814d54fcb72bba40e1d5c0089
4
- data.tar.gz: b7f96c322c455aa01d4ef19e04aa017f61fecb82347b1da7e315cfdc4316708e
3
+ metadata.gz: a334e2726728421ba66d6a08db715587b8e0439b934489857b70d746912e89b8
4
+ data.tar.gz: cdce7a7856fd4b1f68b4cbf9dc5a62d58a1926555b203a01474d64aa95b6fbbe
5
5
  SHA512:
6
- metadata.gz: 134766964d548d79002d0364046d6a46cb9a3b3d14b334c7d7ee67b3581865c31f10184bd7666d59f5732122dbc74f8c8ffdd7ee14664cd610c38ba64675fc31
7
- data.tar.gz: 22bd7813d2a1d061a045a3bc3c7cc2d21c31b3d34f6c8b26e0f2433921b131c124f0f652e3367657bfcc26e987dd7416763c33e05d3e074cfa688703047ec8e2
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 touched
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 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/`
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 touched no candidate source file (default: warn and exit 1) |
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(before, after, root, spec_paths: spec_paths)
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
@@ -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 touched no mutable " \
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. The last two feed the --allow-empty verdict (#46).
10
- Discovery = Data.define(:subjects, :scanned_files, :since_candidates, :since_matched_all)
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). 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.
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
- candidates = discovery.since_candidates
188
- return 0 if candidates.empty?
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
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_mutator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John