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 +4 -4
- data/README.md +33 -5
- 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 +78 -14
- data/lib/active_mutator/since_filter.rb +17 -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: 60c123ba92745825d5041728a14144e2cad95ba814d54fcb72bba40e1d5c0089
|
|
4
|
+
data.tar.gz: b7f96c322c455aa01d4ef19e04aa017f61fecb82347b1da7e315cfdc4316708e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 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 |
|
|
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 |
|
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 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
|
-
|
|
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,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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
302
|
+
def discover
|
|
254
303
|
paths = @config.paths.empty? ? default_paths : @config.paths
|
|
255
|
-
|
|
304
|
+
files = paths
|
|
256
305
|
.flat_map { |p| expand_path_arg(p) }
|
|
257
306
|
.uniq
|
|
258
307
|
.reject { |file| excluded?(file) }
|
|
259
|
-
.sort
|
|
260
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|