active_mutator 0.2.0 → 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 +65 -14
- data/lib/active_mutator/baseline_delta.rb +21 -10
- data/lib/active_mutator/class_shape.rb +47 -0
- data/lib/active_mutator/cli.rb +3 -2
- data/lib/active_mutator/closure_reload.rb +202 -0
- data/lib/active_mutator/config.rb +2 -1
- data/lib/active_mutator/config_file.rb +8 -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 +128 -12
- 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: '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
|
@@ -263,6 +263,7 @@ survivors show inline on the PR diff. Pairs with the CI recipe:
|
|
|
263
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) |
|
|
264
264
|
| `--require FILE` | none | preload files (repeatable) |
|
|
265
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 |
|
|
266
267
|
| `--fail-at SCORE` | none (strict) | exit 0 if score >= SCORE even with survivors (opt-in relaxation for gradual adoption; 0 = report-only) |
|
|
267
268
|
|
|
268
269
|
`--debug-plan` prints the planned mutant list as one JSON document
|
|
@@ -287,7 +288,10 @@ lists; the first `--serial-pattern` replaces them). Recognized keys:
|
|
|
287
288
|
`requires`, `operators` (custom operator files, loaded before analysis; see
|
|
288
289
|
[Custom operators](docs/guides/custom-operators.md)),
|
|
289
290
|
`preload_helper` (a path, or `false` to skip preload),
|
|
290
|
-
`adaptive_timeout` (`true`/`false`)
|
|
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`).
|
|
291
295
|
Unknown keys and wrong types are errors, not silent no-ops.
|
|
292
296
|
|
|
293
297
|
```yaml
|
|
@@ -300,19 +304,66 @@ serial_patterns:
|
|
|
300
304
|
fail_at: 90 # legacy suite: gate on score instead of zero-survivors
|
|
301
305
|
```
|
|
302
306
|
|
|
303
|
-
##
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
constant
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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`.
|
|
316
367
|
|
|
317
368
|
## Guides
|
|
318
369
|
|
|
@@ -84,21 +84,13 @@ module ActiveMutator
|
|
|
84
84
|
abs = File.join(root, rel)
|
|
85
85
|
return [] unless File.exist?(abs)
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
return []
|
|
87
|
+
pattern = constant_reference_pattern(File.read(abs))
|
|
88
|
+
return [] unless pattern
|
|
89
89
|
|
|
90
90
|
all_specs = spec_contents.keys
|
|
91
91
|
|
|
92
92
|
covering_specs = coverage_map.examples_covering_file(abs)
|
|
93
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
94
|
candidates = all_specs.filter_map do |spec_abs|
|
|
103
95
|
spec_rel = spec_abs.delete_prefix(root).delete_prefix("/")
|
|
104
96
|
next if covering_specs.include?(spec_rel)
|
|
@@ -119,5 +111,24 @@ module ActiveMutator
|
|
|
119
111
|
def self.spec_file_of(example_id)
|
|
120
112
|
example_id.sub(%r{\A\./}, "").sub(/\[.*\]\z/, "")
|
|
121
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
|
|
122
133
|
end
|
|
123
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
|
@@ -24,7 +24,7 @@ module ActiveMutator
|
|
|
24
24
|
preload_helper: nil, serial_patterns: ["spec/system/", "spec/features/"],
|
|
25
25
|
browser_boot_seconds: 15.0, accept_survivors: false, exclude: [],
|
|
26
26
|
max_mutants: nil, debug_plan: false, fail_at: nil, adaptive_timeout: true,
|
|
27
|
-
|
|
27
|
+
operators: [], class_level: true, class_level_closure_cap: 10
|
|
28
28
|
}
|
|
29
29
|
options.merge!(ConfigFile.load(Dir.pwd))
|
|
30
30
|
paths = OptionParser.new do |o|
|
|
@@ -35,7 +35,8 @@ module ActiveMutator
|
|
|
35
35
|
o.on("--jobs N", Integer, "Concurrent workers (default: half the CPU count)") { |v| options[:jobs] = v }
|
|
36
36
|
o.on("--format FMT", ConfigFile::FORMATS, "Output format") { |v| options[:format] = v.tr("-", "_").to_sym }
|
|
37
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[:
|
|
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 }
|
|
39
40
|
o.on("--force-baseline", "Ignore cached coverage map") { options[:force_baseline] = true }
|
|
40
41
|
o.on("--timeout-factor F", Float, "Timeout = baseline time * F + floor") { |v| options[:timeout_factor] = v }
|
|
41
42
|
o.on("--timeout-floor S", Float, "Minimum timeout seconds") { |v| options[:timeout_floor] = v }
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
module ActiveMutator
|
|
2
|
+
# Fork-side insertion for class-body mutants. A def mutant can be
|
|
3
|
+
# class_eval'd over the live constant; class-level code cannot (re-running
|
|
4
|
+
# `validates` ADDS a validator, it doesn't replace one). So: remove the
|
|
5
|
+
# constant and re-eval the whole mutated file. Anything already attached to
|
|
6
|
+
# the OLD object — classes that include the module, subclasses, extend
|
|
7
|
+
# sites — would go stale, so they are removed and re-evaled too (pristine
|
|
8
|
+
# sources), dependency-first. The fork dies after the run; nothing is
|
|
9
|
+
# restored.
|
|
10
|
+
#
|
|
11
|
+
# Every guard failure raises Skip; the Worker reports the mutant as
|
|
12
|
+
# `skipped` with the reason. Skipping is honest: a mutant we cannot insert
|
|
13
|
+
# faithfully must not be counted as survived OR killed.
|
|
14
|
+
#
|
|
15
|
+
# Known limitations (inherent to remove-and-reload; accepted trade-offs):
|
|
16
|
+
# - References that hold the target BY VALUE rather than by ancestry are
|
|
17
|
+
# not discovered and keep pointing at the pre-remove_const object:
|
|
18
|
+
# aliases (`ALIAS = MyClass`), arrays/registries the target was pushed
|
|
19
|
+
# into, memoized instances, class variables captured at load time.
|
|
20
|
+
# - `refine`-based modules are anonymous and do not appear in normal
|
|
21
|
+
# `ancestors`, so refinements of the target are not discovered/reloaded.
|
|
22
|
+
# - Re-evaling the target AND each attacher re-runs their class-body side
|
|
23
|
+
# effects. Non-idempotent load-time effects (self-registration into a
|
|
24
|
+
# global registry, DescendantsTracker-style hooks) therefore run twice; a
|
|
25
|
+
# spec asserting such a count can see it doubled (false kill) or masked
|
|
26
|
+
# (false survival).
|
|
27
|
+
# - The re-eval order pins the target first (every attacher depends on it)
|
|
28
|
+
# then sorts the rest by instance-ancestor depth. An `extend`
|
|
29
|
+
# relationship BETWEEN two non-target attachers can still re-eval out of
|
|
30
|
+
# order (rare); a full topological sort is deliberately not attempted.
|
|
31
|
+
class ClosureReload
|
|
32
|
+
Skip = Class.new(StandardError)
|
|
33
|
+
|
|
34
|
+
# The MUTATED target source could not be re-evaled — the mutation broke the
|
|
35
|
+
# class so it no longer loads. Every covering spec loads the class, so the
|
|
36
|
+
# suite would fail: the Worker maps this to a kill, not an error.
|
|
37
|
+
MutantLoadError = Class.new(StandardError)
|
|
38
|
+
|
|
39
|
+
DEFAULT_CAP = 10
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
# Assigned by Runner from config before scheduling; forks inherit it.
|
|
43
|
+
attr_writer :cap
|
|
44
|
+
|
|
45
|
+
def cap = @cap || DEFAULT_CAP
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(subject, mutated_source)
|
|
49
|
+
@subject = subject
|
|
50
|
+
@mutated_source = mutated_source
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(cap: self.class.cap)
|
|
54
|
+
target = resolve_target
|
|
55
|
+
closure = compute_closure(target)
|
|
56
|
+
if closure.size > cap
|
|
57
|
+
raise Skip, "reload closure (#{closure.size} constants) exceeds cap (#{cap})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Re-eval must load dependencies before dependents. Every closure member
|
|
61
|
+
# carries the target directly (single-pass discovery invariant), so the
|
|
62
|
+
# target must be re-eval'd first — pin it to the front. `ancestors.size`
|
|
63
|
+
# can't do this alone: an `extend` puts the target in the extender's
|
|
64
|
+
# SINGLETON ancestry, so a module that extends the target has an instance
|
|
65
|
+
# ancestry SMALLER than the target's and would sort before it (NameError
|
|
66
|
+
# on re-eval). For the remaining attachers, ascending instance-ancestor
|
|
67
|
+
# depth is a valid order among include/subclass relationships (a
|
|
68
|
+
# superclass before its subclass, an included module before its
|
|
69
|
+
# includer); depth is captured while the constants are still live, since
|
|
70
|
+
# it can't be read after remove_const.
|
|
71
|
+
rest = closure.reject { |m| m.equal?(target) }
|
|
72
|
+
ordered = [target, *rest.sort_by { |m| m.ancestors.size }]
|
|
73
|
+
sources = ordered.map { |mod| [mod.name, source_for(mod)] }
|
|
74
|
+
sources.each { |name, _| remove_constant(name) }
|
|
75
|
+
sources.each_with_index do |(_, (file, src)), idx|
|
|
76
|
+
eval(src, TOPLEVEL_BINDING, file, 1) # rubocop:disable Security/Eval
|
|
77
|
+
rescue ScriptError, StandardError => e
|
|
78
|
+
# idx.zero? is the MUTATED target, which by construction depends on
|
|
79
|
+
# nothing else in the closure (every other member carries the target,
|
|
80
|
+
# not vice versa). Its source failing to load is therefore the
|
|
81
|
+
# mutation's own doing — a kill, not a tool error.
|
|
82
|
+
#
|
|
83
|
+
# A PRISTINE dependent (idx > 0) failing means the ancestry-depth order
|
|
84
|
+
# couldn't satisfy a cross-attacher reference (see the ordering note
|
|
85
|
+
# above): we can't faithfully reinstate the closure, so this is an
|
|
86
|
+
# honest Skip, not a survived/killed verdict and not a bare error.
|
|
87
|
+
raise MutantLoadError, e.message if idx.zero?
|
|
88
|
+
|
|
89
|
+
raise Skip, "reload re-eval failed (#{e.message}); closure could not be reinstated in dependency order"
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def resolve_target
|
|
97
|
+
scope = @subject.constant_scope
|
|
98
|
+
target = begin
|
|
99
|
+
Object.const_get(scope)
|
|
100
|
+
rescue NameError
|
|
101
|
+
raise Skip, "constant #{scope} not loaded"
|
|
102
|
+
end
|
|
103
|
+
file, = Object.const_source_location(scope)
|
|
104
|
+
unless file && File.identical?(file, @subject.file)
|
|
105
|
+
raise Skip, "#{scope} defined at #{file || "?"}, not #{@subject.file} (reopened constant)"
|
|
106
|
+
end
|
|
107
|
+
target
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Single discovery pass. Ruby's `ancestors` is transitive, so one scan for
|
|
111
|
+
# everything carrying the target already yields every transitive attacher:
|
|
112
|
+
# an attacher-of-an-attacher (a subclass of an includer, an includer of an
|
|
113
|
+
# includer) carries the target directly too. No BFS/dedup is needed — the
|
|
114
|
+
# re-eval order is imposed later by pinning the target first and sorting the
|
|
115
|
+
# rest by ancestry depth (see #call), not by discovery order and not by a
|
|
116
|
+
# full topological sort (deliberately not attempted; see class docstring).
|
|
117
|
+
def compute_closure(target)
|
|
118
|
+
[target, *attachers(target)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Everything stale after removing `mod`: includers and subclasses carry
|
|
122
|
+
# it in `ancestors`; extend-sites carry it in their singleton class's
|
|
123
|
+
# ancestors, so singleton classes map back through attached_object. `.uniq`
|
|
124
|
+
# collapses a member found both ways (a class that both includes and
|
|
125
|
+
# extends the target).
|
|
126
|
+
def attachers(mod)
|
|
127
|
+
ObjectSpace.each_object(Module).filter_map do |m|
|
|
128
|
+
next if m.equal?(mod)
|
|
129
|
+
next unless carries?(m, mod)
|
|
130
|
+
|
|
131
|
+
if m.singleton_class?
|
|
132
|
+
m = m.attached_object
|
|
133
|
+
unless m.is_a?(Module)
|
|
134
|
+
raise Skip, "an object instance is extended with #{mod.name || mod.inspect}; not reloadable"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
m
|
|
139
|
+
end.uniq
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ObjectSpace hands back every Module in the VM, including ones we cannot
|
|
143
|
+
# introspect — e.g. a module whose #ancestors is overridden to raise. A
|
|
144
|
+
# module we cannot read is not a reloadable dependency of the target, so
|
|
145
|
+
# treat it as unrelated rather than aborting the whole scan. Trade-off: a
|
|
146
|
+
# genuine attacher whose #ancestors raises would be silently dropped
|
|
147
|
+
# (acceptable — pathological).
|
|
148
|
+
#
|
|
149
|
+
# The rescue is deliberately broader than StandardError: #ancestors on a
|
|
150
|
+
# foreign object can raise Exception-level errors that are NOT StandardError
|
|
151
|
+
# — the canonical case is an expired RSpec verifying double
|
|
152
|
+
# (ExpiredTestDoubleError < MockExpectationError < Exception) left in
|
|
153
|
+
# ObjectSpace by another spec. Control-flow errors (a signal such as an
|
|
154
|
+
# interrupt, or an explicit exit) are re-raised so a run stays
|
|
155
|
+
# interruptible; everything else means "not a dependency, skip it".
|
|
156
|
+
def carries?(mod, target)
|
|
157
|
+
mod.ancestors.include?(target)
|
|
158
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
159
|
+
raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
160
|
+
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def source_for(mod)
|
|
165
|
+
name = mod.name
|
|
166
|
+
raise Skip, "anonymous #{mod.is_a?(Class) ? "class" : "module"} in reload closure" unless name
|
|
167
|
+
|
|
168
|
+
return [@subject.file, @mutated_source] if name == @subject.constant_scope
|
|
169
|
+
|
|
170
|
+
file, = Object.const_source_location(name)
|
|
171
|
+
raise Skip, "#{name}: no source file (native or dynamically defined)" unless file && File.exist?(file)
|
|
172
|
+
|
|
173
|
+
src = File.read(file)
|
|
174
|
+
unless single_constant_file?(src)
|
|
175
|
+
raise Skip, "#{name}: #{file} defines multiple top-level constants; not reloadable"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
[file, src]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Same Zeitwerk-shape rule the SubjectFinder gate applies to the target
|
|
182
|
+
# file: re-evaling a multi-constant file would re-run macros on constants
|
|
183
|
+
# that were NOT removed (accumulation bugs). Shared with SubjectFinder.
|
|
184
|
+
def single_constant_file?(source)
|
|
185
|
+
result = Prism.parse(source)
|
|
186
|
+
return false unless result.success?
|
|
187
|
+
|
|
188
|
+
ClassShape.single_top_level_constant?(result.value)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def remove_constant(name)
|
|
192
|
+
parts = name.split("::")
|
|
193
|
+
leaf = parts.pop
|
|
194
|
+
parent = parts.empty? ? Object : Object.const_get(parts.join("::"))
|
|
195
|
+
parent.send(:remove_const, leaf)
|
|
196
|
+
rescue NameError
|
|
197
|
+
# Either a parent namespace earlier in the closure was already removed
|
|
198
|
+
# (taking this nested constant with it), or the leaf is already gone.
|
|
199
|
+
# Nothing to remove — the re-eval pass reinstates it from its own file.
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -5,5 +5,6 @@ module ActiveMutator
|
|
|
5
5
|
:timeout_factor, :timeout_floor, :force_baseline, :root,
|
|
6
6
|
:preload_helper, :serial_patterns, :browser_boot_seconds,
|
|
7
7
|
:accept_survivors, :exclude, :max_mutants, :debug_plan,
|
|
8
|
-
:fail_at, :adaptive_timeout, :
|
|
8
|
+
:fail_at, :adaptive_timeout, :operators,
|
|
9
|
+
:class_level, :class_level_closure_cap)
|
|
9
10
|
end
|
|
@@ -22,12 +22,11 @@ module ActiveMutator
|
|
|
22
22
|
"requires" => :string_list,
|
|
23
23
|
"operators" => :string_list,
|
|
24
24
|
"preload_helper" => :preload_helper,
|
|
25
|
-
"adaptive_timeout" => :boolean
|
|
25
|
+
"adaptive_timeout" => :boolean,
|
|
26
|
+
"class_level" => :boolean,
|
|
27
|
+
"class_level_closure_cap" => :positive_integer
|
|
26
28
|
}.freeze
|
|
27
29
|
|
|
28
|
-
# YAML keys that don't match their Config member name.
|
|
29
|
-
RENAMES = { "operators" => :operator_paths }.freeze
|
|
30
|
-
|
|
31
30
|
def self.load(root)
|
|
32
31
|
path = File.join(root, FILENAME)
|
|
33
32
|
return {} unless File.exist?(path)
|
|
@@ -40,7 +39,7 @@ module ActiveMutator
|
|
|
40
39
|
validator = KEYS[key]
|
|
41
40
|
raise Error, "#{FILENAME}: unknown config key: #{key}" unless validator
|
|
42
41
|
|
|
43
|
-
[
|
|
42
|
+
[key.to_sym, coerce(key, validator, value)]
|
|
44
43
|
end
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -55,6 +54,10 @@ module ActiveMutator
|
|
|
55
54
|
when :integer
|
|
56
55
|
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
57
56
|
value
|
|
57
|
+
when :positive_integer
|
|
58
|
+
raise Error, "#{FILENAME}: #{key} must be an integer" unless value.is_a?(Integer)
|
|
59
|
+
raise Error, "#{FILENAME}: #{key} must be >= 1" unless value >= 1
|
|
60
|
+
value
|
|
58
61
|
when :number
|
|
59
62
|
raise Error, "#{FILENAME}: #{key} must be a number" unless value.is_a?(Numeric)
|
|
60
63
|
value.to_f
|
|
@@ -8,7 +8,9 @@ module ActiveMutator
|
|
|
8
8
|
result = Prism.parse(source)
|
|
9
9
|
raise Error, "#{subject.file} no longer parses" unless result.success?
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
return analyze_class_body(subject, source, result) if subject.class_body?
|
|
12
|
+
|
|
13
|
+
def_node = find_node(result.value, subject.byte_range.begin, Prism::DefNode)
|
|
12
14
|
raise Error, "subject not found: #{subject.name}" unless def_node
|
|
13
15
|
|
|
14
16
|
invalid = 0
|
|
@@ -22,16 +24,115 @@ module ActiveMutator
|
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
+
def analyze_class_body(subject, source, result)
|
|
28
|
+
class_node = find_node(result.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
|
|
29
|
+
raise Error, "subject not found: #{subject.name}" unless class_node
|
|
30
|
+
|
|
31
|
+
invalid = 0
|
|
32
|
+
mutations = collect_class_body_edits(class_node).filter_map do |edit|
|
|
33
|
+
mutation, valid = build_class_body_mutation(subject, source, edit)
|
|
34
|
+
invalid += 1 unless valid
|
|
35
|
+
mutation
|
|
36
|
+
end
|
|
37
|
+
Analysis.new(mutations: mutations, invalid_count: invalid)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Depth-first search for the node of one of `types` whose byte range starts
|
|
41
|
+
# at `start_offset`. Def-level and class-body subject location share this;
|
|
42
|
+
# only the node type(s) differ (DefNode vs Class/ModuleNode).
|
|
43
|
+
def find_node(node, start_offset, *types)
|
|
44
|
+
return node if types.any? { |t| node.is_a?(t) } && node.location.start_offset == start_offset
|
|
27
45
|
|
|
28
46
|
node.compact_child_nodes.each do |child|
|
|
29
|
-
found =
|
|
47
|
+
found = find_node(child, start_offset, *types)
|
|
30
48
|
return found if found
|
|
31
49
|
end
|
|
32
50
|
nil
|
|
33
51
|
end
|
|
34
52
|
|
|
53
|
+
# Class-level code only: defs, nested class/modules and `class << self`
|
|
54
|
+
# bodies are owned by other subjects. Lambdas (scope bodies, if: procs) ARE
|
|
55
|
+
# descended, as are the ActiveSupport::Concern DSL blocks (`included`,
|
|
56
|
+
# `prepended`, `class_methods`) whose bodies re-run as class-level code in
|
|
57
|
+
# the includer (issue #31). Every OTHER block (association extensions,
|
|
58
|
+
# custom DSLs that run in an unknown context) stays pruned — mutating those
|
|
59
|
+
# risks false survivors. Edits that would delete a whole owned statement
|
|
60
|
+
# (StatementDeletion sees the enclosing StatementsNode) are discarded.
|
|
61
|
+
# Owned ranges are collected recursively while walking, not just from the
|
|
62
|
+
# class body's direct children: a def can nest inside class-level control
|
|
63
|
+
# flow (`if`/`unless`/`begin`), and deleting it there is equally out of
|
|
64
|
+
# scope.
|
|
65
|
+
def collect_class_body_edits(class_node)
|
|
66
|
+
owned = []
|
|
67
|
+
edits = []
|
|
68
|
+
class_walk(class_node.body, owned) do |node|
|
|
69
|
+
@operators.each do |op|
|
|
70
|
+
edits.concat(op.edits(node))
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
raise Error, "operator #{op.class.name} failed on #{node.class.name}: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
edits.reject { |e| owned.include?(e.range) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def owned_statement?(node) = ClassShape.owned_by_other_subject?(node)
|
|
79
|
+
|
|
80
|
+
# ActiveSupport::Concern DSL calls whose block body re-runs as class-level
|
|
81
|
+
# code in the includer, so it is in scope for class-body mutation.
|
|
82
|
+
CONCERN_BLOCK_CALLS = %i[included prepended class_methods].freeze
|
|
83
|
+
|
|
84
|
+
def concern_dsl_block?(node)
|
|
85
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? &&
|
|
86
|
+
CONCERN_BLOCK_CALLS.include?(node.name) && node.block.is_a?(Prism::BlockNode)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# No nil guard needed (unlike #walk): the entry node is the class body's
|
|
90
|
+
# StatementsNode, guaranteed present for a class-body subject, and
|
|
91
|
+
# compact_child_nodes never yields nil.
|
|
92
|
+
def class_walk(node, owned, &blk)
|
|
93
|
+
if owned_statement?(node)
|
|
94
|
+
owned << (node.location.start_offset...node.location.end_offset)
|
|
95
|
+
elsif node.is_a?(Prism::BlockNode)
|
|
96
|
+
# Pruned: a block's run-time context is unknown (see collect comment).
|
|
97
|
+
elsif concern_dsl_block?(node)
|
|
98
|
+
# Inside a concern block the statements have no subject of their own, so
|
|
99
|
+
# mutate everything (including nested def bodies) exactly like the
|
|
100
|
+
# def-level #walk — do NOT recurse via class_walk (it would prune the
|
|
101
|
+
# block) and do NOT mark the interior defs owned. The concern call node
|
|
102
|
+
# itself is not yielded: the whole-block deletion edit already comes
|
|
103
|
+
# from the enclosing StatementsNode, and no operator targets a bare
|
|
104
|
+
# receiverless call.
|
|
105
|
+
walk(node.block.body, &blk)
|
|
106
|
+
else
|
|
107
|
+
yield node
|
|
108
|
+
node.compact_child_nodes.each { |child| class_walk(child, owned, &blk) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The mutant is the whole file. The def-shaped fields are filled with the
|
|
113
|
+
# file source so the Mutation shape stays uniform; Worker routes
|
|
114
|
+
# class-body mutants through ClosureReload (whole-file re-eval), never
|
|
115
|
+
# through Inserter's class_eval.
|
|
116
|
+
def build_class_body_mutation(subject, source, edit)
|
|
117
|
+
original = source.byteslice(edit.range)
|
|
118
|
+
return [nil, true] if edit.replacement == original # no-op guard
|
|
119
|
+
|
|
120
|
+
mutated = Splicer.apply(source, [edit])
|
|
121
|
+
parsed = Prism.parse(mutated)
|
|
122
|
+
return [nil, false] unless parsed.success?
|
|
123
|
+
return [nil, false] unless find_node(parsed.value, subject.byte_range.begin, Prism::ClassNode, Prism::ModuleNode)
|
|
124
|
+
|
|
125
|
+
[Mutation.new(
|
|
126
|
+
subject: subject,
|
|
127
|
+
edit: edit,
|
|
128
|
+
original_snippet: original,
|
|
129
|
+
line: source.byteslice(0, edit.range.begin).count("\n") + 1,
|
|
130
|
+
mutated_file_source: mutated,
|
|
131
|
+
mutated_def_source: mutated,
|
|
132
|
+
mutated_def_line: 1
|
|
133
|
+
), true]
|
|
134
|
+
end
|
|
135
|
+
|
|
35
136
|
def collect_edits(def_node)
|
|
36
137
|
edits = []
|
|
37
138
|
walk(def_node.body) do |node|
|
|
@@ -71,7 +172,7 @@ module ActiveMutator
|
|
|
71
172
|
parsed = Prism.parse(mutated)
|
|
72
173
|
return [nil, false] unless parsed.success?
|
|
73
174
|
|
|
74
|
-
new_def =
|
|
175
|
+
new_def = find_node(parsed.value, subject.byte_range.begin, Prism::DefNode)
|
|
75
176
|
return [nil, false] unless new_def
|
|
76
177
|
|
|
77
178
|
[Mutation.new(
|
|
@@ -13,7 +13,8 @@ module ActiveMutator
|
|
|
13
13
|
class StrykerJson
|
|
14
14
|
SCHEMA_URL = "https://git.io/mutation-testing-schema"
|
|
15
15
|
STATUS = { killed: "Killed", survived: "Survived", timeout: "Timeout",
|
|
16
|
-
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored"
|
|
16
|
+
error: "RuntimeError", uncovered: "NoCoverage", accepted: "Ignored",
|
|
17
|
+
skipped: "Ignored" }.freeze
|
|
17
18
|
ACCEPTED_REASON = "Accepted as equivalent in #{AcceptedLedger::FILENAME}".freeze
|
|
18
19
|
REPORT_PATH = File.join(".active_mutator", "mutation-report.json")
|
|
19
20
|
|
|
@@ -87,7 +88,17 @@ module ActiveMutator
|
|
|
87
88
|
def covered_by(result)
|
|
88
89
|
return nil unless @coverage_map
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
subject = result.mutation.subject
|
|
92
|
+
# Class-body lines execute at load time, so per-line coverage never
|
|
93
|
+
# attributes examples to them (see Runner#examples_for_mutation). Mirror
|
|
94
|
+
# the scheduling substitution — every example that loaded the file — so
|
|
95
|
+
# the viewer shows real test linkage instead of an empty coveredBy.
|
|
96
|
+
if subject.class_body?
|
|
97
|
+
examples = @coverage_map.examples_covering_file(subject.file)
|
|
98
|
+
return examples.empty? ? nil : examples.sort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@coverage_map.examples_for(subject.file, result.mutation.lines)
|
|
91
102
|
end
|
|
92
103
|
|
|
93
104
|
def referenced_examples(results)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
2
|
module Reporter
|
|
3
3
|
class Terminal
|
|
4
|
-
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A"
|
|
4
|
+
CHARS = { killed: ".", survived: "S", timeout: "T", error: "E", uncovered: "U", accepted: "A",
|
|
5
|
+
skipped: "-" }.freeze
|
|
5
6
|
|
|
6
7
|
def initialize(out: $stdout)
|
|
7
8
|
@out = out
|
|
@@ -21,6 +22,8 @@ module ActiveMutator
|
|
|
21
22
|
@out.puts format("Mutation score: %.1f%%", score(counts) * 100)
|
|
22
23
|
survivors = results.select { |r| r.status == :survived }
|
|
23
24
|
print_survivors(survivors) unless survivors.empty?
|
|
25
|
+
skipped = results.select { |r| r.status == :skipped }
|
|
26
|
+
print_skipped(skipped) unless skipped.empty?
|
|
24
27
|
stats = OperatorStats.call(results)
|
|
25
28
|
noisy = stats.select { |_, s| s["survived"].positive? }
|
|
26
29
|
print_operator_stats(noisy) unless noisy.empty?
|
|
@@ -54,6 +57,15 @@ module ActiveMutator
|
|
|
54
57
|
@out.puts " #{m.description}"
|
|
55
58
|
@out.puts " - #{m.original_snippet}"
|
|
56
59
|
@out.puts " + #{m.edit.replacement}"
|
|
60
|
+
@out.puts " (#{result.details})" if result.details
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_skipped(skipped)
|
|
65
|
+
@out.puts "", "Skipped mutants (not counted in the score):"
|
|
66
|
+
skipped.each do |result|
|
|
67
|
+
m = result.mutation
|
|
68
|
+
@out.puts " #{m.subject.name} (#{m.subject.file}:#{m.line}): #{result.details}"
|
|
57
69
|
end
|
|
58
70
|
end
|
|
59
71
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted
|
|
2
|
+
# status: :killed | :survived | :timeout | :error | :uncovered | :accepted | :skipped
|
|
3
3
|
Result = Data.define(:mutation, :status, :details) do
|
|
4
4
|
def detected? = %i[killed timeout].include?(status)
|
|
5
5
|
end
|
|
@@ -10,6 +10,7 @@ module ActiveMutator
|
|
|
10
10
|
def call
|
|
11
11
|
ENV["ACTIVE_MUTATOR"] = "1"
|
|
12
12
|
load_operators
|
|
13
|
+
ClosureReload.cap = @config.class_level_closure_cap
|
|
13
14
|
preload!
|
|
14
15
|
preload_spec_helper!
|
|
15
16
|
map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
|
|
@@ -25,7 +26,7 @@ module ActiveMutator
|
|
|
25
26
|
scanned_files = prune_scope(subjects)
|
|
26
27
|
warn_stale(ledger, fingerprints.values, scanned_files)
|
|
27
28
|
|
|
28
|
-
items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
29
|
+
items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
|
|
29
30
|
return debug_plan(items, pre_results) if @config.debug_plan
|
|
30
31
|
|
|
31
32
|
pre_results.each { |r| @reporter.on_result(r) }
|
|
@@ -35,6 +36,8 @@ module ActiveMutator
|
|
|
35
36
|
scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
|
|
36
37
|
calibrators: calibrators)
|
|
37
38
|
results = scheduler.run(items) + pre_results
|
|
39
|
+
# Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
|
|
40
|
+
results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
|
|
38
41
|
|
|
39
42
|
accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
|
|
40
43
|
|
|
@@ -42,7 +45,9 @@ module ActiveMutator
|
|
|
42
45
|
exit_code(results)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
# Returns [work_items, pre_results].
|
|
48
|
+
# Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
|
|
49
|
+
# planned mutation to the example ids it was scheduled against, so phase 2
|
|
50
|
+
# escalation can subtract what was already run. Public for unit testing.
|
|
46
51
|
def plan_work(mutations, map, ledger: nil, fingerprints: {})
|
|
47
52
|
items = []
|
|
48
53
|
pre_results = []
|
|
@@ -51,19 +56,75 @@ module ActiveMutator
|
|
|
51
56
|
pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
|
|
52
57
|
next
|
|
53
58
|
end
|
|
54
|
-
example_ids =
|
|
59
|
+
example_ids = examples_for_mutation(mutation, map)
|
|
55
60
|
if example_ids.empty?
|
|
56
61
|
pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
|
|
57
62
|
else
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
items << build_work_item(mutation, example_ids, map)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
|
|
67
|
+
[items, pre_results, phase1_ids]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Phase 2 of the class-body kill pipeline (public for unit testing).
|
|
71
|
+
# A class-body survivor is only DECLARED after every spec file that
|
|
72
|
+
# references the constant has had its shot: re-enqueue against the
|
|
73
|
+
# referencing files phase 1 didn't run, and take the escalated verdict.
|
|
74
|
+
#
|
|
75
|
+
# `scheduler` is injectable for unit tests; in the normal run it is nil and
|
|
76
|
+
# a dedicated escalation scheduler is built lazily (only when there is
|
|
77
|
+
# phase-2 work) with NO on_result — escalation is a refinement pass, and
|
|
78
|
+
# reporting through the live callback would print a second status char for a
|
|
79
|
+
# mutant already streamed in phase 1. The final summary reflects the
|
|
80
|
+
# escalated verdicts regardless.
|
|
81
|
+
def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
|
|
82
|
+
candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
|
|
83
|
+
# Perf gate: skip reading the whole spec suite into memory in the common
|
|
84
|
+
# case of no class-body survivors. (Deleting this line is a behavioral
|
|
85
|
+
# no-op — the later `items.empty?` return still guards correctness — so
|
|
86
|
+
# its mutant is a known equivalent.)
|
|
87
|
+
return results if candidates.empty?
|
|
88
|
+
|
|
89
|
+
spec_contents = Dir[File.join(@config.root, "spec/**/*_spec.rb")].to_h { |f| [f, File.read(f)] }
|
|
90
|
+
patterns = {} # subject file => constant-reference pattern (parsed once per file)
|
|
91
|
+
items = {}
|
|
92
|
+
candidates.each do |r|
|
|
93
|
+
file = r.mutation.subject.file
|
|
94
|
+
pattern = patterns.fetch(file) do
|
|
95
|
+
patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
|
|
96
|
+
end
|
|
97
|
+
next unless pattern
|
|
98
|
+
|
|
99
|
+
ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
|
|
100
|
+
next if ids.empty?
|
|
101
|
+
|
|
102
|
+
items[r.mutation] = build_work_item(r.mutation, ids, map)
|
|
103
|
+
end
|
|
104
|
+
return results if items.empty?
|
|
105
|
+
|
|
106
|
+
scheduler ||= Scheduler.new(jobs: @config.jobs)
|
|
107
|
+
escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
|
|
108
|
+
results.map do |r|
|
|
109
|
+
# A replacement only ever exists for a survived candidate (items is
|
|
110
|
+
# built solely from those), so no redundant status re-check is needed.
|
|
111
|
+
replacement = escalated[r.mutation]
|
|
112
|
+
next r unless replacement
|
|
113
|
+
|
|
114
|
+
case replacement.status
|
|
115
|
+
when :killed
|
|
116
|
+
replacement
|
|
117
|
+
when :survived
|
|
118
|
+
extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
|
|
119
|
+
replacement.with(details: "escalated (+#{extra} spec files)")
|
|
120
|
+
else
|
|
121
|
+
# A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
|
|
122
|
+
# already survived phase 1, so keep that verdict rather than letting
|
|
123
|
+
# an inconclusive escalation inflate the score (a :timeout counts as
|
|
124
|
+
# detected in exit_code/score).
|
|
125
|
+
r
|
|
64
126
|
end
|
|
65
127
|
end
|
|
66
|
-
[items, pre_results]
|
|
67
128
|
end
|
|
68
129
|
|
|
69
130
|
def exit_code(results)
|
|
@@ -78,12 +139,45 @@ module ActiveMutator
|
|
|
78
139
|
|
|
79
140
|
private
|
|
80
141
|
|
|
142
|
+
# Single source of truth for lane/timeout/variable derivation, shared by
|
|
143
|
+
# phase-1 planning and phase-2 escalation so the two never drift.
|
|
144
|
+
def build_work_item(mutation, example_ids, map)
|
|
145
|
+
lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
|
|
146
|
+
variable = map.time_for(example_ids) * @config.timeout_factor
|
|
147
|
+
boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
|
|
148
|
+
timeout = variable + @config.timeout_floor + boot_extra
|
|
149
|
+
WorkItem.new(mutation: mutation, example_ids: example_ids,
|
|
150
|
+
timeout: timeout, lane: lane, variable: variable)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Spec files that textually match `pattern` (a constant-reference pattern
|
|
154
|
+
# for the subject's file, built via BaselineDelta.constant_reference_pattern
|
|
155
|
+
# so the escaping/word-boundary rules stay shared), minus everything phase 1
|
|
156
|
+
# already ran; returned as example ids.
|
|
157
|
+
#
|
|
158
|
+
# Two deliberate choices: (a) matching is TEXTUAL, so a constant named in a
|
|
159
|
+
# comment or string still counts — intentional, since the worst case is a
|
|
160
|
+
# wasted run and the verdict stays correct; (b) unlike
|
|
161
|
+
# BaselineDelta.newly_covering_candidates there is intentionally NO fan-out
|
|
162
|
+
# ceiling here — a class-body survivor gets every referencing spec its shot
|
|
163
|
+
# before being declared.
|
|
164
|
+
def escalation_examples(map, spec_contents, phase1_example_ids, pattern)
|
|
165
|
+
phase1_files = phase1_example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq
|
|
166
|
+
spec_contents.filter_map do |abs, content|
|
|
167
|
+
rel = abs.delete_prefix(@config.root.chomp("/") + "/")
|
|
168
|
+
next if phase1_files.include?(rel)
|
|
169
|
+
next unless content.match?(pattern)
|
|
170
|
+
|
|
171
|
+
map.examples_for_spec_file(rel)
|
|
172
|
+
end.flatten.uniq.sort
|
|
173
|
+
end
|
|
174
|
+
|
|
81
175
|
# Custom operators must exist in the PARENT before Engine analysis:
|
|
82
176
|
# subclassing Operators::Base self-registers, and forks inherit the
|
|
83
177
|
# loaded class. `requires` can't serve — those load inside the fork's
|
|
84
178
|
# setup, after mutations are already planned.
|
|
85
179
|
def load_operators
|
|
86
|
-
@config.
|
|
180
|
+
@config.operators.each do |f|
|
|
87
181
|
require File.expand_path(f, @config.root)
|
|
88
182
|
rescue LoadError, SyntaxError => e
|
|
89
183
|
raise Error, "operator file not loadable: #{f}: #{e.message}"
|
|
@@ -98,6 +192,24 @@ module ActiveMutator
|
|
|
98
192
|
mutation.lines.to_a | mutation.subject.line_range.to_a
|
|
99
193
|
end
|
|
100
194
|
|
|
195
|
+
# Class-body lines execute at load time, so line coverage never
|
|
196
|
+
# attributes examples to them. Substitute: every example that covers ANY
|
|
197
|
+
# line of the file (it must have loaded the class), plus the convention
|
|
198
|
+
# spec file's examples. Phase 2 (escalation) widens further before a
|
|
199
|
+
# survivor is declared.
|
|
200
|
+
def examples_for_mutation(mutation, map)
|
|
201
|
+
return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
|
|
202
|
+
|
|
203
|
+
(map.examples_covering_file(mutation.subject.file) |
|
|
204
|
+
map.examples_for_spec_file(convention_spec_rel(mutation.subject.file))).sort
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def convention_spec_rel(file)
|
|
208
|
+
rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
|
|
209
|
+
rest = rel.sub(%r{\A[^/]+/}, "")
|
|
210
|
+
"spec/#{rest}_spec.rb"
|
|
211
|
+
end
|
|
212
|
+
|
|
101
213
|
def build_reporter
|
|
102
214
|
case @config.format
|
|
103
215
|
when :json then Reporter::Json.new
|
|
@@ -126,6 +238,7 @@ module ActiveMutator
|
|
|
126
238
|
.uniq
|
|
127
239
|
.reject { |file| excluded?(file) }
|
|
128
240
|
.sort.flat_map { |file| SubjectFinder.call(file) }
|
|
241
|
+
subjects = subjects.reject(&:class_body?) unless @config.class_level
|
|
129
242
|
if @config.subject_filter
|
|
130
243
|
matcher = SubjectMatcher.new(@config.subject_filter)
|
|
131
244
|
subjects = subjects.select { |s| matcher.match?(s.name) }
|
|
@@ -210,8 +323,11 @@ module ActiveMutator
|
|
|
210
323
|
# MAINTENANCE: any future flag that narrows the mutant set below "every
|
|
211
324
|
# subject in the scanned files" MUST be added to this nil-trigger list,
|
|
212
325
|
# or scoped accept runs will clobber out-of-scope ledger entries (#24).
|
|
326
|
+
# --no-class-level drops every class_body subject (discover_subjects), so a
|
|
327
|
+
# file's class-body fingerprint is absent even though the file is scanned;
|
|
328
|
+
# without this guard its accepted ledger entry looks stale and gets pruned.
|
|
213
329
|
def prune_scope(subjects)
|
|
214
|
-
return nil if @config.subject_filter || @config.since || @config.max_mutants
|
|
330
|
+
return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
|
|
215
331
|
|
|
216
332
|
subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
|
|
217
333
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module ActiveMutator
|
|
2
|
-
# A mutable unit: one method definition
|
|
3
|
-
# byte_range/line_range cover the whole `def ... end
|
|
2
|
+
# A mutable unit. kind :instance/:singleton = one method definition
|
|
3
|
+
# (byte_range/line_range cover the whole `def ... end`). kind :class_body =
|
|
4
|
+
# the class-level code of one class/module (byte_range covers the whole
|
|
5
|
+
# class/module node; Engine only mutates non-def body statements).
|
|
4
6
|
# sclass: def lives inside `class << self` — its source slice is `def foo`,
|
|
5
7
|
# so Inserter must target the singleton class, not the constant itself.
|
|
6
8
|
Subject = Data.define(:name, :file, :byte_range, :line_range, :constant_scope, :kind, :sclass) do
|
|
@@ -9,5 +11,7 @@ module ActiveMutator
|
|
|
9
11
|
end
|
|
10
12
|
|
|
11
13
|
def singleton? = kind == :singleton
|
|
14
|
+
|
|
15
|
+
def class_body? = kind == :class_body
|
|
12
16
|
end
|
|
13
17
|
end
|
|
@@ -11,16 +11,25 @@ module ActiveMutator
|
|
|
11
11
|
skip_lines = result.comments
|
|
12
12
|
.select { |c| c.slice.match?(SKIP_MARKER) }
|
|
13
13
|
.to_set { |c| c.location.start_line }
|
|
14
|
-
finder = new(file, skip_lines: skip_lines
|
|
14
|
+
finder = new(file, skip_lines: skip_lines,
|
|
15
|
+
class_level: zeitwerk_shaped?(result.value))
|
|
15
16
|
finder.visit(result.value)
|
|
16
17
|
finder.subjects
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
# Class-body subjects only for Zeitwerk-shaped files: exactly one
|
|
21
|
+
# top-level constant. Multi-constant files and core-class reopens have no
|
|
22
|
+
# safe remove_const + re-eval story (issue #32). Shared with ClosureReload.
|
|
23
|
+
def self.zeitwerk_shaped?(program)
|
|
24
|
+
ClassShape.single_top_level_constant?(program)
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
attr_reader :subjects
|
|
20
28
|
|
|
21
|
-
def initialize(file, skip_lines: Set.new)
|
|
29
|
+
def initialize(file, skip_lines: Set.new, class_level: true)
|
|
22
30
|
@file = file
|
|
23
31
|
@skip_lines = skip_lines
|
|
32
|
+
@class_level = class_level
|
|
24
33
|
@stack = []
|
|
25
34
|
@subjects = []
|
|
26
35
|
@sclass_depth = 0
|
|
@@ -33,13 +42,19 @@ module ActiveMutator
|
|
|
33
42
|
def visit_class_node(node)
|
|
34
43
|
return if @sclass_depth.positive?
|
|
35
44
|
|
|
36
|
-
with_scope(node.constant_path.slice)
|
|
45
|
+
with_scope(node.constant_path.slice) do
|
|
46
|
+
add_class_body_subject(node)
|
|
47
|
+
super
|
|
48
|
+
end
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
def visit_module_node(node)
|
|
40
52
|
return if @sclass_depth.positive?
|
|
41
53
|
|
|
42
|
-
with_scope(node.constant_path.slice)
|
|
54
|
+
with_scope(node.constant_path.slice) do
|
|
55
|
+
add_class_body_subject(node)
|
|
56
|
+
super
|
|
57
|
+
end
|
|
43
58
|
end
|
|
44
59
|
|
|
45
60
|
# `class << self` inside a constant scope: defs there are singleton
|
|
@@ -85,6 +100,32 @@ module ActiveMutator
|
|
|
85
100
|
|
|
86
101
|
private
|
|
87
102
|
|
|
103
|
+
# One subject for the class-level code of this class/module. Only if the
|
|
104
|
+
# body has at least one statement the class-body walk can mutate: defs
|
|
105
|
+
# and nested class/modules are owned by other subjects.
|
|
106
|
+
def add_class_body_subject(node)
|
|
107
|
+
return unless @class_level
|
|
108
|
+
return if @skip_lines.include?(node.location.start_line - 1)
|
|
109
|
+
|
|
110
|
+
body = node.body
|
|
111
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
112
|
+
return if body.body.all? { |s| owned_by_other_subject?(s) }
|
|
113
|
+
|
|
114
|
+
scope = @stack.join("::")
|
|
115
|
+
loc = node.location
|
|
116
|
+
@subjects << Subject.new(
|
|
117
|
+
name: "#{scope} (class body)",
|
|
118
|
+
file: @file,
|
|
119
|
+
byte_range: loc.start_offset...loc.end_offset,
|
|
120
|
+
line_range: loc.start_line..loc.end_line,
|
|
121
|
+
constant_scope: scope,
|
|
122
|
+
kind: :class_body,
|
|
123
|
+
sclass: false
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def owned_by_other_subject?(node) = ClassShape.owned_by_other_subject?(node)
|
|
128
|
+
|
|
88
129
|
def with_scope(name)
|
|
89
130
|
@stack.push(name)
|
|
90
131
|
yield
|
|
@@ -2,12 +2,32 @@ require "json"
|
|
|
2
2
|
require "set"
|
|
3
3
|
|
|
4
4
|
module ActiveMutator
|
|
5
|
-
# Runs INSIDE a fork.
|
|
6
|
-
# files,
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# the
|
|
5
|
+
# Runs INSIDE a fork. Insertion order relative to RSpec's setup (which loads
|
|
6
|
+
# the spec files, and with them the app) DIFFERS by mutant kind:
|
|
7
|
+
#
|
|
8
|
+
# Class-body: insert BEFORE setup. `RSpec.describe SomeClass` binds
|
|
9
|
+
# `metadata[:described_class]` to the constant AT LOAD TIME, and a class-body
|
|
10
|
+
# mutant reloads the constant to a NEW object via ClosureReload; a group
|
|
11
|
+
# loaded first would keep the pre-mutation object and falsely survive. So we
|
|
12
|
+
# require the subject file, reload, THEN let setup load the groups — every
|
|
13
|
+
# group binds to the mutated object.
|
|
14
|
+
#
|
|
15
|
+
# Def: insert AFTER setup. A def mutant class_evals the live method in
|
|
16
|
+
# place, so it must be the LAST thing to touch that method. Inserting before
|
|
17
|
+
# setup let a file loaded during spec-load (a concern/decorator/monkeypatch
|
|
18
|
+
# that reopens the class but isn't transitively required by the subject
|
|
19
|
+
# file) silently redefine the method back to the original, reporting a false
|
|
20
|
+
# survivor. Loading everything first, then inserting, closes that window.
|
|
21
|
+
# Requiring the subject after setup also means spec_helper's load-time setup
|
|
22
|
+
# runs first, so a subject that depends on it still loads in non-preloaded
|
|
23
|
+
# projects.
|
|
24
|
+
#
|
|
25
|
+
# The explicit `require` of the subject file guarantees the target constant
|
|
26
|
+
# exists before insertion regardless of preload: preloaded projects
|
|
27
|
+
# (Rails/Zeitwerk, or a preloaded spec helper) already have it in
|
|
28
|
+
# $LOADED_FEATURES so it's a no-op, while non-preloaded projects (plain
|
|
29
|
+
# gems whose spec files require the lib themselves, or --no-preload-helper)
|
|
30
|
+
# get it loaded rather than relying on spec-load to define it.
|
|
11
31
|
class Worker
|
|
12
32
|
def self.run(mutation, example_ids, writer)
|
|
13
33
|
new(mutation, example_ids, writer).run
|
|
@@ -23,20 +43,42 @@ module ActiveMutator
|
|
|
23
43
|
require "rspec/core"
|
|
24
44
|
devnull = File.open(File::NULL, "w")
|
|
25
45
|
runner = RSpec::Core::Runner.new(RSpec::Core::ConfigurationOptions.new(@example_ids))
|
|
26
|
-
|
|
46
|
+
if @mutation.subject.class_body?
|
|
47
|
+
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
48
|
+
insert_mutation # BEFORE setup: groups bind described_class to the mutated object
|
|
49
|
+
runner.setup(devnull, devnull) # loads spec files
|
|
50
|
+
else
|
|
51
|
+
runner.setup(devnull, devnull) # loads spec files -> the app, in dependency order
|
|
52
|
+
require @mutation.subject.file # no-op if already loaded; guarantees the constant exists
|
|
53
|
+
insert_mutation # AFTER load: nothing left can redefine the method back
|
|
54
|
+
end
|
|
27
55
|
# One failure kills the mutant; running the rest of the covering set
|
|
28
56
|
# is pure waste inside the fork.
|
|
29
57
|
RSpec.configuration.fail_fast = 1
|
|
30
|
-
Inserter.new.insert(@mutation) # now the target constant exists
|
|
31
58
|
after_fork_hygiene
|
|
32
59
|
code = runner.run_specs(covering_groups)
|
|
33
60
|
emit(code.zero? ? "survived" : "killed")
|
|
61
|
+
rescue ClosureReload::Skip => e
|
|
62
|
+
emit("skipped", details: e.message)
|
|
63
|
+
rescue ClosureReload::MutantLoadError => e
|
|
64
|
+
# The mutation made the class unloadable; a real suite would fail on it.
|
|
65
|
+
emit("killed", details: "mutated class failed to load: #{e.message}")
|
|
34
66
|
rescue StandardError, ScriptError => e
|
|
35
67
|
emit("error", details: "#{e.class}: #{e.message}")
|
|
36
68
|
end
|
|
37
69
|
|
|
38
70
|
private
|
|
39
71
|
|
|
72
|
+
# Def mutants class_eval over the live constant; class-body mutants
|
|
73
|
+
# cannot (macros accumulate) and go through whole-file closure reload.
|
|
74
|
+
def insert_mutation
|
|
75
|
+
if @mutation.subject.class_body?
|
|
76
|
+
ClosureReload.new(@mutation.subject, @mutation.mutated_file_source).call
|
|
77
|
+
else
|
|
78
|
+
Inserter.new.insert(@mutation)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
40
82
|
def after_fork_hygiene
|
|
41
83
|
srand
|
|
42
84
|
if defined?(ActiveRecord::Base)
|
data/lib/active_mutator.rb
CHANGED
|
@@ -10,6 +10,7 @@ end
|
|
|
10
10
|
require_relative "active_mutator/edit"
|
|
11
11
|
require_relative "active_mutator/splicer"
|
|
12
12
|
require_relative "active_mutator/subject"
|
|
13
|
+
require_relative "active_mutator/class_shape"
|
|
13
14
|
require_relative "active_mutator/subject_finder"
|
|
14
15
|
require_relative "active_mutator/subject_matcher"
|
|
15
16
|
require_relative "active_mutator/operators/base"
|
|
@@ -30,6 +31,7 @@ require_relative "active_mutator/baseline"
|
|
|
30
31
|
require_relative "active_mutator/baseline_delta"
|
|
31
32
|
require_relative "active_mutator/defined_constants"
|
|
32
33
|
require_relative "active_mutator/inserter"
|
|
34
|
+
require_relative "active_mutator/closure_reload"
|
|
33
35
|
require_relative "active_mutator/worker"
|
|
34
36
|
require_relative "active_mutator/result"
|
|
35
37
|
require_relative "active_mutator/work_item"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_mutator
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel John
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: prism
|
|
@@ -17,6 +17,9 @@ dependencies:
|
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
19
|
version: '0.30'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '2'
|
|
20
23
|
type: :runtime
|
|
21
24
|
prerelease: false
|
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -24,18 +27,21 @@ dependencies:
|
|
|
24
27
|
- - ">="
|
|
25
28
|
- !ruby/object:Gem::Version
|
|
26
29
|
version: '0.30'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2'
|
|
27
33
|
- !ruby/object:Gem::Dependency
|
|
28
34
|
name: rspec-core
|
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
|
30
36
|
requirements:
|
|
31
|
-
- - "
|
|
37
|
+
- - "~>"
|
|
32
38
|
- !ruby/object:Gem::Version
|
|
33
39
|
version: '3.12'
|
|
34
40
|
type: :runtime
|
|
35
41
|
prerelease: false
|
|
36
42
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
43
|
requirements:
|
|
38
|
-
- - "
|
|
44
|
+
- - "~>"
|
|
39
45
|
- !ruby/object:Gem::Version
|
|
40
46
|
version: '3.12'
|
|
41
47
|
- !ruby/object:Gem::Dependency
|
|
@@ -72,7 +78,9 @@ files:
|
|
|
72
78
|
- lib/active_mutator/baseline.rb
|
|
73
79
|
- lib/active_mutator/baseline_delta.rb
|
|
74
80
|
- lib/active_mutator/baseline_hooks.rb
|
|
81
|
+
- lib/active_mutator/class_shape.rb
|
|
75
82
|
- lib/active_mutator/cli.rb
|
|
83
|
+
- lib/active_mutator/closure_reload.rb
|
|
76
84
|
- lib/active_mutator/config.rb
|
|
77
85
|
- lib/active_mutator/config_file.rb
|
|
78
86
|
- lib/active_mutator/coverage_map.rb
|
|
@@ -114,7 +122,6 @@ licenses:
|
|
|
114
122
|
- MIT
|
|
115
123
|
metadata:
|
|
116
124
|
rubygems_mfa_required: 'true'
|
|
117
|
-
homepage_uri: https://github.com/drj613/active_mutator
|
|
118
125
|
source_code_uri: https://github.com/drj613/active_mutator
|
|
119
126
|
changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
|
|
120
127
|
bug_tracker_uri: https://github.com/drj613/active_mutator/issues
|