active_mutator 0.1.1 → 0.3.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +156 -13
  3. data/lib/active_mutator/accepted_ledger.rb +22 -7
  4. data/lib/active_mutator/baseline_delta.rb +78 -1
  5. data/lib/active_mutator/class_shape.rb +47 -0
  6. data/lib/active_mutator/cli.rb +17 -4
  7. data/lib/active_mutator/closure_reload.rb +202 -0
  8. data/lib/active_mutator/config.rb +3 -1
  9. data/lib/active_mutator/config_file.rb +92 -0
  10. data/lib/active_mutator/defined_constants.rb +48 -0
  11. data/lib/active_mutator/edit.rb +8 -2
  12. data/lib/active_mutator/engine.rb +121 -7
  13. data/lib/active_mutator/inserter.rb +6 -3
  14. data/lib/active_mutator/operators/base.rb +2 -1
  15. data/lib/active_mutator/operators/call_swap.rb +16 -0
  16. data/lib/active_mutator/operators/literal.rb +14 -2
  17. data/lib/active_mutator/reporter/github.rb +36 -0
  18. data/lib/active_mutator/reporter/json.rb +1 -0
  19. data/lib/active_mutator/reporter/operator_stats.rb +20 -0
  20. data/lib/active_mutator/reporter/stryker_json.rb +128 -0
  21. data/lib/active_mutator/reporter/terminal.rb +24 -1
  22. data/lib/active_mutator/result.rb +1 -1
  23. data/lib/active_mutator/runner.rb +239 -19
  24. data/lib/active_mutator/scheduler.rb +41 -5
  25. data/lib/active_mutator/source_location.rb +21 -0
  26. data/lib/active_mutator/subject.rb +13 -3
  27. data/lib/active_mutator/subject_finder.rb +89 -9
  28. data/lib/active_mutator/subject_matcher.rb +23 -0
  29. data/lib/active_mutator/timeout_calibrator.rb +75 -0
  30. data/lib/active_mutator/version.rb +1 -1
  31. data/lib/active_mutator/work_item.rb +8 -1
  32. data/lib/active_mutator/worker.rb +53 -8
  33. data/lib/active_mutator.rb +10 -0
  34. metadata +20 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfe7e211472b45836efb059eb37472307becf74e85b8441664e8cec836c10c26
4
- data.tar.gz: 6e0ed299bc84b8c912e73314f58ea88cc2fbb783f6949aea14285a27ea2e5bd9
3
+ metadata.gz: '08222e2342f45fd4c9150cd9201383231f1add08c5c5582bccf74f4350c87181'
4
+ data.tar.gz: ae88865c407500e7b7fcc0cb9a0ab72fe90aa2860136f9bd523203a1baae9c35
5
5
  SHA512:
6
- metadata.gz: 6153450b921e1647a13d14f4e473cc123c045ff738f2cea1b6786797e265b05bc7bc00233bdf66f192296be1c372f0c458f6910992d01716320033b86a0a00e9
7
- data.tar.gz: b1d9c1133f21dbe6370651c2d00e7e4173620ac13986ce24e8b91931f5f5793985d41cf2f8a7be626b73c948b6a473dec99f6e3560f52630cdad83c8ab3d5d72
6
+ metadata.gz: c0ed008b82b47bf0e70e74bd51330eafaeb02f6c7241e5a19e2a75395293db1ccf02e3f13f3810abf2da1972f5b50b678e35bae4efb946b139aac22e0faccfd0
7
+ data.tar.gz: dd02830ae6b56cdc687cf90dc5131d1409298ac8e5d03217a57f92a044aca4dee6731043498e1f021d9eee4ef064af8cab80fb048263046dfe28fe719bef0077
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,27 @@ 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
+ | `--[no-]class-level` | on | mutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects |
267
+ | `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
268
+
269
+ `--debug-plan` prints the planned mutant list as one JSON document
270
+ (`{"planned": [...], "pre_resolved": {...}}`) and exits without running
271
+ anything. A coverage baseline is still built or loaded, since timeouts
272
+ and covering examples come from it.
212
273
 
213
274
  Every active_mutator process sets `ENV["ACTIVE_MUTATOR"] = "1"`. Use it to
214
275
  guard SimpleCov or other tooling in your spec helper:
@@ -217,12 +278,92 @@ guard SimpleCov or other tooling in your spec helper:
217
278
  SimpleCov.start "rails" unless ENV["ACTIVE_MUTATOR"]
218
279
  ```
