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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfe7e211472b45836efb059eb37472307becf74e85b8441664e8cec836c10c26
4
- data.tar.gz: 6e0ed299bc84b8c912e73314f58ea88cc2fbb783f6949aea14285a27ea2e5bd9
3
+ metadata.gz: 5729b9385c24e575708a5227a3838ae28e09dd2ddf3e40e1883e9e74f7399275
4
+ data.tar.gz: 69b41b3304c8c008f54f6c20547ff9283d26a278eea706dc7cf923466dde1b5c
5
5
  SHA512:
6
- metadata.gz: 6153450b921e1647a13d14f4e473cc123c045ff738f2cea1b6786797e265b05bc7bc00233bdf66f192296be1c372f0c458f6910992d01716320033b86a0a00e9
7
- data.tar.gz: b1d9c1133f21dbe6370651c2d00e7e4173620ac13986ce24e8b91931f5f5793985d41cf2f8a7be626b73c948b6a473dec99f6e3560f52630cdad83c8ab3d5d72
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, `0` otherwise, including when there are
132
- only `uncovered`, `accepted`, or `error` results.
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
- incremental baseline's newly-covering-example blind spot)
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 NAME` | none | one subject, e.g. `Foo#bar` |
204
- | `--format terminal\|json` | terminal | report format |
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
- Heredoc strings are not mutated. `class << self` bodies and nested defs
224
- are skipped. The incremental baseline can miss examples that only cover
225
- changed code after the change (nightly `--force-baseline` recovers).
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
- def stale_entries(all_current_fingerprints)
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
- @entries.reject { |e| current.include?(e) }
43
+ scanned = scanned_files.to_set
44
+ @entries.reject { |e| current.include?(e) || !scanned.include?(e.file) }
34
45
  end
35
46
 
36
- # Union new acceptances in, prune anything no longer matching a current
37
- # mutant, write atomically.
38
- def accept!(new_fingerprints, all_current_fingerprints)
39
- current = all_current_fingerprints.to_set
40
- @entries = (@entries + new_fingerprints).uniq.select { |e| current.include?(e) }
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
- rerun_example_ids.concat(coverage_map.examples_covering_file(abs)) unless added
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
@@ -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 only the named subject, e.g. Foo::Bar#baz") { |v| options[:subject_filter] = v }
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", %w[terminal json], "Output format") { |v| options[:format] = v.to_sym }
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
@@ -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
- Edit = Data.define(:range, :replacement, :description)
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 { |op| edits.concat(op.edits(node)) }
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
- return if node.is_a?(Prism::DefNode) # nested defs are separate subjects
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. Top-level subjects eval at main scope.
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
- .class_eval(mutation.mutated_def_source, subject.file, mutation.mutated_def_line)
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 # quote-less parts (interpolation)
27
- return [] if opening.start_with?("<<") # heredocs: v1 limit
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