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.
@@ -10,9 +10,11 @@ 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
- map = Baseline.new(root: @config.root).coverage_map(force: @config.force_baseline)
16
+ map = Baseline.new(root: @config.root, spec_paths: @config.spec_paths)
17
+ .coverage_map(force: @config.force_baseline)
16
18
  @reporter.coverage_map = map if @reporter.respond_to?(:coverage_map=)
17
19
  subjects = discover_subjects
18
20
  analyses = subjects.map { |s| Engine.new.analyze(s) }
@@ -25,7 +27,7 @@ module ActiveMutator
25
27
  scanned_files = prune_scope(subjects)
26
28
  warn_stale(ledger, fingerprints.values, scanned_files)
27
29
 
28
- items, pre_results = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
30
+ items, pre_results, phase1_ids = plan_work(mutations, map, ledger: ledger, fingerprints: fingerprints)
29
31
  return debug_plan(items, pre_results) if @config.debug_plan
30
32
 
31
33
  pre_results.each { |r| @reporter.on_result(r) }
@@ -35,6 +37,8 @@ module ActiveMutator
35
37
  scheduler = Scheduler.new(jobs: @config.jobs, on_result: @reporter.method(:on_result),
36
38
  calibrators: calibrators)
37
39
  results = scheduler.run(items) + pre_results
40
+ # Phase 2 runs on its own scheduler (built lazily inside), so pass nil.
41
+ results = escalate_class_body_survivors(results, nil, map, phase1_ids: phase1_ids)
38
42
 
39
43
  accept_survivors!(ledger, results, fingerprints, scanned_files) if @config.accept_survivors
40
44
 
@@ -42,7 +46,9 @@ module ActiveMutator
42
46
  exit_code(results)
43
47
  end
44
48
 
45
- # Returns [work_items, pre_results]. Public for unit testing.
49
+ # Returns [work_items, pre_results, phase1_ids]. phase1_ids maps each
50
+ # planned mutation to the example ids it was scheduled against, so phase 2
51
+ # escalation can subtract what was already run. Public for unit testing.
46
52
  def plan_work(mutations, map, ledger: nil, fingerprints: {})
47
53
  items = []
48
54
  pre_results = []
@@ -51,19 +57,75 @@ module ActiveMutator
51
57
  pre_results << Result.new(mutation: mutation, status: :accepted, details: nil)
52
58
  next
53
59
  end
54
- example_ids = map.examples_for(mutation.subject.file, coverage_lines(mutation))
60
+ example_ids = examples_for_mutation(mutation, map)
55
61
  if example_ids.empty?
56
62
  pre_results << Result.new(mutation: mutation, status: :uncovered, details: nil)
57
63
  else