219
280
 
220
- ## Known limits (v1.1)
281
+ ## Configuration file
282
+
283
+ Put team-wide settings in `.active_mutator.yml` at the project root; CLI
284
+ flags override file values (`--require` and `--exclude` add to the file's
285
+ lists; the first `--serial-pattern` replaces them). Recognized keys:
286
+ `jobs`, `format`, `timeout_factor`, `timeout_floor`,
287
+ `browser_boot_seconds`, `fail_at`, `exclude`, `serial_patterns`,
288
+ `requires`, `operators` (custom operator files, loaded before analysis; see
289
+ [Custom operators](docs/guides/custom-operators.md)),
290
+ `preload_helper` (a path, or `false` to skip preload),
291
+ `adaptive_timeout` (`true`/`false`),
292
+ `class_level` (`true`/`false`, default `true` — mutate class-level code),
293
+ `class_level_closure_cap` (integer, default `10` — max constants a
294
+ class-body mutant may reload before it is `skipped`).
295
+ Unknown keys and wrong types are errors, not silent no-ops.
296
+
297
+ ```yaml
298
+ # .active_mutator.yml
299
+ jobs: 4
300
+ exclude:
301
+ - lib/generated
302
+ serial_patterns:
303
+ - spec/system/
304
+ fail_at: 90 # legacy suite: gate on score instead of zero-survivors
305
+ ```
221
306
 
