active_mutator 0.2.0 → 0.4.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 +79 -14
- data/lib/active_mutator/baseline.rb +44 -6
- data/lib/active_mutator/baseline_delta.rb +36 -16
- data/lib/active_mutator/baseline_hooks.rb +15 -6
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +22 -2
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +4 -2
- data/lib/active_mutator/config_file.rb +15 -5
- data/lib/active_mutator/engine.rb +106 -5
- data/lib/active_mutator/reporter/stryker_json.rb +13 -2
- data/lib/active_mutator/reporter/terminal.rb +13 -1
- data/lib/active_mutator/result.rb +1 -1
- data/lib/active_mutator/runner.rb +136 -14
- data/lib/active_mutator/subject.rb +6 -2
- data/lib/active_mutator/subject_finder.rb +45 -4
- data/lib/active_mutator/version.rb +1 -1
- data/lib/active_mutator/worker.rb +50 -8
- data/lib/active_mutator.rb +2 -0
- metadata +12 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c97d4e4b28fb22f07d54f258e65162b4a4e9c731ab89caa851610e1ff3d1a83
|
|
4
|
+
data.tar.gz: dfd2fdddc29f209281e45001ae7a21bd6d75fce6833a5f4086f3592d36378843
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 983e510da62ef0a31abefd1342485e17a365013ce13eb16b9ad3a1b929ca5ff13c643c15ae9529c6b0def30215268b1a2c424d102489f4351d27b2c9cddd2ca1
|
|
7
|
+
data.tar.gz: 272643ac14e46cfba0e0ea95d4b9fe4cedc7ace3b7359eaabdf7bed60a8caf2451f84ba3986828f9528dfe402e9fe1fe4d9b20d7daa63f842ec728ebfb4ef2af
|
data/README.md
CHANGED
|
@@ -258,13 +258,23 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
|
258
258
|
| `--force-baseline` | off | ignore cached coverage map |
|
|
259
259
|
| `--preload-helper FILE` / `--no-preload-helper` | auto-detect | parent spec-helper preload |
|
|
260
260
|
| `--serial-pattern PAT` | `spec/system/`, `spec/features/` | covering-path prefixes forced serial |
|
|
261
|
+
| `--spec-path DIR` | `spec/` | where spec files live, relative to the project root (repeatable; the first use replaces the default `spec/`), e.g. `--spec-path engines/billing/spec --spec-path spec` |
|
|
261
262
|
| `--browser-boot-seconds S` | 15 | serial-lane timeout bump |
|
|
262
263
|
| `--timeout-factor F` / `--timeout-floor S` | 8 / 10 | mutation timeout budget |
|
|
263
264
|
| `--[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) |
|
|
264
265
|
| `--require FILE` | none | preload files (repeatable) |
|
|
265
266
|
| `--operator FILE` | none | load a custom operator file before analysis (repeatable) |
|
|
267
|
+
| `--[no-]class-level` | on | mutate class-level code (macros, constants, DSL/scope lambdas) via class-body subjects |
|
|
266
268
|
| `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
|
|
267
269
|
|
|
270
|
+
`--spec-path` tells active_mutator where spec files live (coverage
|
|
271
|
+
classification, digests, escalation); RSpec's own discovery is still the
|
|
272
|
+
project's job — a project with specs under `test/` also needs
|
|
273
|
+
`--default-path test` in its `.rspec`. The serial-lane defaults stay
|
|
274
|
+
`spec/system/` and `spec/features/` regardless of `--spec-path`; a
|
|
275
|
+
custom spec root with browser specs should set `--serial-pattern`
|
|
276
|
+
(e.g. `--serial-pattern test/system/`) itself.
|
|
277
|
+
|
|
268
278
|
`--debug-plan` prints the planned mutant list as one JSON document
|
|
269
279
|
(`{"planned": [...], "pre_resolved": {...}}`) and exits without running
|
|
270
280
|
anything. A coverage baseline is still built or loaded, since timeouts
|
|
@@ -284,10 +294,15 @@ flags override file values (`--require` and `--exclude` add to the file's
|
|
|
284
294
|
lists; the first `--serial-pattern` replaces them). Recognized keys:
|
|
285
295
|
`jobs`, `format`, `timeout_factor`, `timeout_floor`,
|
|
286
296
|
`browser_boot_seconds`, `fail_at`, `exclude`, `serial_patterns`,
|
|
297
|
+
`spec_paths` (where spec files live, relative to the project root;
|
|
298
|
+
replaces the default `spec`),
|
|
287
299
|
`requires`, `operators` (custom operator files, loaded before analysis; see
|
|
288
300
|
[Custom operators](docs/guides/custom-operators.md)),
|
|
289
301
|
`preload_helper` (a path, or `false` to skip preload),
|
|
290
|
-
`adaptive_timeout` (`true`/`false`)
|
|
302
|
+
`adaptive_timeout` (`true`/`false`),
|
|
303
|
+
`class_level` (`true`/`false`, default `true` — mutate class-level code),
|
|
304
|
+
`class_level_closure_cap` (integer, default `10` — max constants a
|
|
305
|
+
class-body mutant may reload before it is `skipped`).
|
|
291
306
|
Unknown keys and wrong types are errors, not silent no-ops.
|
|
292
307
|
|
|
293
308
|
```yaml
|
|
@@ -297,22 +312,72 @@ exclude:
|
|
|
297
312
|
- lib/generated
|
|
298
313
|
serial_patterns:
|
|
299
314
|
- spec/system/
|
|
315
|
+
spec_paths:
|
|
316
|
+
- engines/billing/spec
|
|
317
|
+
- spec
|
|
300
318
|
fail_at: 90 # legacy suite: gate on score instead of zero-survivors
|
|
301
319
|
```
|
|
302
320
|
|
|
303
|
-
##
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
constant
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
## Class-level mutation
|
|
322
|
+
|
|
323
|
+
Class-level code — macros (`validates`, `scope`, `has_many`), constants,
|
|
324
|
+
and DSL/scope lambdas — IS mutated. Each Zeitwerk-shaped file gets a
|
|
325
|
+
`… (class body)` subject alongside its method subjects, and the same
|
|
326
|
+
operator set runs over its class-level statements. Because re-running a
|
|
327
|
+
macro *accumulates* rather than replaces (calling `validates` twice adds a
|
|
328
|
+
second validator), a class-body mutant can't be inserted with `class_eval`
|
|
329
|
+
the way a `def` mutant is. Instead active_mutator removes the target
|
|
330
|
+
constant and re-evaluates the whole mutated file, reloading anything
|
|
331
|
+
attached to it (includers, subclasses, extenders) in dependency order. See
|
|
332
|
+
[`docs/guides/how-it-works.md`](docs/guides/how-it-works.md) for the full
|
|
333
|
+
closure-reload pipeline.
|
|
334
|
+
|
|
335
|
+
Disable it with `--no-class-level` (or `class_level: false` in the config
|
|
336
|
+
file). A class-body mutant whose closure can't be reloaded faithfully — the
|
|
337
|
+
closure exceeds `class_level_closure_cap` (default `10`), the constant was
|
|
338
|
+
reopened elsewhere, or an attacher is anonymous/native — is reported
|
|
339
|
+
`skipped` (progress char `-`): listed but **not counted in the score**,
|
|
340
|
+
because a mutant we can't insert faithfully must not be called survived or
|
|
341
|
+
killed.
|
|
342
|
+
|
|
343
|
+
## Known limits
|
|
344
|
+
|
|
345
|
+
Method bodies **and** Zeitwerk-shaped class bodies are mutated; the
|
|
346
|
+
remaining limits are:
|
|
347
|
+
|
|
348
|
+
- **Class-body mutation requires a Zeitwerk-shaped file** — exactly one
|
|
349
|
+
top-level class/module per file. Multi-constant files and core-class
|
|
350
|
+
monkey-patches/reopens are not class-body-mutated (issue #32). Their
|
|
351
|
+
method bodies still are.
|
|
352
|
+
- **Most code inside blocks is not mutated.** `ActiveSupport::Concern` DSL
|
|
353
|
+
blocks (`included`/`prepended`/`class_methods do … end`) ARE mutated — their
|
|
354
|
+
bodies re-run as class-level code in the includer (issue #31). Every other
|
|
355
|
+
block (`has_many :x do … end` and any `do … end`/`{ … }` body) is pruned to
|
|
356
|
+
avoid false survivors from mutating code whose run-time context is unknown.
|
|
357
|
+
- **Constants captured by value go stale.** A reference that holds the
|
|
358
|
+
target *by value* rather than by ancestry — an alias (`ALIAS = SomeClass`),
|
|
359
|
+
a registry the class was pushed into, a memoized instance, a class
|
|
360
|
+
variable captured at load — keeps pointing at the pre-reload object after
|
|
361
|
+
the closure reload. Such stale references can produce false survivors.
|
|
362
|
+
- **Whole-file re-eval re-runs class-body side effects.** The reload
|
|
363
|
+
re-evaluates the target and every attacher's class body, so non-idempotent
|
|
364
|
+
load-time side effects (global self-registration, descendant tracking) run
|
|
365
|
+
twice — which can double or mask a count a spec asserts on.
|
|
366
|
+
- **`refine`-based modules are not discovered or reloaded.** Refinements
|
|
367
|
+
are anonymous and don't appear in normal `ancestors`.
|
|
368
|
+
- **RSpec only.** Test selection, worker setup, and the world-group filter
|
|
369
|
+
are all RSpec-API-shaped.
|
|
370
|
+
- **Method-body scope details:** plain heredoc bodies ARE mutated (emptied);
|
|
371
|
+
interpolated heredocs are skipped. `class << self` bodies are mutated as
|
|
372
|
+
singleton subjects (`class << obj` and top-level `class << self` are
|
|
373
|
+
skipped). Nested defs mutate as part of the enclosing method's body — they
|
|
374
|
+
get no subject of their own (a directly-inserted mutant would be reverted
|
|
375
|
+
whenever the outer method re-runs the `def`).
|
|
376
|
+
- **The incremental baseline's residual blind spot:** constant-reference
|
|
377
|
+
detection handles the common case since 0.2; a few residual cases (pure
|
|
378
|
+
indirection, partially-covering files, leaf-only or wrapper-only
|
|
379
|
+
references, `class ::Foo`, `Data.define`/`Struct.new` value objects) are
|
|
380
|
+
caught by nightly `--force-baseline`.
|
|
316
381
|
|
|
317
382
|
## Guides
|
|
318
383
|
|
|
@@ -5,10 +5,11 @@ require "json"
|
|
|
5
5
|
module ActiveMutator
|
|
6
6
|
# Runs the host suite once, instrumented, in a subprocess. Produces and
|
|
7
7
|
# caches the CoverageMap. Invalidation is coarse: any digest change in
|
|
8
|
-
# {app,lib
|
|
8
|
+
# {app,lib}/**/*.rb or the configured spec paths triggers a full re-run.
|
|
9
9
|
class Baseline
|
|
10
|
-
def initialize(root:, cache_dir: File.join(root, ".active_mutator"))
|
|
10
|
+
def initialize(root:, spec_paths: ["spec"], cache_dir: File.join(root, ".active_mutator"))
|
|
11
11
|
@root = root
|
|
12
|
+
@spec_paths = spec_paths
|
|
12
13
|
@cache_dir = cache_dir
|
|
13
14
|
@out_path = File.join(cache_dir, "coverage.json")
|
|
14
15
|
end
|
|
@@ -19,13 +20,19 @@ module ActiveMutator
|
|
|
19
20
|
digests = current_digests
|
|
20
21
|
if !force && File.exist?(@out_path)
|
|
21
22
|
map = CoverageMap.load(@out_path)
|
|
22
|
-
|
|
23
|
+
# A spec_paths change silently degrades the delta classifier: files
|
|
24
|
+
# under a removed spec path just vanish from the digest scan, so
|
|
25
|
+
# BaselineDelta treats their stale example records as untouched
|
|
26
|
+
# source coverage instead of dropping them. Force a full rebuild
|
|
27
|
+
# whenever the configured spec_paths differ from what the cache was
|
|
28
|
+
# stamped with.
|
|
29
|
+
if stored_spec_paths(map) == @spec_paths && map.fresh?(digests)
|
|
23
30
|
@last_refresh = :cached
|
|
24
31
|
return map
|
|
25
32
|
end
|
|
26
|
-
if map.version == 2
|
|
33
|
+
if stored_spec_paths(map) == @spec_paths && map.version == 2
|
|
27
34
|
delta = BaselineDelta.compute(old_digests: stored_digests(map), new_digests: digests,
|
|
28
|
-
coverage_map: map, root: @root)
|
|
35
|
+
coverage_map: map, root: @root, spec_paths: @spec_paths)
|
|
29
36
|
unless delta.full?
|
|
30
37
|
run_partial!(delta)
|
|
31
38
|
stamp_digests(digests)
|
|
@@ -59,6 +66,25 @@ module ActiveMutator
|
|
|
59
66
|
ok = system(env, "bundle", "exec", "rspec", chdir: @root, out: :err)
|
|
60
67
|
raise BaselineFailed, "baseline suite failed, fix the suite before mutating" unless ok
|
|
61
68
|
raise BaselineFailed, "baseline produced no coverage output" unless File.exist?(@out_path)
|
|
69
|
+
|
|
70
|
+
verify_complete!(@out_path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An aborted subprocess can still exit 0 with a partial map (RSpec
|
|
74
|
+
# rescues Errno::EPIPE and runs after(:suite)); stamping that as fresh
|
|
75
|
+
# silently reports every mutant uncovered. Payloads without the count
|
|
76
|
+
# predate this check and are accepted as-is.
|
|
77
|
+
def verify_complete!(out_path)
|
|
78
|
+
payload = JSON.parse(File.read(out_path))
|
|
79
|
+
expected = payload["expected_examples"]
|
|
80
|
+
return unless expected
|
|
81
|
+
|
|
82
|
+
recorded = payload.fetch("records", {}).size
|
|
83
|
+
return if recorded >= expected
|
|
84
|
+
|
|
85
|
+
raise BaselineFailed,
|
|
86
|
+
"baseline aborted early: #{recorded} of #{expected} examples recorded — " \
|
|
87
|
+
"re-run without interrupting the suite"
|
|
62
88
|
end
|
|
63
89
|
|
|
64
90
|
def baseline_env(out_path)
|
|
@@ -66,6 +92,7 @@ module ActiveMutator
|
|
|
66
92
|
"ACTIVE_MUTATOR" => "1",
|
|
67
93
|
"ACTIVE_MUTATOR_ROOT" => @root,
|
|
68
94
|
"ACTIVE_MUTATOR_BASELINE_OUT" => out_path,
|
|
95
|
+
"ACTIVE_MUTATOR_SPEC_PATHS" => @spec_paths.join(":"),
|
|
69
96
|
# RUBYOPT, not `rspec --require`: project .rspec requires (spec_helper
|
|
70
97
|
# → app code) run before command-line requires, and Coverage misses
|
|
71
98
|
# everything loaded before Coverage.start. -r fires before rspec boots.
|
|
@@ -83,6 +110,12 @@ module ActiveMutator
|
|
|
83
110
|
JSON.parse(File.read(@out_path)).fetch("digests", {})
|
|
84
111
|
end
|
|
85
112
|
|
|
113
|
+
# A pre-0.4.0 cache predates spec_paths and has no key; treat that as the
|
|
114
|
+
# old implicit default so existing default-config caches stay valid.
|
|
115
|
+
def stored_spec_paths(map)
|
|
116
|
+
JSON.parse(File.read(@out_path)).fetch("spec_paths", ["spec"])
|
|
117
|
+
end
|
|
118
|
+
|
|
86
119
|
def run_partial!(delta)
|
|
87
120
|
targets = delta.rerun_spec_files + delta.rerun_example_ids
|
|
88
121
|
partial_out = File.join(@cache_dir, "partial.json")
|
|
@@ -91,6 +124,8 @@ module ActiveMutator
|
|
|
91
124
|
ok = system(env, "bundle", "exec", "rspec", *targets, chdir: @root, out: :err)
|
|
92
125
|
raise BaselineFailed, "partial baseline run failed, fix the suite before mutating" unless ok
|
|
93
126
|
raise BaselineFailed, "partial baseline produced no output" unless File.exist?(partial_out)
|
|
127
|
+
|
|
128
|
+
verify_complete!(partial_out)
|
|
94
129
|
end
|
|
95
130
|
merge_partial!(partial_out, delta)
|
|
96
131
|
ensure
|
|
@@ -122,11 +157,14 @@ module ActiveMutator
|
|
|
122
157
|
def stamp_digests(digests)
|
|
123
158
|
data = JSON.parse(File.read(@out_path))
|
|
124
159
|
data["digests"] = digests
|
|
160
|
+
data["spec_paths"] = @spec_paths
|
|
125
161
|
AtomicFile.write(@out_path, JSON.generate(data))
|
|
126
162
|
end
|
|
127
163
|
|
|
128
164
|
def current_digests
|
|
129
|
-
files = Dir[File.join(@root, "{app,lib
|
|
165
|
+
files = Dir[File.join(@root, "{app,lib}/**/*.rb")]
|
|
166
|
+
files += @spec_paths.flat_map { |sp| Dir[File.join(@root, sp, "**", "*.rb")] }
|
|
167
|
+
files = files.uniq.sort
|
|
130
168
|
files += [File.join(@root, "Gemfile.lock"), File.join(@root, ".rspec")].select { |f| File.exist?(f) }
|
|
131
169
|
files.to_h { |f| [f.delete_prefix("#{@root}/"), Digest::SHA256.file(f).hexdigest] }
|
|
132
170
|
end
|
|
@@ -15,10 +15,10 @@ module ActiveMutator
|
|
|
15
15
|
# files, a full re-run is cheaper and simpler than a giant partial one.
|
|
16
16
|
REFERENCE_FULL_RATIO = 0.5
|
|
17
17
|
|
|
18
|
-
def self.compute(old_digests:, new_digests:, coverage_map:, root:)
|
|
18
|
+
def self.compute(old_digests:, new_digests:, coverage_map:, root:, spec_paths: ["spec"])
|
|
19
19
|
changed = (old_digests.keys | new_digests.keys)
|
|
20
20
|
.reject { |k| old_digests[k] == new_digests[k] }
|
|
21
|
-
return FULL if changed.any? { |k| full_trigger?(k) }
|
|
21
|
+
return FULL if changed.any? { |k| full_trigger?(k, spec_paths: spec_paths) }
|
|
22
22
|
|
|
23
23
|
rerun_spec_files = []
|
|
24
24
|
rerun_example_ids = []
|
|
@@ -35,7 +35,7 @@ module ActiveMutator
|
|
|
35
35
|
changed.each do |rel|
|
|
36
36
|
added = !old_digests.key?(rel)
|
|
37
37
|
deleted = !new_digests.key?(rel)
|
|
38
|
-
if rel.start_with?("
|
|
38
|
+
if spec_paths.any? { |sp| rel.start_with?("#{sp}/") }
|
|
39
39
|
owned = coverage_map.examples_for_spec_file(rel)
|
|
40
40
|
if deleted
|
|
41
41
|
# A deleted spec file with no records is support-like: other spec
|
|
@@ -54,7 +54,7 @@ module ActiveMutator
|
|
|
54
54
|
rerun_example_ids.concat(coverage_map.examples_covering_file(abs))
|
|
55
55
|
end
|
|
56
56
|
unless deleted
|
|
57
|
-
spec_contents ||=
|
|
57
|
+
spec_contents ||= spec_file_contents(root: root, spec_paths: spec_paths)
|
|
58
58
|
candidates = newly_covering_candidates(root: root, rel: rel, coverage_map: coverage_map,
|
|
59
59
|
spec_contents: spec_contents)
|
|
60
60
|
return FULL if candidates == :full
|
|
@@ -71,8 +71,17 @@ module ActiveMutator
|
|
|
71
71
|
drop_source_files: drop_source_files.uniq.sort)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def self.full_trigger?(rel)
|
|
75
|
-
rel.start_with?("
|
|
74
|
+
def self.full_trigger?(rel, spec_paths: ["spec"])
|
|
75
|
+
spec_paths.any? { |sp| rel.start_with?("#{sp}/support/") } || !rel.end_with?(".rb")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# All *_spec.rb files under the configured spec paths, absolute path =>
|
|
79
|
+
# file content. Shared by the delta scan and Runner's phase-2 escalation
|
|
80
|
+
# so "where do specs live" is answered in exactly one place (#35).
|
|
81
|
+
def self.spec_file_contents(root:, spec_paths:)
|
|
82
|
+
spec_paths
|
|
83
|
+
.flat_map { |sp| Dir[File.join(root, sp, "**", "*_spec.rb")] }
|
|
84
|
+
.uniq.to_h { |f| [f, File.read(f)] }
|
|
76
85
|
end
|
|
77
86
|
|
|
78
87
|
# #11: an unchanged spec file can START covering a changed source file
|
|
@@ -84,21 +93,13 @@ module ActiveMutator
|
|
|
84
93
|
abs = File.join(root, rel)
|
|
85
94
|
return [] unless File.exist?(abs)
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
return []
|
|
96
|
+
pattern = constant_reference_pattern(File.read(abs))
|
|
97
|
+
return [] unless pattern
|
|
89
98
|
|
|
90
99
|
all_specs = spec_contents.keys
|
|
91
100
|
|
|
92
101
|
covering_specs = coverage_map.examples_covering_file(abs)
|
|
93
102
|
.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
103
|
candidates = all_specs.filter_map do |spec_abs|
|
|
103
104
|
spec_rel = spec_abs.delete_prefix(root).delete_prefix("/")
|
|
104
105
|
next if covering_specs.include?(spec_rel)
|
|
@@ -119,5 +120,24 @@ module ActiveMutator
|
|
|
119
120
|
def self.spec_file_of(example_id)
|
|
120
121
|
example_id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "")
|
|
121
122
|
end
|
|
123
|
+
|
|
124
|
+
# Regexp matching any textual reference to a constant DEFINED in `source`,
|
|
125
|
+
# or nil when the source defines none. Shared by newly_covering_candidates
|
|
126
|
+
# and Runner's phase-2 escalation so the escaping/word-boundary rules live
|
|
127
|
+
# in one place.
|
|
128
|
+
#
|
|
129
|
+
# Escaping is required: dynamic-namespace class definitions (e.g.
|
|
130
|
+
# `class (a)::Baz`, `class foo.bar::Baz`) make constant_path.slice carry
|
|
131
|
+
# regex metachars. Unescaped, "(a)::Baz" would match the literal text
|
|
132
|
+
# "a::Baz" — a false candidate.
|
|
133
|
+
# TODO(#11, Task 10 residual gap): a top-level `class ::Foo` yields the
|
|
134
|
+
# slice "::Foo", and /\b::Foo\b/ can never match (no word boundary before
|
|
135
|
+
# ":"), so such files are silently unscanned.
|
|
136
|
+
def self.constant_reference_pattern(source)
|
|
137
|
+
constants = DefinedConstants.in_source(source)
|
|
138
|
+
return nil if constants.empty?
|
|
139
|
+
|
|
140
|
+
/\b(?:#{constants.map { |c| Regexp.escape(c) }.join("|")})\b/
|
|
141
|
+
end
|
|
122
142
|
end
|
|
123
143
|
end
|
|
@@ -9,7 +9,8 @@ module ActiveMutator
|
|
|
9
9
|
RECORDS = {}
|
|
10
10
|
TIMES = {}
|
|
11
11
|
|
|
12
|
-
def self.diff_coverage(before, after, root)
|
|
12
|
+
def self.diff_coverage(before, after, root, spec_paths: ["spec"])
|
|
13
|
+
prefixes = spec_paths.map { |sp| "/#{sp}/" }
|
|
13
14
|
hits = []
|
|
14
15
|
after.each do |path, data|
|
|
15
16
|
next unless path.start_with?(root)
|
|
@@ -19,7 +20,7 @@ module ActiveMutator
|
|
|
19
20
|
# spec/fixtures/ tree), which would otherwise falsely exclude every
|
|
20
21
|
# file under it.
|
|
21
22
|
relative = path.delete_prefix(root)
|
|
22
|
-
next if relative.start_with?(
|
|
23
|
+
next if prefixes.any? { |p| relative.start_with?(p) }
|
|
23
24
|
|
|
24
25
|
before_lines = before.dig(path, :lines)
|
|
25
26
|
data[:lines].each_with_index do |count, idx|
|
|
@@ -32,8 +33,10 @@ module ActiveMutator
|
|
|
32
33
|
hits
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
def self.build_payload(records, times)
|
|
36
|
-
{ "version" => 2, "records" => records, "times" => times }
|
|
36
|
+
def self.build_payload(records, times, expected_examples: nil)
|
|
37
|
+
payload = { "version" => 2, "records" => records, "times" => times }
|
|
38
|
+
payload["expected_examples"] = expected_examples if expected_examples
|
|
39
|
+
payload
|
|
37
40
|
end
|
|
38
41
|
end
|
|
39
42
|
end
|
|
@@ -51,16 +54,22 @@ if ENV["ACTIVE_MUTATOR_BASELINE_OUT"]
|
|
|
51
54
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
52
55
|
after = Coverage.peek_result
|
|
53
56
|
root = ENV.fetch("ACTIVE_MUTATOR_ROOT")
|
|
57
|
+
spec_paths = ENV.fetch("ACTIVE_MUTATOR_SPEC_PATHS", "spec").split(":")
|
|
54
58
|
ActiveMutator::BaselineHooks::RECORDS[example.id] =
|
|
55
|
-
ActiveMutator::BaselineHooks.diff_coverage(before, after, root)
|
|
59
|
+
ActiveMutator::BaselineHooks.diff_coverage(before, after, root, spec_paths: spec_paths)
|
|
56
60
|
# NOT example.execution_result.run_time: that is nil until after
|
|
57
61
|
# around hooks complete.
|
|
58
62
|
ActiveMutator::BaselineHooks::TIMES[example.id] = elapsed
|
|
59
63
|
end
|
|
60
64
|
|
|
61
65
|
config.after(:suite) do
|
|
66
|
+
# The expected count lets the parent detect an aborted run: RSpec
|
|
67
|
+
# swallows some mid-suite failures (e.g. Errno::EPIPE on a closed
|
|
68
|
+
# stdout) yet still runs after(:suite) and exits 0, which would stamp
|
|
69
|
+
# a partial coverage map as a fresh, trusted baseline.
|
|
62
70
|
payload = ActiveMutator::BaselineHooks.build_payload(
|
|
63
|
-
ActiveMutator::BaselineHooks::RECORDS, ActiveMutator::BaselineHooks::TIMES
|
|
71
|
+
ActiveMutator::BaselineHooks::RECORDS, ActiveMutator::BaselineHooks::TIMES,
|
|
72
|
+
expected_examples: RSpec.world.example_count
|
|
64
73
|
)
|
|
65
74
|
File.write(ENV.fetch("ACTIVE_MUTATOR_BASELINE_OUT"), JSON.generate(payload))
|
|
66
75
|
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
|
@@ -3,6 +3,13 @@ require "optparse"
|
|
|
3
3
|
module ActiveMutator
|
|
4
4
|
module CLI
|
|
5
5
|
def self.run(argv)
|
|
6
|
+
# Handled before OptionParser: its built-in --version handler prints
|
|
7
|
+
# "version unknown" and exits the process.
|
|
8
|
+
if argv.include?("--version")
|
|
9
|
+
puts "active_mutator #{VERSION}"
|
|
10
|
+
return 0
|
|
11
|
+
end
|
|
12
|
+
|
|
6
13
|
Runner.new(parse(argv)).call
|
|
7
14
|
rescue OptionParser::ParseError, Error => e
|
|
8
15
|
warn "active_mutator: #{e.message}"
|
|
@@ -22,9 +29,10 @@ module ActiveMutator
|
|
|
22
29
|
# boot cost (RSpec setup + spec file loading).
|
|
23
30
|
requires: [], timeout_factor: 8.0, timeout_floor: 10.0, force_baseline: false,
|
|
24
31
|
preload_helper: nil, serial_patterns: ["spec/system/", "spec/features/"],
|
|
32
|
+
spec_paths: ["spec"],
|
|
25
33
|
browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
|
|
26
34
|
max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
|
|
27
|
-
|
|
35
|
+
operators: [], class_level: true, class_level_closure_cap: 10
|
|
28
36
|
}
|
|
29
37
|
options.merge!(ConfigFile.load(Dir.pwd))
|
|
30
38
|
paths = OptionParser.new do |o|
|
|
@@ -35,7 +43,8 @@ module ActiveMutator
|
|
|
35
43
|
o.on("--jobs N", Integer, "Concurrent workers (default: half the CPU count)") { |v| options[:jobs] = v }
|
|
36
44
|
o.on("--format FMT", ConfigFile::FORMATS, "Output format") { |v| options[:format] = v.tr("-", "_").to_sym }
|
|
37
45
|
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[:
|
|
46
|
+
o.on("--operator FILE", "Ruby file defining a custom operator, loaded before analysis (repeatable)") { |v| options[:operators] << v }
|
|
47
|
+
o.on("--[no-]class-level", "Mutate class-level code: macros, constants, DSL lambdas (default: on)") { |v| options[:class_level] = v }
|
|
39
48
|
o.on("--force-baseline", "Ignore cached coverage map") { options[:force_baseline] = true }
|
|
40
49
|
o.on("--timeout-factor F", Float, "Timeout = baseline time * F + floor") { |v| options[:timeout_factor] = v }
|
|
41
50
|
o.on("--timeout-floor S", Float, "Minimum timeout seconds") { |v| options[:timeout_floor] = v }
|
|
@@ -46,6 +55,11 @@ module ActiveMutator
|
|
|
46
55
|
options[:serial_patterns_replaced] = true
|
|
47
56
|
options[:serial_patterns] << v
|
|
48
57
|
end
|
|
58
|
+
o.on("--spec-path DIR", "Directory holding spec files, relative to root (repeatable; replaces the default spec/ on first use)") do |v|
|
|
59
|
+
options[:spec_paths] = [] unless options[:spec_paths_replaced]
|
|
60
|
+
options[:spec_paths_replaced] = true
|
|
61
|
+
options[:spec_paths] << v
|
|
62
|
+
end
|
|
49
63
|
o.on("--browser-boot-seconds S", Float, "Extra timeout budget for serial-lane mutants") { |v| options[:browser_boot_seconds] = v }
|
|
50
64
|
o.on("--[no-]adaptive-timeout", "Scale timeout budgets from observed worker wall times (default: on)") { |v| options[:adaptive_timeout] = v }
|
|
51
65
|
o.on("--accept-survivors", "Record surviving mutants into the acceptance ledger") { options[:accept_survivors] = true }
|
|
@@ -58,6 +72,12 @@ module ActiveMutator
|
|
|
58
72
|
end
|
|
59
73
|
end.parse(argv)
|
|
60
74
|
options.delete(:serial_patterns_replaced)
|
|
75
|
+
options.delete(:spec_paths_replaced)
|
|
76
|
+
options[:spec_paths] = options[:spec_paths].map { |sp| sp.sub(%r{/+\z}, "") }
|
|
77
|
+
raise OptionParser::InvalidArgument, "--spec-path list must not be empty" if options[:spec_paths].empty?
|
|
78
|
+
if options[:spec_paths].any? { |sp| sp.strip.empty? }
|
|
79
|
+
raise OptionParser::InvalidArgument, "--spec-path entries must not be blank"
|
|
80
|
+
end
|
|
61
81
|
|
|
62
82
|
Config.new(paths: paths, root: Dir.pwd, **options)
|
|
63
83
|
end
|