active_mutator 0.5.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: 45e58d5b8880a6716b7a78ee4011f0e5529600b3daa222ad39854723d08bd9d3
4
- data.tar.gz: d9c9067771e0596e3241d8a0bb408c3c9f041e98eac33f08066b1118feedb5b8
3
+ metadata.gz: a334e2726728421ba66d6a08db715587b8e0439b934489857b70d746912e89b8
4
+ data.tar.gz: cdce7a7856fd4b1f68b4cbf9dc5a62d58a1926555b203a01474d64aa95b6fbbe
5
5
  SHA512:
6
- metadata.gz: 2e6881dce81b23302b40220a9b593b290b22b08eb5f6c4426da96e2f5557e369cd635525f32aa357078c6ae10f289c05d216b025992ce23ff607841b42b71b1b
7
- data.tar.gz: 25fbb644044f506fcc4663ed26be6898187a4205a91778fdfc049240ecf7c1bb54a52f81b38e2ab9f59f75c00f0bbcd5d93b8ad6bc0d7c7a149d90cdc4f69cf4
6
+ metadata.gz: d1cfeffd4210a83fcd974a3b175eabf0da02a54b7d253664e3252ed5fc0487fd60fcf4ad3db54d29d12e53363842e0a5116e0602c6ce83a971813e8895a84dbf
7
+ data.tar.gz: d21dae9ed1a6e6b668517ad99130a7a6e2d7de6d6f9ebf4f620de5ce7e0690bc81d105e8f3d78aff72c288034a2366ae6a0dced6a35d2101b63421fde0ef1da0
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 changed
142
+ no code in a 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,33 @@ 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 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
272
+ counts as a real change here.
273
+ - With `--subject` and no `--since` there is no diff to judge, so
274
+ `--allow-empty` exits `0` unconditionally.
275
+
276
+ `--changed` follows the same rule, and an untracked candidate `.rb` file
277
+ counts as a candidate. This replaces the repo-side log-scraping script some
278
+ projects used to tell a docs-only PR from a broken `--since` range.
279
+
248
280
  ## Flags
249
281
 
250
282
  | Flag | Default | Meaning |
@@ -256,7 +288,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
256
288
  | `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
257
289
  | `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
258
290
  | `--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) |
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) |
260
292
  | `--format terminal\|json\|stryker-json\|github` | terminal | report format |
261
293
  | `--accept-survivors` | off | record survivors to the acceptance ledger |
262
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
@@ -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 changed no code " \
71
+ "in a mutable 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,14 @@ 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; `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)
12
+
5
13
  def initialize(config, reporter: nil)
6
14
  @config = config
7
15
  @reporter = reporter || build_reporter
@@ -13,14 +21,24 @@ module ActiveMutator
13
21
  ClosureReload.cap = @config.class_level_closure_cap
14
22
  preload!
15
23
  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
24
+ discovery = discover
25
+ subjects = discovery.subjects
20
26
  analyses = subjects.map { |s| Engine.new.analyze(s) }
21
27
  mutations = analyses.flat_map(&:mutations)
22
28
  mutations = mutations.first(@config.max_mutants) if @config.max_mutants
23
29
  invalid_count = analyses.sum(&:invalid_count)
30
+ # Decide emptiness before the baseline: a scoped run that plans nothing
31
+ # has no use for a coverage map, and building one spawns the whole spec
32
+ # suite (#47).
33
+ if mutations.empty? && (@config.since || @config.subject_filter)
34
+ return debug_plan([], []) if @config.debug_plan
35
+
36
+ return empty_plan_exit(invalid_count, discovery)
37
+ end
38
+
39
+ map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
40
+ .coverage_map(force: @config.force_baseline)
41
+ @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
24
42
 
25
43
  fingerprints = Fingerprint.for_mutations(mutations, root: @config.root)
26
44
  ledger = AcceptedLedger.load(@config.root)
@@ -29,7 +47,6 @@ module ActiveMutator
29
47
 
30
48
  items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
31
49
  return debug_plan(items, pre_results) if @config.debug_plan
32
- return empty_plan_exit if mutations.empty? && (@config.since || @config.subject_filter)
33
50
 
34
51
  pre_results.each { |r| @reporter.on_result(r) }
35
52
  calibrators = if @config.adaptive_timeout
@@ -145,18 +162,58 @@ module ActiveMutator
145
162
  # the usual cause is a --since range or --subject filter that matched no
146
163
  # mutable code, or class-body code dropped by --no-class-level (#23 covers
147
164
  # the zero-subject case for explicit paths).
148
- def empty_plan_exit
165
+ def empty_plan_exit(invalid_count, discovery)
166
+ @reporter.summary([], invalid_count: invalid_count, empty_plan: true)
149
167
  causes = []
150
168
  causes << "--since #{@config.since} matched no mutable code" if @config.since
151
169
  causes << "--subject #{@config.subject_filter} matched no subjects" if @config.subject_filter
152
170
  causes << "--no-class-level excludes class-body code" unless @config.class_level
153
171
  warn "active_mutator: no mutants planned (#{causes.join("; ")})"
154
- return 0 if @config.allow_empty
172
+ unless @config.allow_empty
173
+ warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
174
+ return 1
175
+ end
176
+
177
+ allow_empty_exit(discovery)
178
+ end
179
+
180
+ # --allow-empty forgives an empty plan only when the --since diff touched no
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.
186
+ def allow_empty_exit(discovery)
187
+ return 0 unless @config.since
188
+ return 0 if discovery.since_candidates.empty?
189
+
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
155
197
 
