active_mutator 0.5.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45e58d5b8880a6716b7a78ee4011f0e5529600b3daa222ad39854723d08bd9d3
4
- data.tar.gz: d9c9067771e0596e3241d8a0bb408c3c9f041e98eac33f08066b1118feedb5b8
3
+ metadata.gz: 60c123ba92745825d5041728a14144e2cad95ba814d54fcb72bba40e1d5c0089
4
+ data.tar.gz: b7f96c322c455aa01d4ef19e04aa017f61fecb82347b1da7e315cfdc4316708e
5
5
  SHA512:
6
- metadata.gz: 2e6881dce81b23302b40220a9b593b290b22b08eb5f6c4426da96e2f5557e369cd635525f32aa357078c6ae10f289c05d216b025992ce23ff607841b42b71b1b
7
- data.tar.gz: 25fbb644044f506fcc4663ed26be6898187a4205a91778fdfc049240ecf7c1bb54a52f81b38e2ab9f59f75c00f0bbcd5d93b8ad6bc0d7c7a149d90cdc4f69cf4
6
+ metadata.gz: 134766964d548d79002d0364046d6a46cb9a3b3d14b334c7d7ee67b3581865c31f10184bd7666d59f5732122dbc74f8c8ffdd7ee14664cd610c38ba64675fc31
7
+ data.tar.gz: 22bd7813d2a1d061a045a3bc3c7cc2d21c31b3d34f6c8b26e0f2433921b131c124f0f652e3367657bfcc26e987dd7416763c33e05d3e074cfa688703047ec8e2
data/README.md CHANGED
@@ -131,10 +131,15 @@ discarded before scheduling and reported as a count only. Exit code is `1`
131
131
  if unaccepted survivors or errors exist (or, with `--fail-at`, if the score
132
132
  is below the threshold), `0` otherwise, including when there are only
133
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.
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).
138
143
 
139
144
  When survivors exist, the summary also prints a per-operator table showing
140
145
  how often each operator's mutants survive, to help spot likely-equivalent
@@ -245,6 +250,29 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
245
250
  residual blind spot — constant-reference detection handles the common
246
251
  newly-covering-example case since 0.2)
247
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
+
248
276
  ## Flags
249
277
 
250
278
  | Flag | Default | Meaning |
@@ -256,7 +284,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
256
284
  | `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
257
285
  | `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
258
286
  | `--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) |
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) |
260
288
  | `--format terminal\|json\|stryker-json\|github` | terminal | report format |
261
289
  | `--accept-survivors` | off | record survivors to the acceptance ledger |
262
290
  | `--force-baseline` | off | ignore cached coverage map |
@@ -66,7 +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", "Exit 0 when --since/--subject plan no mutants (default: exit 1)") { options[:allow_empty] = 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 }
70
72
  o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
71
73
  raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
72
74
  options[:fail_at] = v
@@ -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,23 +9,26 @@ module ActiveMutator
9
9
 
10
10
  def on_result(result); end
11
11
 
12
- def summary(results, invalid_count:)
13
- counts = results.group_by(&:status).transform_values(&:size)
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" => exit_reason(counts)
22
+ "exit_reason" => exit_reason(counts, empty_plan)
21
23
  )
22
24
  end
23
25
 
24
26
  private
25
27
 
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?
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?
29
32
 
30
33
  "clean"
31
34
  end
@@ -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
- def summary(results, invalid_count:)
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,14 +12,21 @@ module ActiveMutator
12
12
  @out.print(CHARS.fetch(result.status))
13
13
  end
14
14
 
15
- def summary(results, invalid_count:)
16
- counts = results.group_by(&:status).transform_values(&:size)
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
- CHARS.each_key do |status|
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)
29
+ @out.puts format("Mutation score: %.1f%%", score(counts) * 100) unless empty_plan
23
30
  print_group("Surviving mutants:", results.select { |r| r.status == :survived })
24
31
  print_group("Errored mutants (not detected):", results.select { |r| r.status == :error })
25
32
  print_group("Timed-out mutants (counted as detected):", results.select { |r| r.status == :timeout })
@@ -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
- map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
17
- .coverage_map(force: @config.force_baseline)
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)
@@ -29,7 +46,6 @@ module ActiveMutator
29
46
 
30
47
  items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
31
48
  return debug_plan(items, pre_results) if @config.debug_plan
32
- return empty_plan_exit if mutations.empty? && (@config.since || @config.subject_filter)
33
49
 
34
50
  pre_results.each { |r| @reporter.on_result(r) }
35
51
  calibrators = if @config.adaptive_timeout
@@ -145,18 +161,51 @@ module ActiveMutator
145
161
  # the usual cause is a --since range or --subject filter that matched no
146
162
  # mutable code, or class-body code dropped by --no-class-level (#23 covers
147
163
  # the zero-subject case for explicit paths).
148
- def empty_plan_exit
164
+ def empty_plan_exit(invalid_count, discovery)
165
+ @reporter.summary([], invalid_count: invalid_count, empty_plan: true)
149
166
  causes = []
150
167
  causes << "--since #{@config.since} matched no mutable code" if @config.since
151
168
  causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
152
169
  causes << "--no-class-level excludes class-body code" unless @config.class_level
153
170
  warn "active_mutator: no mutants planned (#{causes.join("; ")})"
154
- return 0 if @config.allow_empty
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?
155
189
 
156
- warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
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(", ")}"
157
197
  1
158
198
  end
159
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
+
160
209
  # Single source of truth for lane/timeout/variable derivation, shared by
161
210
  # phase-1 planning and phase-2 escalation so the two never drift.
162
211
  def build_work_item(mutation, example_ids, map)
@@ -250,23 +299,38 @@ module ActiveMutator
250
299
  end
251
300
  end
252
301
 
253
- def discover_subjects
302
+ def discover
254
303
  paths = @config.paths.empty? ? default_paths : @config.paths
255
- subjects = paths
304
+ files = paths
256
305
  .flat_map { |p| expand_path_arg(p) }
257
306
  .uniq
258
307
  .reject { |file| excluded?(file) }
259
- .sort.flat_map { |file| SubjectFinder.call(file) }
260
- subjects = subjects.reject(&:class_body?) unless @config.class_level
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) }
261
311
  if @config.subject_filter
262
312
  matcher = SubjectMatcher.new(@config.subject_filter)
263
313
  subjects = subjects.select { |s| matcher.match?(s.name) }
264
314
  end
315
+ since_candidates = []
265
316
  if @config.since
266
317
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
267
318
  subjects = subjects.select { |s| filter.cover?(s) }
319
+ since_candidates = filter.changed_files & scanned_files
268
320
  end
269
- subjects
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("/")}/") }
270
334
  end
271
335
 
272
336
  # Positional args may be files or directories. Anything else is an error:
@@ -346,7 +410,7 @@ module ActiveMutator
346
410
  # MAINTENANCE: any future flag that narrows the mutant set below "every
347
411
  # subject in the scanned files" MUST be added to this nil-trigger list,
348
412
  # or scoped accept runs will clobber out-of-scope ledger entries (#24).
349
- # --no-class-level drops every class_body subject (discover_subjects), so a
413
+ # --no-class-level drops every class_body subject (discover), so a
350
414
  # file's class-body fingerprint is absent even though the file is scanned;
351
415
  # without this guard its accepted ledger entry looks stale and gets pruned.
352
416
  def prune_scope(subjects)
@@ -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.each_line { |l| @changed[l.strip] = :all unless l.strip.empty? }
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
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.0"
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John