222
- 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).
307
+ ## Class-level mutation
308
+
309
+ Class-level code macros (`validates`, `scope`, `has_many`), constants,
310
+ and DSL/scope lambdas IS mutated. Each Zeitwerk-shaped file gets a
311
+ `… (class body)` subject alongside its method subjects, and the same
312
+ operator set runs over its class-level statements. Because re-running a
313
+ macro *accumulates* rather than replaces (calling `validates` twice adds a
314
+ second validator), a class-body mutant can't be inserted with `class_eval`
315
+ the way a `def` mutant is. Instead active_mutator removes the target
316
+ constant and re-evaluates the whole mutated file, reloading anything
317
+ attached to it (includers, subclasses, extenders) in dependency order. See
318
+ [`docs/guides/how-it-works.md`](docs/guides/how-it-works.md) for the full
319
+ closure-reload pipeline.
320
+
321
+ Disable it with `--no-class-level` (or `class_level: false` in the config
322
+ file). A class-body mutant whose closure can't be reloaded faithfully — the
323
+ closure exceeds `class_level_closure_cap` (default `10`), the constant was
324
+ reopened elsewhere, or an attacher is anonymous/native — is reported
325
+ `skipped` (progress char `-`): listed but **not counted in the score**,
326
+ because a mutant we can't insert faithfully must not be called survived or
327
+ killed.
328
+
329
+ ## Known limits
330
+
331
+ Method bodies **and** Zeitwerk-shaped class bodies are mutated; the
332
+ remaining limits are:
333
+
334
+ - **Class-body mutation requires a Zeitwerk-shaped file** — exactly one
335
+ top-level class/module per file. Multi-constant files and core-class
336
+ monkey-patches/reopens are not class-body-mutated (issue #32). Their
337
+ method bodies still are.
338
+ - **Most code inside blocks is not mutated.** `ActiveSupport::Concern` DSL
339
+ blocks (`included`/`prepended`/`class_methods do … end`) ARE mutated — their
340
+ bodies re-run as class-level code in the includer (issue #31). Every other
341
+ block (`has_many :x do … end` and any `do … end`/`{ … }` body) is pruned to
342
+ avoid false survivors from mutating code whose run-time context is unknown.
343
+ - **Constants captured by value go stale.** A reference that holds the
344
+ target *by value* rather than by ancestry — an alias (`ALIAS = SomeClass`),
345
+ a registry the class was pushed into, a memoized instance, a class
346
+ variable captured at load — keeps pointing at the pre-reload object after
347
+ the closure reload. Such stale references can produce false survivors.
348
+ - **Whole-file re-eval re-runs class-body side effects.** The reload
349
+ re-evaluates the target and every attacher's class body, so non-idempotent
350
+ load-time side effects (global self-registration, descendant tracking) run
351
+ twice — which can double or mask a count a spec asserts on.
352
+ - **`refine`-based modules are not discovered or reloaded.** Refinements
353
+ are anonymous and don't appear in normal `ancestors`.
354
+ - **RSpec only.** Test selection, worker setup, and the world-group filter
355
+ are all RSpec-API-shaped.
356
+ - **Method-body scope details:** plain heredoc bodies ARE mutated (emptied);
357
+ interpolated heredocs are skipped. `class << self` bodies are mutated as
358
+ singleton subjects (`class << obj` and top-level `class << self` are
359
+ skipped). Nested defs mutate as part of the enclosing method's body — they
360
+ get no subject of their own (a directly-inserted mutant would be reverted
361
+ whenever the outer method re-runs the `def`).
362
+ - **The incremental baseline's residual blind spot:** constant-reference
363
+ detection handles the common case since 0.2; a few residual cases (pure
364
+ indirection, partially-covering files, leaf-only or wrapper-only
365
+ references, `class ::Foo`, `Data.define`/`Struct.new` value objects) are
366
+ caught by nightly `--force-baseline`.
226
367
 
227
368
  ## Guides
228
369
 
@@ -234,6 +375,8 @@ changed code after the change (nightly `--force-baseline` recovers).
234
375
  - [Operator reference](docs/guides/operators.md): every mutation
235
376
  active_mutator can generate, with before/after examples and what a
236
377
  survivor of each one means.
378
+ - [Custom operators](docs/guides/custom-operators.md): write and load your
379
+ own mutation operators with `--operator` / the `operators:` config key.
237
380
  - [Mutation-check skill](docs/skills/mutation-check.md): the agent-facing
238
381
  workflow. Run, read survivors, strengthen tests, or accept with a reason.
239
382
 
@@ -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,61 @@ 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
+ pattern = constant_reference_pattern(File.read(abs))
88
+ return [] unless pattern
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
+ candidates = all_specs.filter_map do |spec_abs|
95
+ spec_rel = spec_abs.delete_prefix(root).delete_prefix("/")
96
+ next if covering_specs.include?(spec_rel)
97
+
98
+ spec_rel if spec_contents.fetch(spec_abs).match?(pattern)
99
+ end
100
+ if candidates.size > 1 && candidates.size > all_specs.size * REFERENCE_FULL_RATIO
101
+ # Never silently degrade: a full baseline where the user expected an
102
+ # incremental refresh must be explained, or it looks like a hang.
103
+ warn "active_mutator: constant-reference scan matched #{candidates.size} of " \
104
+ "#{all_specs.size} spec files for #{rel}; falling back to full baseline"
105
+ return :full
106
+ end
107
+
108
+ candidates
109
+ end
110
+
111
+ def self.spec_file_of(example_id)
112
+ example_id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "")
113
+ end
114
+
115
+ # Regexp matching any textual reference to a constant DEFINED in `source`,
116
+ # or nil when the source defines none. Shared by newly_covering_candidates
117
+ # and Runner's phase-2 escalation so the escaping/word-boundary rules live
118
+ # in one place.
119
+ #
120
+ # Escaping is required: dynamic-namespace class definitions (e.g.
121
+ # `class (a)::Baz`, `class foo.bar::Baz`) make constant_path.slice carry
122
+ # regex metachars. Unescaped, "(a)::Baz" would match the literal text
123
+ # "a::Baz" — a false candidate.
124
+ # TODO(#11, Task 10 residual gap): a top-level `class ::Foo` yields the
125
+ # slice "::Foo", and /\b::Foo\b/ can never match (no word boundary before
126
+ # ":"), so such files are silently unscanned.
127
+ def self.constant_reference_pattern(source)
128
+ constants = DefinedConstants.in_source(source)
129
+ return nil if constants.empty?
130
+
131
+ /\b(?:#{constants.map { |c| Regexp.escape(c) }.join("|")})\b/
132
+ end
56
133
  end
57
134
  end
@@ -0,0 +1,47 @@
1
+ module ActiveMutator
2
+ # Structural predicates over a parsed file that the class-level machinery
3
+ # must apply IDENTICALLY in more than one place. Centralized so the gates
4
+ # cannot drift:
5
+ # - SubjectFinder decides which files get a class-body subject.
6
+ # - ClosureReload decides which dependent files are safe to remove_const
7
+ # and re-eval.
8
+ # Both need the same "is this file a single reloadable constant?" rule, and
9
+ # both need the same "does this class-body statement belong to another
10
+ # subject?" rule.
11
+ # `extend self`, not `module_function`: module_function copies each method
12
+ # onto the singleton at definition time, so a def-level mutant (which
13
+ # redefines the INSTANCE method in the fork) would never reach the singleton
14
+ # copy that callers invoke — an untestable false survivor. `extend self`
15
+ # keeps ONE method object, dispatched to via the singleton's ancestry.
16
+ module ClassShape
17
+ extend self
18
+
19
+ # Zeitwerk-shaped: the file defines exactly one top-level constant, so
20
+ # remove_const + whole-file re-eval reinstates precisely that constant
21
+ # (issue #32). A file with more than one would re-run macros on / reassign
22
+ # the constants that were NOT removed (accumulation and "already
23
+ # initialized constant" bugs).
24
+ #
25
+ # Counts every top-level constant-DEFINING form, not just `class`/`module`
26
+ # blocks: `Adapter = Class.new`, `Point = Struct.new(...)`,
27
+ # `Config = Data.define(...)`, and plain `CONST = ...` are ConstantWriteNodes
28
+ # (or ConstantPathWriteNodes) that a class/module-only count missed, letting
29
+ # a two-constant file slip through and get reassigned on re-eval.
30
+ def single_top_level_constant?(program)
31
+ program.statements.body.count { |s| defines_constant?(s) } == 1
32
+ end
33
+
34
+ def defines_constant?(node)
35
+ node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
36
+ node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
37
+ end
38
+
39
+ # A class-body statement that is owned by a DIFFERENT subject — its own
40
+ # def, or a nested class/module/singleton-class that gets its own subjects.
41
+ # The class-body walk must neither collect edits for it nor delete it.
42
+ def owned_by_other_subject?(node)
43
+ node.is_a?(Prism::DefNode) || node.is_a?(Prism::ClassNode) ||
44
+ node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::SingletonClassNode)
45
+ end
46
+ end
47
+ end
@@ -22,16 +22,21 @@ 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
+ operators: [], class_level: true, class_level_closure_cap: 10
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[:operators] << v }
39
+ o.on("--[no-]class-level", "Mutate class-level code: macros, constants, DSL lambdas (default: on)") { |v| options[:class_level] = v }
35
40
  o.on("--force-baseline", "Ignore cached coverage map") { options[:force_baseline] = true }