58
- lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
59
- variable = map.time_for(example_ids) * @config.timeout_factor
60
- boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
61
- timeout = variable + @config.timeout_floor + boot_extra
62
- items << WorkItem.new(mutation: mutation, example_ids: example_ids,
63
- timeout: timeout, lane: lane, variable: variable)
64
+ items << build_work_item(mutation, example_ids, map)
65
+ end
66
+ end
67
+ phase1_ids = items.to_h { |i| [i.mutation, i.example_ids] }
68
+ [items, pre_results, phase1_ids]
69
+ end
70
+
71
+ # Phase 2 of the class-body kill pipeline (public for unit testing).
72
+ # A class-body survivor is only DECLARED after every spec file that
73
+ # references the constant has had its shot: re-enqueue against the
74
+ # referencing files phase 1 didn't run, and take the escalated verdict.
75
+ #
76
+ # `scheduler` is injectable for unit tests; in the normal run it is nil and
77
+ # a dedicated escalation scheduler is built lazily (only when there is
78
+ # phase-2 work) with NO on_result — escalation is a refinement pass, and
79
+ # reporting through the live callback would print a second status char for a
80
+ # mutant already streamed in phase 1. The final summary reflects the
81
+ # escalated verdicts regardless.
82
+ def escalate_class_body_survivors(results, scheduler, map, phase1_ids:)
83
+ candidates = results.select { |r| r.status == :survived && r.mutation.subject.class_body? }
84
+ # Perf gate: skip reading the whole spec suite into memory in the common
85
+ # case of no class-body survivors. (Deleting this line is a behavioral
86
+ # no-op — the later `items.empty?` return still guards correctness — so
87
+ # its mutant is a known equivalent.)
88
+ return results if candidates.empty?
89
+
90
+ spec_contents = BaselineDelta.spec_file_contents(root: @config.root, spec_paths: @config.spec_paths)
91
+ patterns = {} # subject file => constant-reference pattern (parsed once per file)
92
+ items = {}
93
+ candidates.each do |r|
94
+ file = r.mutation.subject.file
95
+ pattern = patterns.fetch(file) do
96
+ patterns[file] = BaselineDelta.constant_reference_pattern(File.read(file))
97
+ end
98
+ next unless pattern
99
+
100
+ ids = escalation_examples(map, spec_contents, phase1_ids.fetch(r.mutation, []), pattern)
101
+ next if ids.empty?
102
+
103
+ items[r.mutation] = build_work_item(r.mutation, ids, map)
104
+ end
105
+ return results if items.empty?
106
+
107
+ scheduler ||= Scheduler.new(jobs: @config.jobs)
108
+ escalated = scheduler.run(items.values).to_h { |res| [res.mutation, res] }
109
+ results.map do |r|
110
+ # A replacement only ever exists for a survived candidate (items is
111
+ # built solely from those), so no redundant status re-check is needed.
112
+ replacement = escalated[r.mutation]
113
+ next r unless replacement
114
+
115
+ case replacement.status
116
+ when :killed
117
+ replacement
118
+ when :survived
119
+ extra = items[r.mutation].example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq.size
120
+ replacement.with(details: "escalated (+#{extra} spec files)")
121
+ else
122
+ # A timeout/error/skip in phase 2 did NOT prove a kill — the mutant
123
+ # already survived phase 1, so keep that verdict rather than letting
124
+ # an inconclusive escalation inflate the score (a :timeout counts as
125
+ # detected in exit_code/score).
126
+ r
64
127
  end
65
128
  end
66
- [items, pre_results]
67
129
  end
68
130
 
69
131
  def exit_code(results)
@@ -78,12 +140,45 @@ module ActiveMutator
78
140
 
79
141
  private
80
142
 
143
+ # Single source of truth for lane/timeout/variable derivation, shared by
144
+ # phase-1 planning and phase-2 escalation so the two never drift.
145
+ def build_work_item(mutation, example_ids, map)
146
+ lane = example_ids.any? { |id| serial_example?(id) } ? :serial : :parallel
147
+ variable = map.time_for(example_ids) * @config.timeout_factor
148
+ boot_extra = lane == :serial ? @config.browser_boot_seconds : 0.0
149
+ timeout = variable + @config.timeout_floor + boot_extra
150
+ WorkItem.new(mutation: mutation, example_ids: example_ids,
151
+ timeout: timeout, lane: lane, variable: variable)
152
+ end
153
+
154
+ # Spec files that textually match `pattern` (a constant-reference pattern
155
+ # for the subject's file, built via BaselineDelta.constant_reference_pattern
156
+ # so the escaping/word-boundary rules stay shared), minus everything phase 1
157
+ # already ran; returned as example ids.
158
+ #
159
+ # Two deliberate choices: (a) matching is TEXTUAL, so a constant named in a
160
+ # comment or string still counts — intentional, since the worst case is a
161
+ # wasted run and the verdict stays correct; (b) unlike
162
+ # BaselineDelta.newly_covering_candidates there is intentionally NO fan-out
163
+ # ceiling here — a class-body survivor gets every referencing spec its shot
164
+ # before being declared.
165
+ def escalation_examples(map, spec_contents, phase1_example_ids, pattern)
166
+ phase1_files = phase1_example_ids.map { |id| BaselineDelta.spec_file_of(id) }.uniq
167
+ spec_contents.filter_map do |abs, content|
168
+ rel = abs.delete_prefix(@config.root.chomp("/") + "/")
169
+ next if phase1_files.include?(rel)
170
+ next unless content.match?(pattern)
171
+
172
+ map.examples_for_spec_file(rel)
173
+ end.flatten.uniq.sort
174
+ end
175
+
81
176
  # Custom operators must exist in the PARENT before Engine analysis:
82
177
  # subclassing Operators::Base self-registers, and forks inherit the
