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.
- checksums.yaml +4 -4
- data/README.md +156 -13
- data/lib/active_mutator/accepted_ledger.rb +22 -7
- data/lib/active_mutator/baseline_delta.rb +78 -1
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +17 -4
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +3 -1
- data/lib/active_mutator/config_file.rb +92 -0
- data/lib/active_mutator/defined_constants.rb +48 -0
- data/lib/active_mutator/edit.rb +8 -2
- data/lib/active_mutator/engine.rb +121 -7
- 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 +128 -0
- data/lib/active_mutator/reporter/terminal.rb +24 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +239 -19
- data/lib/active_mutator/scheduler.rb +41 -5
- data/lib/active_mutator/source_location.rb +21 -0
- data/lib/active_mutator/subject.rb +13 -3
- data/lib/active_mutator/subject_finder.rb +89 -9
- 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 +53 -8
- data/lib/active_mutator.rb +10 -0
- metadata +20 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08222e2342f45fd4c9150cd9201383231f1add08c5c5582bccf74f4350c87181'
|
|
4
|
+
data.tar.gz: ae88865c407500e7b7fcc0cb9a0ab72fe90aa2860136f9bd523203a1baae9c35
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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,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
|
|
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
|
+
| `--[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
|
-
##
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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,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
|
data/lib/active_mutator/cli.rb
CHANGED
|
@@ -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
|
|
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[: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
|
|