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 +4 -4
- data/README.md +37 -5
- data/lib/active_mutator/baseline_hooks.rb +24 -2
- data/lib/active_mutator/cli.rb +3 -1
- data/lib/active_mutator/reporter/github.rb +2 -2
- data/lib/active_mutator/reporter/json.rb +10 -7
- data/lib/active_mutator/reporter/stryker_json.rb +6 -1
- data/lib/active_mutator/reporter/terminal.rb +13 -6
- data/lib/active_mutator/runner.rb +87 -14
- data/lib/active_mutator/since_filter.rb +55 -1
- 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
|
@@ -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
|
|
135
|
-
`--fail-at` gate. A `--since`
|
|
136
|
-
|
|
137
|
-
|
|
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 |
|
|
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(
|
|
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
|
@@ -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",
|
|
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
|
-
|
|
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" => 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 "
|
|
28
|
-
return "
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
310
|
+
def discover
|
|
254
311
|
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
255
|
-
|
|
312
|
+
files = paths
|
|
256
313
|
.flat_map { |p| expand_path_arg(p) }
|
|
257
314
|
.uniq
|
|
258
315
|
.reject { |file| excluded?(file) }
|
|
259
|
-
.sort
|
|
260
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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)
|