active_mutator 0.1.1 → 0.2.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 +103 -11
- data/lib/active_mutator/accepted_ledger.rb +22 -7
- data/lib/active_mutator/baseline_delta.rb +67 -1
- data/lib/active_mutator/cli.rb +16 -4
- data/lib/active_mutator/config.rb +2 -1
- data/lib/active_mutator/config_file.rb +89 -0
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +15 -2
- data/lib/active_mutator/inserter.rb +6 -3
- data/lib/active_mutator/operators/base.rb +2 -1
- data/lib/active_mutator/operators/call_swap.rb +16 -0
- data/lib/active_mutator/operators/literal.rb +14 -2
- data/lib/active_mutator/reporter/github.rb +36 -0
- data/lib/active_mutator/reporter/json.rb +1 -0
- data/lib/active_mutator/reporter/operator_stats.rb +20 -0
- data/lib/active_mutator/reporter/stryker_json.rb +117 -0
- data/lib/active_mutator/reporter/terminal.rb +11 -0
- data/lib/active_mutator/runner.rb +119 -15
- data/lib/active_mutator/scheduler.rb +41 -5
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +7 -1
- data/lib/active_mutator/subject_finder.rb +46 -7
- data/lib/active_mutator/subject_matcher.rb +23 -0
- data/lib/active_mutator/timeout_calibrator.rb +75 -0
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/work_item.rb +8 -1
- data/lib/active_mutator/worker.rb +3 -0
- data/lib/active_mutator.rb +8 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5729b9385c24e575708a5227a3838ae28e09dd2ddf3e40e1883e9e74f7399275
|
|
4
|
+
data.tar.gz: 69b41b3304c8c008f54f6c20547ff9283d26a278eea706dc7cf923466dde1b5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e0dce87a35314cb96e68e7051e3dfa8bbc329b58a3c41beef1201c7c5e8a3205d58a28593bf99382dfd1f9a9fd52859b44def8e2ee5d57dd0879e9dfcadec37
|
|
7
|
+
data.tar.gz: 5ec63925341ef8356801ee171e83af5b6323feb0f15838b1430421c9e5aaf73017d0e5ebaafb653039917a97144933e3572c2d182ba0544de27fbe60a0d7d6ca
|
data/README.md
CHANGED
|
@@ -128,8 +128,14 @@ Each character on the progress line is one mutant, printed as it finishes:
|
|
|
128
128
|
|
|
129
129
|
`invalid` mutants (edits that don't even re-parse as valid Ruby) are
|
|
130
130
|
discarded before scheduling and reported as a count only. Exit code is `1`
|
|
131
|
-
if unaccepted survivors exist,
|
|
132
|
-
|
|
131
|
+
if unaccepted survivors exist (or, with `--fail-at`, if the score is below
|
|
132
|
+
the threshold), `0` otherwise, including when there are only `uncovered`,
|
|
133
|
+
`accepted`, or `error` results. The JSON report's `exit_reason` field
|
|
134
|
+
reflects survivor presence, independent of the `--fail-at` gate.
|
|
135
|
+
|
|
136
|
+
When survivors exist, the summary also prints a per-operator table showing
|
|
137
|
+
how often each operator's mutants survive, to help spot likely-equivalent
|
|
138
|
+
mutant patterns.
|
|
133
139
|
|
|
134
140
|
## How it works, compactly
|
|
135
141
|
|
|
@@ -155,17 +161,42 @@ the serial lane for browser specs, timeout budgets, and every status, is in
|
|
|
155
161
|
|
|
156
162
|
```bash
|
|
157
163
|
active_mutator # mutate app/ and lib/, full run
|
|
158
|
-
active_mutator app/models # scope by path
|
|
164
|
+
active_mutator app/models # scope by path (directory)
|
|
165
|
+
active_mutator app/models/document.rb # scope to a single file
|
|
159
166
|
active_mutator --changed # uncommitted work only (dev loop)
|
|
160
167
|
active_mutator --since origin/main # PR scope (CI)
|
|
161
168
|
active_mutator --subject 'Foo::Bar#baz' # one method
|
|
169
|
+
active_mutator --exclude 'lib/generated' # skip a subtree (repeatable)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`--subject` also takes broader expressions: `Foo::Bar` (all methods of
|
|
173
|
+
that constant), `Foo::Bar*` (raw name prefix — matches `Foo::Bar::Qux`
|
|
174
|
+
and also `Foo::Barn`), `Foo::Bar#*` (instance
|
|
175
|
+
methods only), `Foo::Bar.*` (singleton methods only).
|
|
176
|
+
|
|
177
|
+
`--exclude PAT` is a glob relative to the project root, applied during
|
|
178
|
+
subject discovery, and gitignore-like: `lib/generated`, `lib/generated/`,
|
|
179
|
+
and `lib/generated/**` all exclude the whole subtree. File globs like
|
|
180
|
+
`**/legacy/*` work too.
|
|
181
|
+
|
|
182
|
+
Skip a single method by putting `# active_mutator:skip` on the line above
|
|
183
|
+
its `def`:
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
# active_mutator:skip
|
|
187
|
+
def legacy_delegator
|
|
188
|
+
target.call
|
|
189
|
+
end
|
|
162
190
|
```
|
|
163
191
|
|
|
164
192
|
Statuses: `killed` (test failed, this is good), `survived` (test gap),
|
|
165
193
|
`timeout` (counts as detected), `uncovered` (no covering example, this is
|
|
166
194
|
coverage debt), `accepted` (known-equivalent, see ledger), `error`,
|
|
167
195
|
`invalid` (discarded).
|
|
168
|
-
Exit code 1 if unaccepted survivors exist
|
|
196
|
+
Exit code is 1 if unaccepted survivors exist (or, with `--fail-at`, if the
|
|
197
|
+
score is below the threshold). Mistyped positional paths (a file that
|
|
198
|
+
doesn't exist, or a non-`.rb` file) are an error (exit 2) instead of a
|
|
199
|
+
vacuous green run.
|
|
169
200
|
|
|
170
201
|
Score = (killed + timeout) / (killed + timeout + survived).
|
|
171
202
|
|
|
@@ -185,13 +216,31 @@ git add .active_mutator_accepted.json # committed state
|
|
|
185
216
|
```
|
|
186
217
|
|
|
187
218
|
Acceptance takes effect on the next run. The accepting run still exits 1.
|
|
219
|
+
Scoped accepting runs (`--changed`, `--subject`, path args) are safe: the
|
|
220
|
+
ledger only prunes entries in files fully scanned by non-narrowed runs, so
|
|
221
|
+
out-of-scope acceptances are never dropped.
|
|
188
222
|
Agent workflow: see [`docs/skills/mutation-check.md`](docs/skills/mutation-check.md).
|
|
189
223
|
|
|
224
|
+
## Reports
|
|
225
|
+
|
|
226
|
+
`--format stryker-json` writes `.active_mutator/mutation-report.json` in the
|
|
227
|
+
Stryker [mutation-testing-report-schema](https://github.com/stryker-mutator/mutation-testing-elements)
|
|
228
|
+
v2 format. Open it in the
|
|
229
|
+
[Stryker report viewer](https://microsoft.github.io/mutation-testing-elements/)
|
|
230
|
+
for per-file mutant maps with inline diffs, filterable by status.
|
|
231
|
+
|
|
232
|
+
`--format github` prints one `::warning` annotation per surviving mutant, so
|
|
233
|
+
survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
234
|
+
|
|
235
|
+
bundle exec active_mutator --since origin/main --format github
|
|
236
|
+
|
|
190
237
|
## CI recipe
|
|
191
238
|
|
|
192
|
-
- Per-PR: `active_mutator --since origin/main` (minutes
|
|
239
|
+
- Per-PR: `active_mutator --since origin/main --format github` (minutes;
|
|
240
|
+
survivors annotate the PR diff)
|
|
193
241
|
- Nightly: `active_mutator --force-baseline` (full run; also recovers the
|
|
194
|
-
|
|
242
|
+
residual blind spot — constant-reference detection handles the common
|
|
243
|
+
newly-covering-example case since 0.2)
|
|
195
244
|
|
|
196
245
|
## Flags
|
|
197
246
|
|
|
@@ -200,15 +249,26 @@ Agent workflow: see [`docs/skills/mutation-check.md`](docs/skills/mutation-check
|
|
|
200
249
|
| `--jobs N` | half the cores | fork-pool width |
|
|
201
250
|
| `--changed` | none | mutate uncommitted + untracked work |
|
|
202
251
|
| `--since REF` | none | mutate methods changed since REF |
|
|
203
|
-
| `--subject
|
|
204
|
-
| `--
|
|
252
|
+
| `--subject EXPR` | none | subject expression, e.g. `Foo#bar`, `Foo::Bar`, `Foo::Bar*`, `Foo#*`, `Foo.*` |
|
|
253
|
+
| `--exclude PAT` | none | skip files matching glob during subject discovery (repeatable, gitignore-like) |
|
|
254
|
+
| `--max-mutants N` | none | deterministic sample of the first N mutants (quick smoke run on huge scopes; accepted/uncovered mutants count against N) |
|
|
255
|
+
| `--debug-plan` | off | print planned mutants as JSON and exit without running |
|
|
256
|
+
| `--format terminal\|json\|stryker-json\|github` | terminal | report format |
|
|
205
257
|
| `--accept-survivors` | off | record survivors to the acceptance ledger |
|
|
206
258
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
207
259
|
| `--preload-helper FILE` / `--no-preload-helper` | auto-detect | parent spec-helper preload |
|
|
208
260
|
| `--serial-pattern PAT` | `spec/system/`, `spec/features/` | covering-path prefixes forced serial |
|
|
209
261
|
| `--browser-boot-seconds S` | 15 | serial-lane timeout bump |
|
|
210
262
|
| `--timeout-factor F` / `--timeout-floor S` | 8 / 10 | mutation timeout budget |
|
|
263
|
+
| `--[no-]adaptive-timeout` | on | scale timeout budgets from observed worker wall times (median utilization, grow-only, clamped 1x–4x; `--timeout-factor`/`--timeout-floor` set the starting budget) |
|
|
211
264
|
| `--require FILE` | none | preload files (repeatable) |
|
|
265
|
+
| `--operator FILE` | none | load a custom operator file before analysis (repeatable) |
|
|
266
|
+
| `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
|
|
267
|
+
|
|
268
|
+
`--debug-plan` prints the planned mutant list as one JSON document
|
|
269
|
+
(`{"planned": [...], "pre_resolved": {...}}`) and exits without running
|
|
270
|
+
anything. A coverage baseline is still built or loaded, since timeouts
|
|
271
|
+
and covering examples come from it.
|
|
212
272
|
|
|
213
273
|
Every active_mutator process sets `ENV["ACTIVE_MUTATOR"] = "1"`. Use it to
|
|
214
274
|
guard SimpleCov or other tooling in your spec helper:
|
|
@@ -217,12 +277,42 @@ guard SimpleCov or other tooling in your spec helper:
|
|
|
217
277
|
SimpleCov.start "rails" unless ENV["ACTIVE_MUTATOR"]
|
|
218
278
|
```
|
|
219
279
|
|
|
280
|
+
## Configuration file
|
|
281
|
+
|
|
282
|
+
Put team-wide settings in `.active_mutator.yml` at the project root; CLI
|
|
283
|
+
flags override file values (`--require` and `--exclude` add to the file's
|
|
284
|
+
lists; the first `--serial-pattern` replaces them). Recognized keys:
|
|
285
|
+
`jobs`, `format`, `timeout_factor`, `timeout_floor`,
|
|
286
|
+
`browser_boot_seconds`, `fail_at`, `exclude`, `serial_patterns`,
|
|
287
|
+
`requires`, `operators` (custom operator files, loaded before analysis; see
|
|
288
|
+
[Custom operators](docs/guides/custom-operators.md)),
|
|
289
|
+
`preload_helper` (a path, or `false` to skip preload),
|
|
290
|
+
`adaptive_timeout` (`true`/`false`).
|
|
291
|
+
Unknown keys and wrong types are errors, not silent no-ops.
|
|
292
|
+
|
|
293
|
+
```yaml
|
|
294
|
+
# .active_mutator.yml
|
|
295
|
+
jobs: 4
|
|
296
|
+
exclude:
|
|
297
|
+
- lib/generated
|
|
298
|
+
serial_patterns:
|
|
299
|
+
- spec/system/
|
|
300
|
+
fail_at: 90 # legacy suite: gate on score instead of zero-survivors
|
|
301
|
+
```
|
|
302
|
+
|
|
220
303
|
## Known limits (v1.1)
|
|
221
304
|
|
|
222
305
|
Method bodies only (no class-macro/constant mutation). RSpec only.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
306
|
+
Plain heredoc bodies ARE mutated (emptied); interpolated heredocs are
|
|
307
|
+
skipped. `class << self` bodies are mutated as singleton subjects
|
|
308
|
+
(`class << obj` and top-level `class << self` are skipped). Nested defs
|
|
309
|
+
mutate as part of the enclosing method's body — they get no subject of
|
|
310
|
+
their own (a directly-inserted mutant would be reverted whenever the
|
|
311
|
+
outer method re-runs the `def`). The incremental baseline recovers the residual blind spot —
|
|
312
|
+
constant-reference detection handles the common case since 0.2, and a few
|
|
313
|
+
residual cases (pure indirection, partially-covering files, leaf-only or
|
|
314
|
+
wrapper-only references, `class ::Foo`, `Data.define`/`Struct.new` value
|
|
315
|
+
objects) are caught by nightly `--force-baseline`.
|
|
226
316
|
|
|
227
317
|
## Guides
|
|
228
318
|
|
|
@@ -234,6 +324,8 @@ changed code after the change (nightly `--force-baseline` recovers).
|
|
|
234
324
|
- [Operator reference](docs/guides/operators.md): every mutation
|
|
235
325
|
active_mutator can generate, with before/after examples and what a
|
|
236
326
|
survivor of each one means.
|
|
327
|
+
- [Custom operators](docs/guides/custom-operators.md): write and load your
|
|
328
|
+
own mutation operators with `--operator` / the `operators:` config key.
|
|
237
329
|
- [Mutation-check skill](docs/skills/mutation-check.md): the agent-facing
|
|
238
330
|
workflow. Run, read survivors, strengthen tests, or accept with a reason.
|
|
239
331
|
|
|
@@ -5,6 +5,10 @@ module ActiveMutator
|
|
|
5
5
|
# Committed, repo-root ledger of accepted (equivalent) survivors.
|
|
6
6
|
# Deliberately NOT inside .active_mutator/: that dir is gitignored and
|
|
7
7
|
# disposable, while acceptance decisions are durable team/CI state.
|
|
8
|
+
#
|
|
9
|
+
# Entries whose file no longer exists are kept, not pruned: the file may
|
|
10
|
+
# still exist on another branch, so deletion is the user's call. The runner
|
|
11
|
+
# warns about them on every run instead (see #missing_file_entries).
|
|
8
12
|
class AcceptedLedger
|
|
9
13
|
FILENAME = ".active_mutator_accepted.json"
|
|
10
14
|
|
|
@@ -28,16 +32,27 @@ module ActiveMutator
|
|
|
28
32
|
|
|
29
33
|
def accepted?(fingerprint) = @entries.include?(fingerprint)
|
|
30
34
|
|
|
31
|
-
|
|
35
|
+
# Entries outside the scanned files can't be judged by this run, so they
|
|
36
|
+
# are never stale here. scanned_files: nil means "no file was fully
|
|
37
|
+
# scanned" (subject-level filtering active) — union only, prune nothing.
|
|
38
|
+
# See #24: a scoped accept run once deleted every out-of-scope entry.
|
|
39
|
+
def stale_entries(all_current_fingerprints, scanned_files:)
|
|
40
|
+
return [] if scanned_files.nil?
|
|
41
|
+
|
|
32
42
|
current = all_current_fingerprints.to_set
|
|
33
|
-
|
|
43
|
+
scanned = scanned_files.to_set
|
|
44
|
+
@entries.reject { |e| current.include?(e) || !scanned.include?(e.file) }
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
# Missing is objective regardless of run scope: such entries can never
|
|
48
|
+
# appear in scanned_files, so without this they'd be immortal AND silent.
|
|
49
|
+
def missing_file_entries(root)
|
|
50
|
+
@entries.reject { |e| File.exist?(File.join(root, e.file)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def accept!(new_fingerprints, all_current_fingerprints, scanned_files:)
|
|
54
|
+
stale = stale_entries(all_current_fingerprints, scanned_files: scanned_files).to_set
|
|
55
|
+
@entries = (@entries + new_fingerprints).uniq.reject { |e| stale.include?(e) }
|
|
41
56
|
AtomicFile.write(@path, JSON.pretty_generate(@entries.map(&:to_h)))
|
|
42
57
|
nil
|
|
43
58
|
end
|
|
@@ -11,6 +11,10 @@ module ActiveMutator
|
|
|
11
11
|
FULL = Delta.new(full: true, rerun_spec_files: [], rerun_example_ids: [],
|
|
12
12
|
drop_example_ids: [], drop_source_files: [])
|
|
13
13
|
|
|
14
|
+
# If a changed constant is referenced by more than this share of all spec
|
|
15
|
+
# files, a full re-run is cheaper and simpler than a giant partial one.
|
|
16
|
+
REFERENCE_FULL_RATIO = 0.5
|
|
17
|
+
|
|
14
18
|
def self.compute(old_digests:, new_digests:, coverage_map:, root:)
|
|
15
19
|
changed = (old_digests.keys | new_digests.keys)
|
|
16
20
|
.reject { |k| old_digests[k] == new_digests[k] }
|
|
@@ -21,6 +25,13 @@ module ActiveMutator
|
|
|
21
25
|
drop_example_ids = []
|
|
22
26
|
drop_source_files = []
|
|
23
27
|
|
|
28
|
+
# Read the spec-file list and their contents once per compute call, not
|
|
29
|
+
# once per changed source file: newly_covering_candidates scans every
|
|
30
|
+
# spec file, so re-globbing and re-reading inside the loop was
|
|
31
|
+
# O(changed_files x spec_files) IO. Built lazily so a delta with no
|
|
32
|
+
# scannable source change pays nothing.
|
|
33
|
+
spec_contents = nil
|
|
34
|
+
|
|
24
35
|
changed.each do |rel|
|
|
25
36
|
added = !old_digests.key?(rel)
|
|
26
37
|
deleted = !new_digests.key?(rel)
|
|
@@ -39,7 +50,17 @@ module ActiveMutator
|
|
|
39
50
|
else
|
|
40
51
|
abs = File.join(root, rel)
|
|
41
52
|
drop_source_files << abs if deleted
|
|
42
|
-
|
|
53
|
+
unless added
|
|
54
|
+
rerun_example_ids.concat(coverage_map.examples_covering_file(abs))
|
|
55
|
+
end
|
|
56
|
+
unless deleted
|
|
57
|
+
spec_contents ||= Dir[File.join(root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
|
|
58
|
+
candidates = newly_covering_candidates(root: root, rel: rel, coverage_map: coverage_map,
|
|
59
|
+
spec_contents: spec_contents)
|
|
60
|
+
return FULL if candidates == :full
|
|
61
|
+
|
|
62
|
+
rerun_spec_files.concat(candidates)
|
|
63
|
+
end
|
|
43
64
|
end
|
|
44
65
|
end
|
|
45
66
|
|
|
@@ -53,5 +74,50 @@ module ActiveMutator
|
|
|
53
74
|
def self.full_trigger?(rel)
|
|
54
75
|
rel.start_with?("spec/support/") || !rel.end_with?(".rb")
|
|
55
76
|
end
|
|
77
|
+
|
|
78
|
+
# #11: an unchanged spec file can START covering a changed source file
|
|
79
|
+
# because of the edit itself. Cheap static detection: spec files that
|
|
80
|
+
# textually reference a constant the changed file defines, but currently
|
|
81
|
+
# contribute zero coverage to it, get re-run. Files already covering it
|
|
82
|
+
# are handled example-by-example via rerun_example_ids.
|
|
83
|
+
def self.newly_covering_candidates(root:, rel:, coverage_map:, spec_contents:)
|
|
84
|
+
abs = File.join(root, rel)
|
|
85
|
+
return [] unless File.exist?(abs)
|
|
86
|
+
|
|
87
|
+
constants = DefinedConstants.in_source(File.read(abs))
|
|
88
|
+
return [] if constants.empty?
|
|
89
|
+
|
|
90
|
+
all_specs = spec_contents.keys
|
|
91
|
+
|
|
92
|
+
covering_specs = coverage_map.examples_covering_file(abs)
|
|
93
|
+
.map { |id| spec_file_of(id) }.to_a.uniq
|
|
94
|
+
# Escaping is required: dynamic-namespace class definitions (e.g.
|
|
95
|
+
# `class (a)::Baz`, `class foo.bar::Baz`) make constant_path.slice carry
|
|
96
|
+
# regex metachars. Unescaped, "(a)::Baz" would match the literal text
|
|
97
|
+
# "a::Baz" — a false candidate.
|
|
98
|
+
# TODO(#11, Task 10 residual gap): a top-level `class ::Foo` yields the
|
|
99
|
+
# slice "::Foo", and /\b::Foo\b/ can never match (no word boundary
|
|
100
|
+
# before ":"), so such files are silently unscanned.
|
|
101
|
+
pattern = /\b(?:#{constants.map { |c| Regexp.escape(c) }.join("|")})\b/
|
|
102
|
+
candidates = all_specs.filter_map do |spec_abs|
|
|
103
|
+
spec_rel = spec_abs.delete_prefix(root).delete_prefix("/")
|
|
104
|
+
next if covering_specs.include?(spec_rel)
|
|
105
|
+
|
|
106
|
+
spec_rel if spec_contents.fetch(spec_abs).match?(pattern)
|
|
107
|
+
end
|
|
108
|
+
if candidates.size > 1 && candidates.size > all_specs.size * REFERENCE_FULL_RATIO
|
|
109
|
+
# Never silently degrade: a full baseline where the user expected an
|
|
110
|
+
# incremental refresh must be explained, or it looks like a hang.
|
|
111
|
+
warn "active_mutator: constant-reference scan matched #{candidates.size} of " \
|
|
112
|
+
"#{all_specs.size} spec files for #{rel}; falling back to full baseline"
|
|
113
|
+
return :full
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
candidates
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.spec_file_of(example_id)
|
|
120
|
+
example_id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "")
|
|
121
|
+
end
|
|
56
122
|
end
|
|
57
123
|
end
|
data/lib/active_mutator/cli.rb
CHANGED
|
@@ -22,16 +22,20 @@ module ActiveMutator
|
|
|
22
22
|
# boot cost (RSpec setup + spec file loading).
|
|
23
23
|
requires: [], timeout_factor: 8.0, timeout_floor: 10.0, force_baseline: false,
|
|
24
24
|
preload_helper: nil, serial_patterns: ["spec/system/", "spec/features/"],
|
|
25
|
-
browser_boot_seconds: 15.0, accept_survivors: false
|
|
25
|
+
browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
|
|
26
|
+
max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
|
|
27
|
+
operator_paths: []
|
|
26
28
|
}
|
|
29
|
+
options.merge!(ConfigFile.load(Dir.pwd))
|
|
27
30
|
paths = OptionParser.new do |o|
|
|
28
31
|
o.banner = "Usage: active_mutator [paths] [options]"
|
|
29
32
|
o.on("--since REF", "Mutate only methods changed since git REF") { |v| options[:since] = v }
|
|
30
33
|
o.on("--changed", "Mutate uncommitted work (alias for --since HEAD, plus untracked files)") { options[:since] = "HEAD" }
|
|
31
|
-
o.on("--subject NAME", "Mutate
|
|
34
|
+
o.on("--subject NAME", "Mutate matching subjects: Foo::Bar#baz, Foo::Bar, Foo::Bar*, Foo::Bar#*") { |v| options[:subject_filter] = v }
|
|
32
35
|
o.on("--jobs N", Integer, "Concurrent workers (default: half the CPU count)") { |v| options[:jobs] = v }
|
|
33
|
-
o.on("--format FMT",
|
|
34
|
-
o.on("--require FILE", "File to require before mutating (repeatable)") { |v| options[:requires] << v }
|
|
36
|
+
o.on("--format FMT", ConfigFile::FORMATS, "Output format") { |v| options[:format] = v.tr("-", "_").to_sym }
|
|
37
|
+
o.on("--require FILE", "File to require before mutating (repeatable; adds to config-file requires)") { |v| options[:requires] << v }
|
|
38
|
+
o.on("--operator FILE", "Ruby file defining a custom operator, loaded before analysis (repeatable)") { |v| options[:operator_paths] << v }
|
|
35
39
|
o.on("--force-baseline", "Ignore cached coverage map") { options[:force_baseline] = true }
|
|
36
40
|
o.on("--timeout-factor F", Float, "Timeout = baseline time * F + floor") { |v| options[:timeout_factor] = v }
|
|
37
41
|
o.on("--timeout-floor S", Float, "Minimum timeout seconds") { |v| options[:timeout_floor] = v }
|
|
@@ -43,7 +47,15 @@ module ActiveMutator
|
|
|
43
47
|
options[:serial_patterns] << v
|
|
44
48
|
end
|
|
45
49
|
o.on("--browser-boot-seconds S", Float, "Extra timeout budget for serial-lane mutants") { |v| options[:browser_boot_seconds] = v }
|
|
50
|
+
o.on("--[no-]adaptive-timeout", "Scale timeout budgets from observed worker wall times (default: on)") { |v| options[:adaptive_timeout] = v }
|
|
46
51
|
o.on("--accept-survivors", "Record surviving mutants into the acceptance ledger") { options[:accept_survivors] = true }
|
|
52
|
+
o.on("--exclude PAT", "Skip files matching glob, relative to root (repeatable)") { |v| options[:exclude] << v }
|
|
53
|
+
o.on("--max-mutants N", Integer, "Deterministically sample the first N mutants") { |v| options[:max_mutants] = v }
|
|
54
|
+
o.on("--debug-plan", "Print the planned mutant list as JSON and exit") { options[:debug_plan] = true }
|
|
55
|
+
o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
|
|
56
|
+
raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
|
|
57
|
+
options[:fail_at] = v
|
|
58
|
+
end
|
|
47
59
|
end.parse(argv)
|
|
48
60
|
options.delete(:serial_patterns_replaced)
|
|
49
61
|
|
|
@@ -4,5 +4,6 @@ module ActiveMutator
|
|
|
4
4
|
Config = Data.define(:paths, :since, :subject_filter, :jobs, :format, :requires,
|
|
5
5
|
:timeout_factor, :timeout_floor, :force_baseline, :root,
|
|
6
6
|
:preload_helper, :serial_patterns, :browser_boot_seconds,
|
|
7
|
-
:accept_survivors
|
|
7
|
+
:accept_survivors, :exclude, :max_mutants, :debug_plan,
|
|
8
|
+
:fail_at, :adaptive_timeout, :operator_paths)
|
|
8
9
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Project config file, layered UNDER CLI flags: CLI.parse seeds its option
|
|
5
|
+
# defaults from this before OptionParser runs, so any flag given on the
|
|
6
|
+
# command line wins. Strict on unknown keys and types — a typo silently
|
|
7
|
+
# ignored would be a config that silently doesn't apply.
|
|
8
|
+
class ConfigFile
|
|
9
|
+
FILENAME = ".active_mutator.yml"
|
|
10
|
+
|
|
11
|
+
FORMATS = %w[terminal json stryker-json github].freeze
|
|
12
|
+
|
|
13
|
+
KEYS = {
|
|
14
|
+
"jobs" => :integer,
|
|
15
|
+
"format" => :format,
|
|
16
|
+
"timeout_factor" => :number,
|
|
17
|
+
"timeout_floor" => :number,
|
|
18
|
+
"browser_boot_seconds" => :number,
|
|
19
|
+
"fail_at" => :score,
|
|
20
|
+
"exclude" => :string_list,
|
|
21
|
+
"serial_patterns" => :string_list,
|
|
22
|
+
"requires" => :string_list,
|
|
23
|
+
"operators" => :string_list,
|
|
24
|
+
"preload_helper" => :preload_helper,
|
|
25
|
+
"adaptive_timeout" => :boolean
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# YAML keys that don't match their Config member name.
|
|
29
|
+
RENAMES = { "operators" => :operator_paths }.freeze
|
|
30
|
+
|
|
31
|
+
def self.load(root)
|
|
32
|
+
path = File.join(root, FILENAME)
|
|
33
|
+
return {} unless File.exist?(path)
|
|
34
|
+
|
|
35
|
+
data = parse(path)
|
|
36
|
+
return {} if data.nil?
|
|
37
|
+
raise Error, "#{FILENAME}: top level must be a mapping" unless data.is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
data.to_h do |key, value|
|
|
40
|
+
validator = KEYS[key]
|
|
41
|
+
raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
|
|
42
|
+
|
|
43
|
+
[RENAMES.fetch(key, key.to_sym), coerce(key, validator, value)]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.parse(path)
|
|
48
|
+
YAML.safe_load_file(path, aliases: true)
|
|
49
|
+
rescue Psych::Exception => e
|
|
50
|
+
raise Error, "#{FILENAME}: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.coerce(key, validator, value)
|
|
54
|
+
case validator
|
|
55
|
+
when :integer
|
|
56
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
57
|
+
value
|
|
58
|
+
when :number
|
|
59
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
60
|
+
value.to_f
|
|
61
|
+
when :score
|
|
62
|
+
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
63
|
+
raise Error, "#{FILENAME}: #{key} must be within 0..100" unless (0..100).cover?(value)
|
|
64
|
+
value.to_f
|
|
65
|
+
when :format
|
|
66
|
+
unless FORMATS.include?(value)
|
|
67
|
+
raise Error, "#{FILENAME}: format must be one of #{FORMATS.join(", ")}"
|
|
68
|
+
end
|
|
69
|
+
value.tr("-", "_").to_sym
|
|
70
|
+
when :string_list
|
|
71
|
+
unless value.is_a?(Array) && value.all?(String)
|
|
72
|
+
raise Error, "#{FILENAME}: #{key} must be a list of strings"
|
|
73
|
+
end
|
|
74
|
+
value
|
|
75
|
+
when :boolean
|
|
76
|
+
unless [true, false].include?(value)
|
|
77
|
+
raise Error, "#{FILENAME}: #{key} must be true or false"
|
|
78
|
+
end
|
|
79
|
+
value
|
|
80
|
+
when :preload_helper
|
|
81
|
+
return :none if value == false
|
|
82
|
+
raise Error, "#{FILENAME}: preload_helper must be a path or false" unless value.is_a?(String)
|
|
83
|
+
value
|
|
84
|
+
else
|
|
85
|
+
raise Error, "unhandled validator #{validator}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module ActiveMutator
|
|
4
|
+
# Deepest fully qualified names of classes/modules a source file defines
|
|
5
|
+
# ("Billing::Invoice"). Two shorthands are deliberately never emitted,
|
|
6
|
+
# because either would let a single common token match half of any real
|
|
7
|
+
# spec suite and trip BaselineDelta's full-run fallback on every edit:
|
|
8
|
+
# - bare leaves ("Config" for MyApp::Config)
|
|
9
|
+
# - pure namespace wrappers ("MyApp" for `module MyApp; class Config`):
|
|
10
|
+
# every file in a namespaced app reopens the top module, and every
|
|
11
|
+
# spec mentions it.
|
|
12
|
+
# A wrapper is a node whose non-empty direct body contains ONLY nested
|
|
13
|
+
# class/module definitions. A module with its own defs/macros/constants is
|
|
14
|
+
# a real edit target and IS emitted; so is an empty or def-less leaf class
|
|
15
|
+
# (macro-only ActiveRecord models).
|
|
16
|
+
#
|
|
17
|
+
# Guard is errors.any?, not warnings: Prism produces a complete AST for
|
|
18
|
+
# warnings-only input (`if a = 2`), and those definitions are real.
|
|
19
|
+
module DefinedConstants
|
|
20
|
+
def self.in_source(source)
|
|
21
|
+
result = Prism.parse(source)
|
|
22
|
+
return [] if result.errors.any?
|
|
23
|
+
|
|
24
|
+
names = []
|
|
25
|
+
walk(result.value, [], names)
|
|
26
|
+
names.uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The walk intentionally descends into block bodies (unlike SubjectFinder,
|
|
30
|
+
# which skips them): over-inclusion is the safe direction for spec-file
|
|
31
|
+
# matching, so a constant defined inside a block is still emitted.
|
|
32
|
+
def self.walk(node, scope, names)
|
|
33
|
+
if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
34
|
+
scope = scope + [node.constant_path.slice]
|
|
35
|
+
names << scope.join("::") unless namespace_wrapper?(node)
|
|
36
|
+
end
|
|
37
|
+
node.compact_child_nodes.each { |child| walk(child, scope, names) }
|
|
38
|
+
end
|
|
39
|
+
private_class_method :walk
|
|
40
|
+
|
|
41
|
+
def self.namespace_wrapper?(node)
|
|
42
|
+
statements = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
|
|
43
|
+
statements.any? &&
|
|
44
|
+
statements.all? { |s| s.is_a?(Prism::ClassNode) || s.is_a?(Prism::ModuleNode) }
|
|
45
|
+
end
|
|
46
|
+
private_class_method :namespace_wrapper?
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/active_mutator/edit.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# A single mutation as a text edit: replace `range` (exclusive byte Range)
|
|
3
|
-
# in the original source with `replacement`.
|
|
4
|
-
|
|
3
|
+
# in the original source with `replacement`. `operator` is the producing
|
|
4
|
+
# operator's demodulized class name ("CallSwap"), "Unknown" outside the
|
|
5
|
+
# operator pipeline.
|
|
6
|
+
Edit = Data.define(:range, :replacement, :description, :operator) do
|
|
7
|
+
def initialize(range:, replacement:, description:, operator: "Unknown")
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
5
11
|
end
|
|
@@ -35,14 +35,27 @@ module ActiveMutator
|
|
|
35
35
|
def collect_edits(def_node)
|
|
36
36
|
edits = []
|
|
37
37
|
walk(def_node.body) do |node|
|
|
38
|
-
@operators.each
|
|
38
|
+
@operators.each do |op|
|
|
39
|
+
edits.concat(op.edits(node))
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
# Fail loud but attributed: a buggy (likely third-party) operator
|
|
42
|
+
# should point at itself, not surface as a bare crash mid-analysis.
|
|
43
|
+
raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
|
|
44
|
+
end
|
|
39
45
|
end
|
|
40
46
|
edits
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def walk(node, &blk)
|
|
44
50
|
return if node.nil?
|
|
45
|
-
|
|
51
|
+
# Descend into nested DefNodes rather than treating them as separate
|
|
52
|
+
# subjects. Giving a nested def its own subject identity is a trap:
|
|
53
|
+
# every call of the outer method re-executes the nested `def`, which
|
|
54
|
+
# would silently revert a directly-inserted mutant mid-run (phantom
|
|
55
|
+
# survivors). Instead we mutate the nested body as part of the outer
|
|
56
|
+
# def's re-evaled source. (SubjectFinder still emits no subject for
|
|
57
|
+
# nested defs.) walk is called as walk(def_node.body), so the outer
|
|
58
|
+
# DefNode itself never passes through here.
|
|
46
59
|
|
|
47
60
|
yield node
|
|
48
61
|
node.compact_child_nodes.each { |child| walk(child, &blk) }
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
# Redefines the subject's method with its mutated source. `class_eval` of a
|
|
3
3
|
# `def` handles instance methods; a `def self.x` source string defines the
|
|
4
|
-
# singleton method the same way.
|
|
4
|
+
# singleton method the same way. An sclass subject's source is a plain
|
|
5
|
+
# `def foo` that must land on the constant's singleton class, so we route it
|
|
6
|
+
# through `.singleton_class.class_eval`. Top-level subjects eval at main scope.
|
|
5
7
|
class Inserter
|
|
6
8
|
def insert(mutation)
|
|
7
9
|
subject = mutation.subject
|
|
8
10
|
if subject.constant_scope
|
|
9
|
-
Object.const_get(subject.constant_scope)
|
|
10
|
-
|
|
11
|
+
target = Object.const_get(subject.constant_scope)
|
|
12
|
+
target = target.singleton_class if subject.sclass
|
|
13
|
+
target.class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
|
|
11
14
|
else
|
|
12
15
|
eval(mutation.mutated_def_source, TOPLEVEL_BINDING, # rubocop:disable Security/Eval
|
|
13
16
|
subject.file, mutation.mutated_def_line)
|
|
@@ -18,7 +18,8 @@ module ActiveMutator
|
|
|
18
18
|
def loc_range(loc) = loc.start_offset...loc.end_offset
|
|
19
19
|
|
|
20
20
|
def edit(range, replacement, description)
|
|
21
|
-
Edit.new(range: range, replacement: replacement, description: description
|
|
21
|
+
Edit.new(range: range, replacement: replacement, description: description,
|
|
22
|
+
operator: self.class.name.split("::").last)
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|
|
@@ -9,6 +9,22 @@ module ActiveMutator
|
|
|
9
9
|
min: "max", max: "min",
|
|
10
10
|
first: "last", last: "first",
|
|
11
11
|
any?: "none?", none?: "any?",
|
|
12
|
+
# all? is one-way: any? already pairs with none?, so all?→any? adds a
|
|
13
|
+
# distinct mutant without a redundant reverse edge.
|
|
14
|
+
all?: "any?",
|
|
15
|
+
take: "drop", drop: "take",
|
|
16
|
+
min_by: "max_by", max_by: "min_by",
|
|
17
|
+
# sort→reverse is one-way by design: reverse already has a strong
|
|
18
|
+
# forward mutant here, and reverse→sort would double-map `reverse`
|
|
19
|
+
# against nothing useful (reverse has no MAP entry to preserve).
|
|
20
|
+
sort: "reverse",
|
|
21
|
+
# detect/find→first is one-way: first ignores the retained block, so
|
|
22
|
+
# the mutant usually differs (equivalent only when element 0 already
|
|
23
|
+
# satisfies the predicate). No reverse edge: `first` is taken by
|
|
24
|
+
# first→last above.
|
|
25
|
+
detect: "first", find: "first",
|
|
26
|
+
# Evaluated and rejected: sum (initial-arg arity mismatch),
|
|
27
|
+
# find_index (no safe partner — rindex is Array-only).
|
|
12
28
|
# Rails-aware pack:
|
|
13
29
|
present?: "blank?", blank?: "present?",
|
|
14
30
|
save: "save!", save!: "save"
|
|
@@ -23,8 +23,8 @@ module ActiveMutator
|
|
|
23
23
|
|
|
24
24
|
def string_edits(node)
|
|
25
25
|
opening = node.opening_loc&.slice
|
|
26
|
-
return [] unless opening
|
|
27
|
-
return
|
|
26
|
+
return [] unless opening # quote-less parts (interpolation)
|
|
27
|
+
return heredoc_edits(node) if opening.start_with?("<<")
|
|
28
28
|
|
|
29
29
|
if node.unescaped.empty?
|
|
30
30
|
[edit(loc_range(node.location), %("active_mutator"), %(replace "" with "active_mutator"))]
|
|
@@ -32,6 +32,18 @@ module ActiveMutator
|
|
|
32
32
|
[edit(loc_range(node.location), %(""), %(replace string with ""))]
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
# The node span covers the `<<~X` opening token; splicing there breaks
|
|
37
|
+
# the source. Mutate the body content range instead: nonempty body →
|
|
38
|
+
# empty heredoc (opening line directly followed by the terminator).
|
|
39
|
+
# The guard is on the DEDENTED VALUE (unescaped), not content_loc: a
|
|
40
|
+
# squiggly body that dedents to "" would only lose whitespace bytes —
|
|
41
|
+
# an equivalent mutant — so it is skipped even though content is nonempty.
|
|
42
|
+
def heredoc_edits(node)
|
|
43
|
+
return [] if node.unescaped.empty?
|
|
44
|
+
|
|
45
|
+
[edit(loc_range(node.content_loc), "", "empty heredoc body")]
|
|
46
|
+
end
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
49
|
end
|