active_mutator 0.2.0 → 0.4.0

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