36
41
  o.on("--timeout-factor F", Float, "Timeout = baseline time * F + floor") { |v| options[:timeout_factor] = v }
37
42
  o.on("--timeout-floor S", Float, "Minimum timeout seconds") { |v| options[:timeout_floor] = v }
@@ -43,7 +48,15 @@ module ActiveMutator
43
48
  options[:serial_patterns] << v
44
49
  end
45
50
  o.on("--browser-boot-seconds S", Float, "Extra timeout budget for serial-lane mutants") { |v| options[:browser_boot_seconds] = v }
51
+ o.on("--[no-]adaptive-timeout", "Scale timeout budgets from observed worker wall times (default: on)") { |v| options[:adaptive_timeout] = v }
46
52
  o.on("--accept-survivors", "Record surviving mutants into the acceptance ledger") { options[:accept_survivors] = true }
53
+ o.on("--exclude PAT", "Skip files matching glob, relative to root (repeatable)") { |v| options[:exclude] << v }
54
+ o.on("--max-mutants N", Integer, "Deterministically sample the first N mutants") { |v| options[:max_mutants] = v }
55
+ o.on("--debug-plan", "Print the planned mutant list as JSON and exit") { options[:debug_plan] = true }
56
+ o.on("--fail-at SCORE", Float, "Exit 0 if mutation score >= SCORE even with survivors (default: any survivor fails)") do |v|
57
+ raise OptionParser::InvalidArgument, "--fail-at must be within 0..100" unless (0..100).cover?(v)
58
+ options[:fail_at] = v
59
+ end
47
60
  end.parse(argv)
48
61
  options.delete(:serial_patterns_replaced)
49
62