Class: ActiveMutator::ClosureReload

Inherits:
Object
  • Object
show all
Defined in:
lib/active_mutator/closure_reload.rb

Overview

Fork-side insertion for class-body mutants. A def mutant can be class_eval'd over the live constant; class-level code cannot (re-running validates ADDS a validator, it doesn't replace one). So: remove the constant and re-eval the whole mutated file. Anything already attached to the OLD object — classes that include the module, subclasses, extend sites — would go stale, so they are removed and re-evaled too (pristine sources), dependency-first. The fork dies after the run; nothing is restored.

Every guard failure raises Skip; the Worker reports the mutant as skipped with the reason. Skipping is honest: a mutant we cannot insert faithfully must not be counted as survived OR killed.

Known limitations (inherent to remove-and-reload; accepted trade-offs):

- References that hold the target BY VALUE rather than by ancestry are
not discovered and keep pointing at the pre-remove_const object:
aliases (`ALIAS = MyClass`), arrays/registries the target was pushed
into, memoized instances, class variables captured at load time.
- `refine`-based modules are anonymous and do not appear in normal
`ancestors`, so refinements of the target are not discovered/reloaded.
- Re-evaling the target AND each attacher re-runs their class-body side
effects. Non-idempotent load-time effects (self-registration into a
global registry, DescendantsTracker-style hooks) therefore run twice; a
spec asserting such a count can see it doubled (false kill) or masked
(false survival).
- The re-eval order pins the target first (every attacher depends on it)
then sorts the rest by instance-ancestor depth. An `extend`
relationship BETWEEN two non-target attachers can still re-eval out of
order (rare); a full topological sort is deliberately not attempted.

Constant Summary collapse

Skip =
Class.new(StandardError)
MutantLoadError =

The MUTATED target source could not be re-evaled — the mutation broke the class so it no longer loads. Every covering spec loads the class, so the suite would fail: the Worker maps this to a kill, not an error.

Class.new(StandardError)
DEFAULT_CAP =
10

Class Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(subject, mutated_source) ⇒ ClosureReload

Returns a new instance of ClosureReload.



48
49
50
51
# File 'lib/active_mutator/closure_reload.rb', line 48

def initialize(subject, mutated_source)
  @subject = subject
  @mutated_source = mutated_source
end

Class Attribute Details

.capObject



45
# File 'lib/active_mutator/closure_reload.rb', line 45

def cap = @cap || DEFAULT_CAP

Instance Method Details

#call(cap: self.class.cap) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/active_mutator/closure_reload.rb', line 53

def call(cap: self.class.cap)
  target = resolve_target
  closure = compute_closure(target)
  if closure.size > cap
    raise Skip, "reload closure (#{closure.size} constants) exceeds cap (#{cap})"
  end

  # Re-eval must load dependencies before dependents. Every closure member
  # carries the target directly (single-pass discovery invariant), so the
  # target must be re-eval'd first — pin it to the front. `ancestors.size`
  # can't do this alone: an `extend` puts the target in the extender's
  # SINGLETON ancestry, so a module that extends the target has an instance
  # ancestry SMALLER than the target's and would sort before it (NameError
  # on re-eval). For the remaining attachers, ascending instance-ancestor
  # depth is a valid order among include/subclass relationships (a
  # superclass before its subclass, an included module before its
  # includer); depth is captured while the constants are still live, since
  # it can't be read after remove_const.
  rest = closure.reject { |m| m.equal?(target) }
  ordered = [target, *rest.sort_by { |m| m.ancestors.size }]
  sources = ordered.map { |mod| [mod.name, source_for(mod)] }
  sources.each { |name, _| remove_constant(name) }
  sources.each_with_index do |(_, (file, src)), idx|
    eval(src, TOPLEVEL_BINDING, file, 1) # rubocop:disable Security/Eval
  rescue ScriptError, StandardError => e
    # idx.zero? is the MUTATED target, which by construction depends on
    # nothing else in the closure (every other member carries the target,
    # not vice versa). Its source failing to load is therefore the
    # mutation's own doing — a kill, not a tool error.
    #
    # A PRISTINE dependent (idx > 0) failing means the ancestry-depth order
    # couldn't satisfy a cross-attacher reference (see the ordering note
    # above): we can't faithfully reinstate the closure, so this is an
    # honest Skip, not a survived/killed verdict and not a bare error.
    raise MutantLoadError, e.message if idx.zero?

    raise Skip, "reload re-eval failed (#{e.message}); closure could not be reinstated in dependency order"
  end
  nil
end