156
- warn "active_mutator: exiting 1; pass --allow-empty if an empty plan is expected"
198
+ if !@config.class_level && class_body_only?(discovery.since_matched_all, candidates)
199
+ warn "active_mutator: forgiving empty plan: changed lines are class-body code and --no-class-level is set"
200
+ return 0
201
+ end
202
+
203
+ warn "active_mutator: exiting 1; --allow-empty forgives an empty plan only when no candidate " \
204
+ "source file changed. Changed: #{candidates.join(", ")}"
157
205
  1
158
206
  end
159
207
 
208
+ # Every matched subject is a class body AND every candidate file has one:
209
+ # a candidate that matched nothing (comment-only edit, deletion) must not
210
+ # hide behind a class-body change in a different file. Candidates are
211
+ # non-empty here, so no matches at all fails the second test on its own.
212
+ def class_body_only?(subjects, candidates)
213
+ matched_files = subjects.map { |s| relative(s.file) }
214
+ subjects.all?(&:class_body?) && candidates.all? { |file| matched_files.include?(file) }
215
+ end
216
+
160
217
  # Single source of truth for lane/timeout/variable derivation, shared by
161
218
  # phase-1 planning and phase-2 escalation so the two never drift.
162
219
  def build_work_item(mutation, example_ids, map)
@@ -250,23 +307,39 @@ module ActiveMutator
250
307
  end
251
308
  end
252
309
 
253
- def discover_subjects
310
+ def discover
254
311
  paths = @config.paths.empty? ? default_paths : @config.paths
255
- subjects = paths
312
+ files = paths
256
313
  .flat_map { |p| expand_path_arg(p) }
257
314
  .uniq
258
315
  .reject { |file| excluded?(file) }
259
- .sort.flat_map { |file| SubjectFinder.call(file) }
260
- subjects = subjects.reject(&:class_body?) unless @config.class_level
316
+ .sort
317
+ scanned_files = files.map { |f| relative(f) }.reject { |rel| under_spec_paths?(rel) }
318
+ subjects = files.flat_map { |file| SubjectFinder.call(file) }
261
319
  if @config.subject_filter
262
320
  matcher = SubjectMatcher.new(@config.subject_filter)
263
321
  subjects = subjects.select { |s| matcher.match?(s.name) }
264
322
  end
323
+ since_candidates = []
265
324
  if @config.since
266
325
  filter = SinceFilter.new(ref: @config.since, root: @config.root)
267
326
  subjects = subjects.select { |s| filter.cover?(s) }
327
+ since_candidates = filter.changed_files & scanned_files
268
328
  end
269
- subjects
329
+ # Class bodies drop out LAST so since_matched_all still knows about them.
330
+ since_matched_all = subjects
331
+ subjects = subjects.reject(&:class_body?) unless @config.class_level
332
+ Discovery.new(subjects: subjects, scanned_files: scanned_files,
333
+ since_candidates: since_candidates, since_matched_all: since_matched_all,
334
+ since_filter: filter)
335
+ end
336
+
337
+ def relative(file) = file.delete_prefix(@config.root.chomp("/") + "/")
338
+
339
+ # Scanned entries are files, so only the "inside this directory" test
340
+ # matters; the trailing slash keeps `spec` from swallowing `spec_tools/`.
341
+ def under_spec_paths?(rel)
342
+ @config.spec_paths.any? { |sp| rel.start_with?("#{sp.chomp("/")}/") }
270
343
  end
271
344
 
272
345
  # Positional args may be files or directories. Anything else is an error:
@@ -346,7 +419,7 @@ module ActiveMutator
346
419
  # MAINTENANCE: any future flag that narrows the mutant set below "every
347
420
  # subject in the scanned files" MUST be added to this nil-trigger list,
348
421
  # 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
422
+ # --no-class-level drops every class_body subject (discover), so a
350
423
  # file's class-body fingerprint is absent even though the file is scanned;
351
424
  # without this guard its accepted ledger entry looks stale and gets pruned.
352
425
  def prune_scope(subjects)
@@ -20,7 +20,45 @@ 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
+
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
+
23
60
  def initialize(ref:, root:)
61
+ @ref = ref
24
62
  @root = root
25
63
  diff = IO.popen(
26
64
  ["git", "-C", root, "diff", "--unified=0", ref, "--", "*.rb"], &:read
@@ -28,13 +66,29 @@ module ActiveMutator
28
66
  raise Error, "git diff #{ref} failed" unless $?.success?
29
67
 
30
68
  @changed = self.class.parse(diff)
69
+ @touched = self.class.touched_files(diff)
31
70
  untracked = IO.popen(
32
71
  ["git", "-C", root, "ls-files", "--others", "--exclude-standard", "--", "*.rb"], &:read
33
72
  )
34
73
  # Untracked files are invisible to `git diff` but are agentic TDD's most
35
74
  # common case (brand-new file + spec). Whole-file sentinel: every line
36
75
  # counts as changed.
37
- untracked.each_line { |l| @changed[l.strip] = :all unless l.strip.empty? }
76
+ untracked.split("\n").each { |l| @changed[l] = :all }
77
+ @touched |= @changed.keys
78
+ end
79
+
80
+ # Root-relative paths of every .rb file the diff touched (tracked files,
81
+ # including deletion-only ones, plus untracked files). Pure accessor: no
82
+ # further git calls.
83
+ def changed_files = @touched
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)))
38
92
  end
39
93
 
40
94
  def cover?(subject)
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.5.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.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel John