active_mutator 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +156 -13
  3. data/lib/active_mutator/accepted_ledger.rb +22 -7
  4. data/lib/active_mutator/baseline_delta.rb +78 -1
  5. data/lib/active_mutator/class_shape.rb +47 -0
  6. data/lib/active_mutator/cli.rb +17 -4
  7. data/lib/active_mutator/closure_reload.rb +202 -0
  8. data/lib/active_mutator/config.rb +3 -1
  9. data/lib/active_mutator/config_file.rb +92 -0
  10. data/lib/active_mutator/defined_constants.rb +48 -0
  11. data/lib/active_mutator/edit.rb +8 -2
  12. data/lib/active_mutator/engine.rb +121 -7
  13. data/lib/active_mutator/inserter.rb +6 -3
  14. data/lib/active_mutator/operators/base.rb +2 -1
  15. data/lib/active_mutator/operators/call_swap.rb +16 -0
  16. data/lib/active_mutator/operators/literal.rb +14 -2
  17. data/lib/active_mutator/reporter/github.rb +36 -0
  18. data/lib/active_mutator/reporter/json.rb +1 -0
  19. data/lib/active_mutator/reporter/operator_stats.rb +20 -0
  20. data/lib/active_mutator/reporter/stryker_json.rb +128 -0
  21. data/lib/active_mutator/reporter/terminal.rb +24 -1
  22. data/lib/active_mutator/result.rb +1 -1
  23. data/lib/active_mutator/runner.rb +239 -19
  24. data/lib/active_mutator/scheduler.rb +41 -5
  25. data/lib/active_mutator/source_location.rb +21 -0
  26. data/lib/active_mutator/subject.rb +13 -3
  27. data/lib/active_mutator/subject_finder.rb +89 -9
  28. data/lib/active_mutator/subject_matcher.rb +23 -0
  29. data/lib/active_mutator/timeout_calibrator.rb +75 -0
  30. data/lib/active_mutator/version.rb +1 -1
  31. data/lib/active_mutator/work_item.rb +8 -1
  32. data/lib/active_mutator/worker.rb +53 -8
  33. data/lib/active_mutator.rb +10 -0
  34. metadata +20 -5
@@ -1,36 +1,88 @@
1
+ require "set"
2
+
1
3
  module ActiveMutator
2
4
  class SubjectFinder < Prism::Visitor
5
+ SKIP_MARKER = /#\s*active_mutator:\s*skip\b/
6
+
3
7
  def self.call(file)
4
8
  result = Prism.parse(File.read(file))
5
9
  return [] unless result.success?
6
10
 
7
- finder = new(file)
11
+ skip_lines = result.comments
12
+ .select { |c| c.slice.match?(SKIP_MARKER) }
13
+ .to_set { |c| c.location.start_line }
14
+ finder = new(file, skip_lines: skip_lines,
15
+ class_level: zeitwerk_shaped?(result.value))
8
16
  finder.visit(result.value)
9
17
  finder.subjects
10
18
  end
11
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
+
12
27
  attr_reader :subjects
13
28
 
14
- def initialize(file)
29
+ def initialize(file, skip_lines: Set.new, class_level: true)
15
30
  @file = file
31
+ @skip_lines = skip_lines
32
+ @class_level = class_level
16
33
  @stack = []
17
34
  @subjects = []
35
+ @sclass_depth = 0
18
36
  super()
19
37
  end
20
38
 
39
+ # Classes/modules declared inside `class << self` hang their constant on
40
+ # the SINGLETON class, so a lexically-joined scope like "Foo::Bar" is not
41
+ # reachable via Object.const_get — Inserter would crash. Skipped entirely.
21
42
  def visit_class_node(node)
22
- with_scope(node.constant_path.slice) { super }
43
+ return if @sclass_depth.positive?
44
+
45
+ with_scope(node.constant_path.slice) do
46
+ add_class_body_subject(node)
47
+ super
48
+ end
23
49
  end
24
50
 
25
51
  def visit_module_node(node)
26
- with_scope(node.constant_path.slice) { super }
52
+ return if @sclass_depth.positive?
53
+
54
+ with_scope(node.constant_path.slice) do
55
+ add_class_body_subject(node)
56
+ super
57
+ end
27
58
  end
28
59
 
29
- # `class << self` bodies are a documented v1 limit: not visited.
30
- def visit_singleton_class_node(node); end
60
+ # `class << self` inside a constant scope: defs there are singleton
61
+ # methods of the enclosing constant. `class << obj` and a top-level
62
+ # `class << self` (no constant to hang the method on) stay skipped.
63
+ def visit_singleton_class_node(node)
64
+ return unless node.expression.is_a?(Prism::SelfNode) && !@stack.empty?
65
+
66
+ @sclass_depth += 1
67
+ begin
68
+ super
69
+ ensure
70
+ @sclass_depth -= 1
71
+ end
72
+ end
73
+
74
+ # Defs inside blocks (`Data.define do ... end`, `class_eval do ... end`)
75
+ # do not live on the enclosing constant scope, so Inserter would redefine
76
+ # them on the wrong constant and every mutant would falsely survive.
77
+ # Same v1 limit as `class << self`: not visited. Note this also hides
78
+ # classes/modules defined inside blocks (accepted v1 limit).
79
+ def visit_block_node(node); end
31
80
 
32
81
  def visit_def_node(node)
33
- singleton = node.receiver.is_a?(Prism::SelfNode)
82
+ return if @skip_lines.include?(node.location.start_line - 1)
83
+
84
+ sclass = @sclass_depth.positive?
85
+ singleton = sclass || node.receiver.is_a?(Prism::SelfNode)
34
86
  scope = @stack.empty? ? nil : @stack.join("::")
35
87
  loc = node.location
36
88
  @subjects << Subject.new(
@@ -39,13 +91,41 @@ module ActiveMutator
39
91
  byte_range: loc.start_offset...loc.end_offset,
40
92
  line_range: loc.start_line..loc.end_line,
41
93
  constant_scope: scope,
42
- kind: singleton ? :singleton : :instance
94
+ kind: singleton ? :singleton : :instance,
95
+ sclass: sclass
43
96
  )
44
- # No `super`: nested defs are out of scope for v1.
97
+ # No `super`: nested defs get no subject of their own -- their bodies
98
+ # are mutated via the OUTER def (Engine#walk descends into them).
45
99
  end
46
100
 
47
101
  private
48
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
+
49
129
  def with_scope(name)
50
130
  @stack.push(name)
51
131
  yield
@@ -0,0 +1,23 @@
1
+ module ActiveMutator
2
+ # Tiny subject-expression grammar for --subject:
3
+ # Foo::Bar#baz exact Foo::Bar all methods of the constant
4
+ # Foo::Bar* namespace Foo::Bar#* instance-only Foo::Bar.* singleton-only
5
+ class SubjectMatcher
6
+ def initialize(expression)
7
+ @regexp = compile(expression)
8
+ end
9
+
10
+ def match?(name) = @regexp.match?(name)
11
+
12
+ private
13
+
14
+ def compile(expr)
15
+ case expr
16
+ when /\A(.+)([#.])\*\z/ then /\A#{Regexp.escape($1)}#{Regexp.escape($2)}[^#.]+\z/
17
+ when /\A(.+)\*\z/ then /\A#{Regexp.escape($1)}/
18
+ when /[#.]/ then /\A#{Regexp.escape(expr)}\z/
19
+ else /\A#{Regexp.escape(expr)}[#.][^#.]+\z/
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,75 @@
1
+ module ActiveMutator
2
+ # Adaptive timeout budgets (#9). Static budgets derive from baseline times
3
+ # measured warm and unloaded; under parallel load they misclassify
4
+ # slow-but-honest kills as timeouts. The Scheduler feeds this with the
5
+ # observed wall time of every KILLED fork (errors exit artificially fast,
6
+ # survivors run their whole covering set — both would bias the median;
7
+ # timed-out forks have no known wall time at all). One instance per lane:
8
+ # parallel and serial run under different load regimes.
9
+ #
10
+ # Utilization is elapsed / the EFFECTIVE budget the fork ran under (the
11
+ # value budget_for returned at its spawn), not the static item.timeout —
12
+ # otherwise a pinned-high scale could never observe recovery (ratchet).
13
+ # Once WARMUP observations exist, remaining budgets' variable part is
14
+ # scaled by the clamped median utilization. The fixed part (timeout_floor
15
+ # + browser boot) is never scaled: fork boot cost does not shrink because
16
+ # examples run fast.
17
+ #
18
+ # The scale is grow-only (MIN_SCALE = 1.0): budgets may only extend beyond
19
+ # the static value, never fall below it. Downscaling has no recovery signal
20
+ # because timeouts are CENSORED samples — the scheduler skips them (they have
21
+ # no known wall time), so only killed forks feed the window. A low median
22
+ # utilization would shrink the budget, causing a legitimately slow kill to be
23
+ # reaped as a timeout; that censored kill never enters the window, so nothing
24
+ # ever pushes utilization back up. The shrink self-sustains — an asymmetric
25
+ # ratchet. Growing is safe (a too-large budget still records a real kill and
26
+ # relaxes); shrinking is not, so we forbid it.
27
+ class TimeoutCalibrator
28
+ WARMUP = 5
29
+ # A real sliding window, not full-run history: a 500-mutant run's early
30
+ # load regime must age out completely instead of anchoring the median
31
+ # forever. 30 samples is enough for a stable median and small enough to
32
+ # track load changes within a few dozen finishes.
33
+ WINDOW = 30
34
+ TARGET_UTILIZATION = 0.25
35
+ MIN_SCALE = 1.0
36
+ MAX_SCALE = 4.0
37
+
38
+ def initialize
39
+ @utilizations = []
40
+ end
41
+
42
+ def record(elapsed_seconds, budget)
43
+ return unless budget.positive?
44
+
45
+ @utilizations << elapsed_seconds / budget
46
+ @utilizations.shift while @utilizations.size > WINDOW
47
+ end
48
+
49
+ def warmed? = @utilizations.size >= WARMUP
50
+
51
+ def budget_for(item)
52
+ return item.timeout unless warmed?
53
+
54
+ fixed = item.timeout - item.variable
55
+ item.variable * scale + fixed
56
+ end
57
+
58
+ def scale
59
+ # An empty window has no median; a caller invoking scale without warmed?
60
+ # (median of [] is nil) would otherwise raise. Neutral scale = no change.
61
+ return 1.0 if @utilizations.empty?
62
+
63
+ s = median(@utilizations) / TARGET_UTILIZATION
64
+ s.clamp(MIN_SCALE, MAX_SCALE)
65
+ end
66
+
67
+ private
68
+
69
+ def median(values)
70
+ sorted = values.sort
71
+ mid = sorted.size / 2
72
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
73
+ end
74
+ end
75
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.1.1"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -1,4 +1,11 @@
1
1
  module ActiveMutator
2
2
  # lane: :parallel (default pool) | :serial (browser-covered, one at a time)
3
- WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane)
3
+ # timeout: static total budget (variable + fixed), kept for --debug-plan and compat
4
+ # variable: the baseline-estimate-derived part (estimate * timeout_factor) — the
5
+ # only part the TimeoutCalibrator scales
6
+ WorkItem = Data.define(:mutation, :example_ids, :timeout, :lane, :variable) do
7
+ def initialize(mutation:, example_ids:, timeout:, lane:, variable: 0.0)
8
+ super
9
+ end
10
+ end
4
11
  end
@@ -2,12 +2,32 @@ require "json"
2
2
  require "set"
3
3
 
4
4
  module ActiveMutator
5
- # Runs INSIDE a fork. Order is critical: RSpec's setup phase loads the spec
6
- # files, whose spec_helper/rails_helper loads the application. Only THEN
7
- # can the mutation be inserted over the loaded original. Insert-first would
8
- # NameError on any project not preloaded in the parent (all non-Rails
9
- # projects), and loading app code after insertion would silently restore
10
- # the original method.
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,17 +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
- runner.setup(devnull, devnull) # loads spec files -> loads the app
27
- Inserter.new.insert(@mutation) # now the target constant exists
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
55
+ # One failure kills the mutant; running the rest of the covering set
56
+ # is pure waste inside the fork.
57
+ RSpec.configuration.fail_fast = 1
28
58
  after_fork_hygiene
29
59
  code = runner.run_specs(covering_groups)
30
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}")
31
66
  rescue StandardError, ScriptError => e
32
67
  emit("error", details: "#{e.class}: #{e.message}")
33
68
  end
34
69
 
35
70
  private
36
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
+
37
82
  def after_fork_hygiene
38
83
  srand
39
84
  if defined?(ActiveRecord::Base)
@@ -10,7 +10,9 @@ 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"
15
+ require_relative "active_mutator/subject_matcher"
14
16
  require_relative "active_mutator/operators/base"
15
17
  require_relative "active_mutator/operators/conditional_boundary"
16
18
  require_relative "active_mutator/operators/condition_forcing"
@@ -27,16 +29,24 @@ require_relative "active_mutator/atomic_file"
27
29
  require_relative "active_mutator/coverage_map"
28
30
  require_relative "active_mutator/baseline"
29
31
  require_relative "active_mutator/baseline_delta"
32
+ require_relative "active_mutator/defined_constants"
30
33
  require_relative "active_mutator/inserter"
34
+ require_relative "active_mutator/closure_reload"
31
35
  require_relative "active_mutator/worker"
32
36
  require_relative "active_mutator/result"
33
37
  require_relative "active_mutator/work_item"
38
+ require_relative "active_mutator/timeout_calibrator"
34
39
  require_relative "active_mutator/scheduler"
40
+ require_relative "active_mutator/reporter/operator_stats"
35
41
  require_relative "active_mutator/reporter/terminal"
42
+ require_relative "active_mutator/reporter/github"
36
43
  require_relative "active_mutator/reporter/json"
44
+ require_relative "active_mutator/source_location"
37
45
  require_relative "active_mutator/since_filter"
38
46
  require_relative "active_mutator/fingerprint"
39
47
  require_relative "active_mutator/accepted_ledger"
48
+ require_relative "active_mutator/reporter/stryker_json"
40
49
  require_relative "active_mutator/config"
41
50
  require_relative "active_mutator/runner"
51
+ require_relative "active_mutator/config_file"
42
52
  require_relative "active_mutator/cli"
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.1.1
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-14 00:00:00.000000000 Z
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,9 +78,13 @@ 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
85
+ - lib/active_mutator/config_file.rb
77
86
  - lib/active_mutator/coverage_map.rb
87
+ - lib/active_mutator/defined_constants.rb
78
88
  - lib/active_mutator/edit.rb
79
89
  - lib/active_mutator/engine.rb
80
90
  - lib/active_mutator/fingerprint.rb
@@ -89,15 +99,21 @@ files:
89
99
  - lib/active_mutator/operators/logical_operator.rb
90
100
  - lib/active_mutator/operators/negation_removal.rb
91
101
  - lib/active_mutator/operators/statement_deletion.rb
102
+ - lib/active_mutator/reporter/github.rb
92
103
  - lib/active_mutator/reporter/json.rb
104
+ - lib/active_mutator/reporter/operator_stats.rb
105
+ - lib/active_mutator/reporter/stryker_json.rb
93
106
  - lib/active_mutator/reporter/terminal.rb
94
107
  - lib/active_mutator/result.rb
95
108
  - lib/active_mutator/runner.rb
96
109
  - lib/active_mutator/scheduler.rb
97
110
  - lib/active_mutator/since_filter.rb
111
+ - lib/active_mutator/source_location.rb
98
112
  - lib/active_mutator/splicer.rb
99
113
  - lib/active_mutator/subject.rb
100
114
  - lib/active_mutator/subject_finder.rb
115
+ - lib/active_mutator/subject_matcher.rb
116
+ - lib/active_mutator/timeout_calibrator.rb
101
117
  - lib/active_mutator/version.rb
102
118
  - lib/active_mutator/work_item.rb
103
119
  - lib/active_mutator/worker.rb
@@ -106,7 +122,6 @@ licenses:
106
122
  - MIT
107
123
  metadata:
108
124
  rubygems_mfa_required: 'true'
109
- homepage_uri: https://github.com/drj613/active_mutator
110
125
  source_code_uri: https://github.com/drj613/active_mutator
111
126
  changelog_uri: https://github.com/drj613/active_mutator/blob/main/CHANGELOG.md
112
127
  bug_tracker_uri: https://github.com/drj613/active_mutator/issues