83
178
  # loaded class. `requires` can't serve — those load inside the fork's
84
179
  # setup, after mutations are already planned.
85
180
  def load_operators
86
- @config.operator_paths.each do |f|
181
+ @config.operators.each do |f|
87
182
  require File.expand_path(f, @config.root)
88
183
  rescue LoadError, SyntaxError => e
89
184
  raise Error, "operator file not loadable: #{f}: #{e.message}"
@@ -98,6 +193,25 @@ module ActiveMutator
98
193
  mutation.lines.to_a | mutation.subject.line_range.to_a
99
194
  end
100
195
 
196
+ # Class-body lines execute at load time, so line coverage never
197
+ # attributes examples to them. Substitute: every example that covers ANY
198
+ # line of the file (it must have loaded the class), plus the convention
199
+ # spec file's examples. Phase 2 (escalation) widens further before a
200
+ # survivor is declared.
201
+ def examples_for_mutation(mutation, map)
202
+ return map.examples_for(mutation.subject.file, coverage_lines(mutation)) unless mutation.subject.class_body?
203
+
204
+ convention_examples = convention_spec_rels(mutation.subject.file)
205
+ .flat_map { |rel| map.examples_for_spec_file(rel) }
206
+ (map.examples_covering_file(mutation.subject.file) | convention_examples).sort
207
+ end
208
+
209
+ def convention_spec_rels(file)
210
+ rel = file.delete_prefix(@config.root.chomp("/") + "/").delete_suffix(".rb")
211
+ rest = rel.sub(%r{\A[^/]+/}, "")
212
+ @config.spec_paths.map { |sp| "#{sp}/#{rest}_spec.rb" }
213
+ end
214
+
101
215
  def build_reporter
102
216
  case @config.format
103
217
  when :json then Reporter::Json.new
@@ -126,6 +240,7 @@ module ActiveMutator
126
240
  .uniq
127
241
  .reject { |file| excluded?(file) }
128
242
  .sort.flat_map { |file| SubjectFinder.call(file) }
243
+ subjects = subjects.reject(&:class_body?) unless @config.class_level
129
244
  if @config.subject_filter
130
245
  matcher = SubjectMatcher.new(@config.subject_filter)
131
246
  subjects = subjects.select { |s| matcher.match?(s.name) }
@@ -180,7 +295,11 @@ module ActiveMutator
180
295
  helper = if @config.preload_helper
181
296
  File.expand_path(@config.preload_helper, @config.root)
182
297
  else
183
- %w[spec/rails_helper.rb spec/spec_helper.rb]
298
+ # Precedence: within each spec path rails_helper wins over
299
+ # spec_helper; earlier spec paths win over later ones — same
300
+ # as today for the default ["spec"].
301
+ @config.spec_paths
302
+ .flat_map { |sp| ["#{sp}/rails_helper.rb", "#{sp}/spec_helper.rb"] }
184
303
  .map { |p| File.join(@config.root, p) }
185
304
  .find { |p| File.exist?(p) }
186
305
  end
@@ -210,8 +329,11 @@ module ActiveMutator
210
329
  # MAINTENANCE: any future flag that narrows the mutant set below "every
211
330
  # subject in the scanned files" MUST be added to this nil-trigger list,
212
331
  # or scoped accept runs will clobber out-of-scope ledger entries (#24).
332
+ # --no-class-level drops every class_body subject (discover_subjects), so a
333
+ # file's class-body fingerprint is absent even though the file is scanned;
334
+ # without this guard its accepted ledger entry looks stale and gets pruned.
213
335
  def prune_scope(subjects)
214
- return nil if @config.subject_filter || @config.since || @config.max_mutants
336
+ return nil if @config.subject_filter || @config.since || @config.max_mutants || !@config.class_level
215
337
 
216
338
  subjects.map { |s| s.file.delete_prefix("#{@config.root}/") }.uniq
217
339
  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) { super }
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) { super }
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
@@ -1,3 +1,3 @@
1
1
  module ActiveMutator
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  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,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
- runner.setup(devnull, devnull) # loads spec files -> loads the app
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)
@@ -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.2.0
4
+ version: 0.4.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-20 00:00:00.000000000 Z
11
+ date: 2026-08-10 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