evilution 0.30.4 → 0.31.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: 2bd9eaca72cb973c6e11841144b8bae68d2a838aef1ff64c429e18b4090505f1
4
- data.tar.gz: bbdedbc5ef66ca367deeec89a53399ce83a235616fe2ac179ed98bf880f8be9b
3
+ metadata.gz: 923190b1b1b35a2e5bad46d1866a0a001bcd6afa48d208f962e73d73af6e7577
4
+ data.tar.gz: 76edef4423129342ce415bead32545894927070d042f615294d5b04df2c6b5ec
5
5
  SHA512:
6
- metadata.gz: b7cf12d8381e05d66b1a2ffcf1c243e3e18d66f8f073de892d40ff139bb6952214ac5c6bdcff3ab026dd475601629790e7c4d5f86f8315a8baf989bcb5b4aa7b
7
- data.tar.gz: 21a523a1f2753c5e70b9fb2d53262d28b169661b75c2bd2ca44ce5f9e5c6234591f6d8dcc935fea8c49a79689d7be5a2883cae6ff4517b711379b80b8d8315bf
6
+ metadata.gz: ff3ec8345986e7ad9b51e7a7ec75ed3b2acbb95eb00c11d1edd3ca0c41163b7a24fa8ba68e82e941608644c4075f50a2734c1c1ee6787ac48f794ae47bfda8ae
7
+ data.tar.gz: 826763c5a82048db39a81a9343f586aac488ecf1910d56bc086456cb5dbe7ee975a6c4de9651ee87f7210a9f26635599db347ee02c6d6ec43623caa151cd5e14
@@ -387,3 +387,9 @@
387
387
  {"id":"int-011584d4","kind":"field_change","created_at":"2026-05-15T15:55:27.437673072Z","actor":"Denis Kiselev","issue_id":"EV-vxgl","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed PR (merged). MutationApplier#mark_feature_loaded registers realpath in $LOADED_FEATURES post-eval — spec require() no longer reloads original. pagy jobs=4: 0% -> 82.81%. Residual gap vs jobs=1 is separate (EV-wu8w in_process inflation)."}}
388
388
  {"id":"int-c44e7652","kind":"field_change","created_at":"2026-05-16T12:45:46.793709029Z","actor":"Denis Kiselev","issue_id":"EV-xfaj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in PR #1260 — run_minitest counts test methods from Minitest::Runnable.runnables registry instead of an evictable reporter"}}
389
389
  {"id":"int-eea19850","kind":"field_change","created_at":"2026-05-16T14:22:49.671269232Z","actor":"Denis Kiselev","issue_id":"EV-wu8w","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in PR #1264 — run_minitest reads verdict from evilution's own per-run SummaryReporter attached after plugin init"}}
390
+ {"id":"int-cf85d538","kind":"field_change","created_at":"2026-05-16T15:40:28.573223389Z","actor":"Denis Kiselev","issue_id":"EV-5dxk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Part A (required guard: 0 dispatched test methods -> :error) shipped in 0.30.3 PR #1257. Acceptance met. Part B (test-unit integration) split into its own epic."}}
391
+ {"id":"int-6efa8613","kind":"field_change","created_at":"2026-05-16T15:55:20.799858411Z","actor":"Denis Kiselev","issue_id":"EV-174x","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and merged 2026-05-12; GH #1194 / #1195 closed. RedefinitionRecovery widened (already registered/initialized/exists); splice approach reverted for memory leak. Bead-close missed at merge time — syncing now."}}
392
+ {"id":"int-ddb8370f","kind":"field_change","created_at":"2026-05-16T15:55:21.07310729Z","actor":"Denis Kiselev","issue_id":"EV-wwx3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed and merged 2026-05-12; GH #1194 / #1195 closed. RedefinitionRecovery widened (already registered/initialized/exists); splice approach reverted for memory leak. Bead-close missed at merge time — syncing now."}}
393
+ {"id":"int-4ea24fb5","kind":"field_change","created_at":"2026-05-16T16:04:54.395246914Z","actor":"Denis Kiselev","issue_id":"EV-s24s","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Already fixed — both named specs (unparseable_short_circuit_spec.rb, neutralization_integration_spec.rb) pass and destructure via execution.results. Stale in_progress; syncing."}}
394
+ {"id":"int-8b699158","kind":"field_change","created_at":"2026-05-16T17:22:07.345408768Z","actor":"Denis Kiselev","issue_id":"EV-kcuf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Proof-of-life canary implemented + merged. Runner::Canary runs a synthetic unobservable mutation at session start; aborts if not scored :survived. --[no-]canary flag, config.canary default true."}}
395
+ {"id":"int-e4dcc2b3","kind":"field_change","created_at":"2026-05-27T04:19:00.738936734Z","actor":"Denis Kiselev","issue_id":"EV-qbd6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fix landed on master in commit 0f1781f (require 'bundler/setup' at bin/evilution-self:16). Verified 2026-05-27: crash dir lib/evilution/result/*.rb scores 91.11% (744 muts, 656 killed, 64 survived, 24 equivalent, 0 errors); canary passes; dual-runtime harness measures crash dirs again."}}
data/.rubocop_todo.yml CHANGED
@@ -8,7 +8,13 @@ Metrics/AbcSize:
8
8
 
9
9
  Metrics/ClassLength:
10
10
  Exclude:
11
+ - "lib/evilution/config.rb"
11
12
  - "lib/evilution/isolation/fork.rb"
12
13
  - "lib/evilution/integration/minitest.rb"
13
14
  - "lib/evilution/mcp/mutate_tool.rb"
15
+ - "lib/evilution/runner.rb"
14
16
  - "lib/evilution/runner/isolation_resolver.rb"
17
+
18
+ Metrics/MethodLength:
19
+ Exclude:
20
+ - "lib/evilution/runner.rb"
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  Versioning policy: see [docs/versioning.md](docs/versioning.md).
4
4
 
5
+ ## [0.31.0] - 2026-05-27
6
+
7
+ ### Added
8
+
9
+ - **Proof-of-life canary at session start** — before any real mutation runs, `Evilution::Runner::Canary` evals a synthetic, guaranteed-unobservable mutation through the configured integration + isolation. A healthy pipeline must score it `:survived`; any other status aborts the run with a diagnostic, so the user never sees a score that was produced by a broken pipeline (autoload mismatch, reporter-plugin eviction, isolation defect, `fail_if_no_examples` config drift, etc.). On by default; toggle with `--[no-]canary` or `canary: true|false` in `.evilution.yml`. Mirrors the configured `--isolation` so isolation-specific defects are caught too (EV-kcuf, PR #1268, GH #1233)
10
+
11
+ ### Fixed
12
+
13
+ - **Mutation children no longer pollute the working tree with files written to relative paths** — every isolator (fork and in_process) now `Dir.chdir`s into a per-mutation sandbox directory for the duration of `test_command.call`, and removes the sandbox on the way out. Any mutation that turns an absolute path into a relative one — `argument_removal` on `File.join(dir, name)`, `method_call_removal` on `File.expand_path(rel, base)`, etc. — used to write `File.write(name, …)` into the parent process's CWD (typically the repo root) and leak past the run; baselining `lib/evilution/runner/canary.rb` deposited 43 stray files (`canary_<pid>_<hex>_spec.rb`, `evilutioncanary_<pid>_<hex>.rb`) before this fix. Spec resolution and source eval anchor project-relative paths to `Evilution::PROJECT_ROOT` (captured at load), so the sandbox CWD does not break `SpecResolver`, `SpecSelector`, `MutationApplier`, `SourceEvaluator`, or the `RSpec::Core::Runner` / `Minitest.load` invocation sites (EV-wqxu, PR #1281, GH #1278)
14
+ - **`MutationApplier` registers the mutation in `$LOADED_FEATURES` BEFORE evaluating the source, not after** — a sibling `require_relative` chain that loops back to the mutated file during the eval itself (e.g. `lib/evilution/mcp/*.rb` tools whose body requires a peer that requires this file back) re-read the *original* source from disk and clobbered the mutation mid-eval. Every such mutation silently survived. The feature-loaded marker now runs immediately before `@source_evaluator.call`, so the in-progress eval is the canonical source any concurrent require sees. Without this, fork workers started from the same pre-`require` snapshot and the whole file scored 0% (EV-ekax, PR #1272, GH #1269)
15
+
5
16
  ## [0.30.4] - 2026-05-16
6
17
 
7
18
  ### Fixed
data/README.md CHANGED
@@ -101,6 +101,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
101
101
  | `--example-targeting-fallback MODE` | String | `full_file` | Behavior when no example matches: `full_file` (run the whole spec file) or `unresolved` (skip the mutation as `:unresolved`). |
102
102
  | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
103
103
  | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
104
+ | `--[no-]canary` | Boolean | _(enabled)_ | Run a proof-of-life synthetic mutation at session start; abort the run if the pipeline misreports it. Catches misconfigured isolation, broken autoload, and reporter-plugin eviction before any real score is produced. Pass `--no-canary` to skip (e.g. CI speed, or when the canary itself is the thing under test). |
104
105
  | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
105
106
  | `-v`, `--verbose` | Boolean | false | Verbose output with RSS memory and GC stats per phase and per mutation; also prints error class, message, and first 5 backtrace lines for errored mutations. |
106
107
  | `--suggest-tests` | Boolean | false | Generate concrete test code in suggestions (RSpec or Minitest, based on `--integration`). |
@@ -172,6 +173,7 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
172
173
  # suggest_tests: false # concrete test code in suggestions (matches integration)
173
174
  # save_session: false # persist results under .evilution/results/
174
175
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails)
176
+ # canary: true # proof-of-life synthetic mutation at session start (false to skip)
175
177
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails
176
178
  # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
177
179
  # show_disabled: false # report mutations skipped by disable comments
data/docs/isolation.md CHANGED
@@ -103,6 +103,33 @@ the MCP server is a long-lived process that handles runs from different
103
103
  projects — preloading one project's Rails stack into a shared process would
104
104
  poison subsequent runs.
105
105
 
106
+ ## Sandboxed working directory
107
+
108
+ Every isolator runs `test_command.call` inside a per-mutation scratch
109
+ directory (`Dir.mktmpdir("evilution-run")`) that is removed on the way out.
110
+ Mutations that turn an absolute path into a relative one — for example
111
+ `argument_removal` on `File.join(dir, name)` collapsing to `File.write(name,
112
+ …)` — would otherwise drop files into the parent process's CWD (typically
113
+ the repo root) and leak past the run.
114
+
115
+ Project-relative paths used by `SpecResolver`, `SpecSelector`, the
116
+ `RSpec::Core::Runner` / `Minitest.load` invocation sites, and the mutated
117
+ file's `__FILE__` in `SourceEvaluator` are anchored at
118
+ `Evilution::PROJECT_ROOT` (captured at load time), so the sandbox CWD does
119
+ not break spec lookup or `require_relative` chains inside the mutant.
120
+
121
+ ## Proof-of-life canary
122
+
123
+ Before any real mutation runs, `Evilution::Runner::Canary` evals a
124
+ synthetic, guaranteed-unobservable mutation through the configured
125
+ integration + isolation. A healthy pipeline must score it `:survived`; any
126
+ other status aborts the run with a diagnostic so the user never sees a
127
+ score produced by a broken pipeline (autoload mismatch, reporter-plugin
128
+ eviction, isolation defect, `fail_if_no_examples` config drift, etc.).
129
+ On by default; toggle with `--[no-]canary` or `canary: true|false` in
130
+ `.evilution.yml`. The canary mirrors the configured `--isolation` so
131
+ isolation-specific defects are caught too.
132
+
106
133
  ## Related flags
107
134
 
108
135
  - `--timeout N` sets the per-mutation time limit. Under `fork`, this drives
@@ -111,5 +138,6 @@ poison subsequent runs.
111
138
  - `--jobs N` runs N workers in parallel. The parallel pool respects the
112
139
  configured isolation strategy, so `--jobs 4 --isolation fork` uses fork
113
140
  isolation per-mutation inside each worker.
141
+ - `--[no-]canary` toggles the proof-of-life pre-flight check (see above).
114
142
 
115
143
  [rails-handle-interrupt]: https://github.com/rails/rails/blob/main/activesupport/lib/active_support/concurrency/thread_monitor.rb
@@ -86,6 +86,11 @@ class Evilution::CLI::Parser::OptionsBuilder
86
86
  "Use --no-incremental to override `incremental: true` from the config file for one run.") do |v|
87
87
  @options[:incremental] = v
88
88
  end
89
+ opts.on("--[no-]canary",
90
+ "Run a proof-of-life synthetic mutation at session start; abort if " \
91
+ "the pipeline misreports it (default: enabled). Use --no-canary to skip.") do |v|
92
+ @options[:canary] = v
93
+ end
89
94
  opts.on("--stdin", "Read target file paths from stdin (one per line)") { @options[:stdin] = true }
90
95
  end
91
96
 
@@ -20,7 +20,7 @@ class Evilution::Config
20
20
  example_targeting_fallback: :full_file,
21
21
  example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
22
22
  quiet_children: false, quiet_children_dir: "tmp/evilution_children",
23
- profile: :default
23
+ profile: :default, canary: true
24
24
  }.freeze
25
25
 
26
26
  attr_reader :target_files, :schema_version, :timeout, :format,
@@ -31,7 +31,7 @@ class Evilution::Config
31
31
  :skip_heredoc_literals, :related_specs_heuristic,
32
32
  :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
33
33
  :example_targeting, :example_targeting_fallback, :example_targeting_cache,
34
- :spec_selector, :quiet_children, :quiet_children_dir, :profile
34
+ :spec_selector, :quiet_children, :quiet_children_dir, :profile, :canary
35
35
 
36
36
  def initialize(**options)
37
37
  skip_file = options.delete(:skip_config_file) ? true : false
@@ -68,6 +68,10 @@ class Evilution::Config
68
68
  baseline
69
69
  end
70
70
 
71
+ def canary?
72
+ canary
73
+ end
74
+
71
75
  def incremental?
72
76
  incremental
73
77
  end
@@ -130,6 +134,11 @@ class Evilution::Config
130
134
  # Minimum mutation score to pass (0.0 to 1.0, default: 0.0)
131
135
  # min_score: 0.0
132
136
 
137
+ # Proof-of-life canary: run a synthetic, guaranteed-unobservable
138
+ # mutation at session start and abort if the pipeline misreports it
139
+ # (default: true). Set false to skip (e.g. for CI speed).
140
+ # canary: true
141
+
133
142
  # Test integration: rspec, minitest (default: rspec)
134
143
  # integration: rspec
135
144
 
@@ -237,7 +246,8 @@ class Evilution::Config
237
246
  related_specs_heuristic: nil,
238
247
  fallback_to_full_suite: nil,
239
248
  quiet_children: nil,
240
- quiet_children_dir: nil
249
+ quiet_children_dir: nil,
250
+ canary: nil
241
251
  }.freeze
242
252
  private_constant :SIMPLE_ATTR_TRANSFORMS
243
253
 
@@ -56,21 +56,30 @@ class Evilution::Integration::Loading::MutationApplier
56
56
  def apply(mutation, eval_target)
57
57
  @constant_pinner.call(mutation.original_source)
58
58
  @concern_state_cleaner.call(mutation.file_path)
59
+ mark_feature_loaded(mutation.file_path)
59
60
  @redefinition_recovery.call(mutation.original_source) do
60
61
  @source_evaluator.call(eval_target, mutation.file_path)
61
62
  end
62
- mark_feature_loaded(mutation.file_path)
63
63
  end
64
64
 
65
65
  # The mutated source is eval'd straight into the VM — `eval` does not register
66
- # a `$LOADED_FEATURES` entry. If the spec then `require`s the same file (common
67
- # when the project lazy-loads it and only the test references it), `require`
68
- # reloads the ORIGINAL from disk and clobbers the mutation, so every mutation
69
- # silently survives. Under fork isolation each worker starts from the same
70
- # pre-`require` snapshot, so the whole file scores 0%. Registering the
71
- # canonical path makes a later `require`/`require_relative` a no-op.
66
+ # a `$LOADED_FEATURES` entry. Any later `require`/`require_relative` of the
67
+ # same file then reloads the ORIGINAL from disk and clobbers the mutation, so
68
+ # every mutation silently survives. Two paths trigger that reload:
69
+ # - the spec `require`s the file (it lazy-loads it and only the test
70
+ # references it);
71
+ # - the mutated source's OWN body `require_relative`s a sibling whose body
72
+ # `require_relative`s this file back (e.g. lib/evilution/mcp/*.rb tools).
73
+ # The second reload happens DURING the eval, so registration must precede it:
74
+ # `mark_feature_loaded` runs before `@source_evaluator.call`, not after. Under
75
+ # fork isolation each worker starts from the same pre-`require` snapshot, so
76
+ # without this the whole file scores 0%.
72
77
  def mark_feature_loaded(file_path)
73
- absolute = File.realpath(File.expand_path(file_path))
78
+ # When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
79
+ # GH #1278), anchor against PROJECT_ROOT so File.realpath does not chase
80
+ # file_path into a non-existent /tmp path.
81
+ base = Evilution.in_isolated_worker? ? Evilution::PROJECT_ROOT : Dir.pwd
82
+ absolute = File.realpath(File.expand_path(file_path, base))
74
83
  $LOADED_FEATURES << absolute unless $LOADED_FEATURES.include?(absolute)
75
84
  rescue Errno::ENOENT
76
85
  nil
@@ -13,7 +13,11 @@ require_relative "../loading"
13
13
  # substitute the mutated bytes — the privilege level is identical.
14
14
  class Evilution::Integration::Loading::SourceEvaluator
15
15
  def call(source, file_path)
16
- absolute = File.expand_path(file_path)
16
+ # When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
17
+ # GH #1278), anchor the eval __FILE__ against PROJECT_ROOT so siblings
18
+ # `require_relative` can find each other from the real source tree.
19
+ base = Evilution.in_isolated_worker? ? Evilution::PROJECT_ROOT : Dir.pwd
20
+ absolute = File.expand_path(file_path, base)
17
21
  eval(source, TOPLEVEL_BINDING, absolute, 1)
18
22
  end
19
23
  end
@@ -128,7 +128,8 @@ class Evilution::Integration::Minitest < Evilution::Integration::Base
128
128
  end
129
129
 
130
130
  def execute_minitest(mutation, files, command)
131
- files.each { |f| load(File.expand_path(f)) }
131
+ base = Evilution.in_isolated_worker? ? Evilution::PROJECT_ROOT : Dir.pwd
132
+ files.each { |f| load(File.expand_path(f, base)) }
132
133
 
133
134
  detector = reset_crash_detector
134
135
  run = run_minitest(build_args(mutation), detector)
@@ -70,13 +70,50 @@ class Evilution::Integration::RSpec < Evilution::Integration::Base
70
70
  targets = @example_filter_applier.call(mutation, files)
71
71
  return @result_builder.unresolved_example(mutation) if targets.nil?
72
72
 
73
- args = ["--format", "progress", "--no-color", "--order", "defined", *targets]
73
+ args = ["--format", "progress", "--no-color", "--order", "defined", *resolve_targets(targets)]
74
74
  command = "rspec #{args.join(" ")}"
75
75
 
76
76
  reset_examples
77
77
  execute_run(args, command)
78
78
  end
79
79
 
80
+ # Targets are passed straight through when they resolve against Dir.pwd,
81
+ # preserving the relative-path contract for callers/tests. When the CWD
82
+ # cannot find them — workers chdir'd into a per-mutation sandbox by the
83
+ # isolator (EV-wqxu / GH #1278) — the targets are expanded against
84
+ # Evilution::PROJECT_ROOT so RSpec::Core::Runner can still load the files.
85
+ def resolve_targets(targets)
86
+ targets.map { |target| resolve_target(target) }
87
+ end
88
+
89
+ # Splits a target into (path, line). RSpec example locations end in
90
+ # `:LINE` (or `:LINE:LINE...` for nested groups), so the suffix is
91
+ # anchored to the END of the string — that way path components which
92
+ # themselves contain colons (e.g. a Windows-style `C:/proj/spec/x.rb`)
93
+ # are not mis-split by a naive `partition(":")`.
94
+ TARGET_LINE_SUFFIX = /\A(.+?)(:\d+(?::\d+)*)\z/
95
+ private_constant :TARGET_LINE_SUFFIX
96
+
97
+ def resolve_target(target)
98
+ return target if target.start_with?("/")
99
+
100
+ if (match = TARGET_LINE_SUFFIX.match(target))
101
+ path = match[1]
102
+ line_suffix = match[2]
103
+ else
104
+ path = target
105
+ line_suffix = ""
106
+ end
107
+
108
+ return target if File.exist?(path)
109
+ return target unless Evilution.in_isolated_worker?
110
+
111
+ absolute = File.expand_path(path, Evilution::PROJECT_ROOT)
112
+ return target unless File.exist?(absolute)
113
+
114
+ "#{absolute}#{line_suffix}"
115
+ end
116
+
80
117
  def execute_run(args, command)
81
118
  detector = @crash_detector_lifecycle.current
82
119
  snapshot = @state_guard.snapshot
@@ -48,6 +48,15 @@ class Evilution::Isolation::Fork
48
48
  def fork_child(read_io, write_io, sandbox_dir, mutation, test_command)
49
49
  ::Process.fork do
50
50
  ENV["TMPDIR"] = sandbox_dir
51
+ # Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
52
+ # otherwise write into the parent's CWD (typically the repo root) and
53
+ # leak past the run. chdir here keeps such writes inside sandbox_dir,
54
+ # which the ensure block of #call removes. The in_isolated_worker! flag
55
+ # signals the rest of evilution (SpecResolver/SpecSelector/SpecAstCache/
56
+ # MutationApplier/SourceEvaluator/Integration) to anchor project-relative
57
+ # paths to Evilution::PROJECT_ROOT instead of the sandbox CWD.
58
+ Dir.chdir(sandbox_dir)
59
+ Evilution.in_isolated_worker!
51
60
  read_io.close
52
61
  suppress_child_output
53
62
  @hooks.fire(:worker_process_start, mutation:) if @hooks
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
3
4
  require "timeout"
5
+ require "tmpdir"
4
6
  require_relative "../memory"
5
7
  require_relative "../result/mutation_result"
6
8
 
@@ -17,19 +19,34 @@ class Evilution::Isolation::InProcess
17
19
  def call(mutation:, test_command:, timeout:)
18
20
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
21
  rss_before = Evilution::Memory.rss_kb
20
- result = execute_with_timeout(mutation, test_command, timeout)
22
+ sandbox_dir = Dir.mktmpdir("evilution-run")
23
+ result = execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
21
24
  rss_after = Evilution::Memory.rss_kb
22
25
  duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
23
26
  delta = compute_memory_delta(rss_before, rss_after, result)
24
27
 
25
28
  build_mutation_result(mutation, result, duration, rss_before, rss_after, delta)
29
+ ensure
30
+ FileUtils.rm_rf(sandbox_dir) if sandbox_dir
26
31
  end
27
32
 
28
33
  private
29
34
 
30
- def execute_with_timeout(mutation, test_command, timeout)
35
+ # The Dir.chdir block is inside the Timeout.timeout block so that a
36
+ # Timeout::Error raised mid-call still unwinds through Dir.chdir's ensure
37
+ # and restores the parent CWD before the rescue clause runs. The sandbox
38
+ # contains any relative-path writes from path-relativizing mutations
39
+ # (EV-wqxu / GH #1278). Evilution.with_isolated_worker signals the rest of
40
+ # evilution (SpecResolver/SpecSelector/SpecAstCache/MutationApplier/
41
+ # SourceEvaluator/Integration) to anchor project-relative paths to
42
+ # PROJECT_ROOT for the duration of the call.
43
+ def execute_with_timeout(mutation, test_command, timeout, sandbox_dir)
31
44
  result = Timeout.timeout(timeout) do
32
- suppress_output { test_command.call(mutation) }
45
+ Evilution.with_isolated_worker do
46
+ Dir.chdir(sandbox_dir) do
47
+ suppress_output { test_command.call(mutation) }
48
+ end
49
+ end
33
50
  end
34
51
  { timeout: false }.merge(result)
35
52
  rescue Timeout::Error
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require_relative "../runner"
7
+ require_relative "../mutation"
8
+ require_relative "../subject"
9
+
10
+ # Runs one guaranteed-unobservable synthetic mutation through the configured
11
+ # integration + isolation at session start. The synthetic spec never references
12
+ # the synthetic class, so mutating the class cannot change any test outcome — a
13
+ # healthy pipeline must score the mutation :survived. Any other status means the
14
+ # mutation infrastructure is misreporting, so the run aborts before producing
15
+ # numbers that would all be unreliable. Mirrors the configured --isolation so
16
+ # isolation-specific defects are caught too.
17
+ class Evilution::Runner::Canary
18
+ class Failed < Evilution::Error; end
19
+
20
+ def initialize(config:, isolator:, integration_class:, hooks: nil)
21
+ @config = config
22
+ @isolator = isolator
23
+ @integration_class = integration_class
24
+ @hooks = hooks
25
+ end
26
+
27
+ def call
28
+ dir = Dir.mktmpdir("evilution-canary")
29
+ class_path = write_target_class(dir)
30
+ spec_path = write_spec(dir)
31
+
32
+ result = @isolator.call(
33
+ mutation: build_mutation(class_path),
34
+ test_command: ->(mutation) { build_integration(spec_path).call(mutation) },
35
+ timeout: @config.timeout
36
+ )
37
+ raise Failed, failure_message(result.status) unless result.status == :survived
38
+
39
+ nil
40
+ ensure
41
+ FileUtils.remove_entry(dir) if dir
42
+ end
43
+
44
+ private
45
+
46
+ # pid + random hex keeps the synthetic class/spec names unique across
47
+ # concurrent sessions and across repeated re-eval into the same VM.
48
+ def suffix
49
+ @suffix ||= "#{Process.pid}_#{SecureRandom.hex(4)}"
50
+ end
51
+
52
+ def class_name
53
+ @class_name ||= "EvilutionCanary_#{suffix}"
54
+ end
55
+
56
+ def original_source
57
+ <<~RUBY
58
+ class #{class_name}
59
+ private
60
+
61
+ def __evilution_canary_probe
62
+ :original
63
+ end
64
+ end
65
+ RUBY
66
+ end
67
+
68
+ def mutated_source
69
+ original_source.sub(":original", "nil")
70
+ end
71
+
72
+ def write_target_class(dir)
73
+ path = File.join(dir, "#{class_name.downcase}.rb")
74
+ File.write(path, original_source)
75
+ path
76
+ end
77
+
78
+ def write_spec(dir)
79
+ minitest = @config.integration == :minitest
80
+ name = minitest ? "canary_#{suffix}_test.rb" : "canary_#{suffix}_spec.rb"
81
+ path = File.join(dir, name)
82
+ File.write(path, minitest ? minitest_spec_source : rspec_spec_source)
83
+ path
84
+ end
85
+
86
+ def rspec_spec_source
87
+ <<~RUBY
88
+ RSpec.describe("evilution proof-of-life canary") do
89
+ it "pipeline is alive" do
90
+ expect(true).to be(true)
91
+ end
92
+ end
93
+ RUBY
94
+ end
95
+
96
+ def minitest_spec_source
97
+ <<~RUBY
98
+ class EvilutionCanaryTest_#{suffix} < Minitest::Test
99
+ def test_pipeline_is_alive
100
+ assert true
101
+ end
102
+ end
103
+ RUBY
104
+ end
105
+
106
+ def build_mutation(class_path)
107
+ Evilution::Mutation.new(
108
+ subject: Evilution::Subject.new(
109
+ name: "#{class_name}#__evilution_canary_probe",
110
+ file_path: class_path, line_number: 1, source: original_source, node: nil
111
+ ),
112
+ operator_name: :canary_probe,
113
+ sources: Evilution::Mutation::Sources.new(original: original_source, mutated: mutated_source),
114
+ location: Evilution::Mutation::Location.new(file_path: class_path, line: 5, column: 4)
115
+ )
116
+ end
117
+
118
+ def build_integration(spec_path)
119
+ @integration_class.new(test_files: [spec_path], hooks: @hooks)
120
+ end
121
+
122
+ def failure_message(status)
123
+ "evilution proof-of-life canary failed: a guaranteed-unobservable synthetic " \
124
+ "mutation was scored #{status.inspect} instead of :survived. The mutation " \
125
+ "pipeline is misreporting — every score this run would produce is unreliable. " \
126
+ "Likely causes: Rails/Zeitwerk autoloading breaking child eval; an env-specific " \
127
+ "RSpec config (e.g. fail_if_no_examples); a classify_status fallback defect; or " \
128
+ "an isolation-mode defect. Re-run with --no-canary to bypass this check."
129
+ end
130
+ end
@@ -4,6 +4,11 @@ require "fileutils"
4
4
  require_relative "../evilution"
5
5
 
6
6
  class Evilution::Runner
7
+ # Autoloaded: canary.rb subclasses Evilution::Error at class-body eval
8
+ # time, which is not yet defined while evilution.rb is mid-load. Deferring
9
+ # the load until first reference lets evilution.rb finish defining Error.
10
+ autoload :Canary, File.expand_path("runner/canary", __dir__)
11
+
7
12
  attr_reader :config
8
13
 
9
14
  def initialize(config: Evilution::Config.new, on_result: nil, hooks: nil)
@@ -27,6 +32,8 @@ class Evilution::Runner
27
32
  perform_preload
28
33
  log_memory("after preload") if rails_root_detected?
29
34
 
35
+ run_canary
36
+
30
37
  baseline_result = run_baseline(subjects)
31
38
 
32
39
  plan = mutation_planner.call(subjects)
@@ -122,6 +129,17 @@ class Evilution::Runner
122
129
  baseline_runner.call(subjects)
123
130
  end
124
131
 
132
+ def run_canary
133
+ return unless config.canary?
134
+
135
+ Evilution::Runner::Canary.new(
136
+ config: config,
137
+ isolator: isolator,
138
+ integration_class: baseline_runner.integration_class,
139
+ hooks: @hooks
140
+ ).call
141
+ end
142
+
125
143
  def run_mutations(mutations, baseline_result = nil)
126
144
  mutation_executor.call(mutations, baseline_result)
127
145
  end
@@ -59,13 +59,30 @@ class Evilution::SpecAstCache
59
59
  end
60
60
 
61
61
  def parse(path)
62
- raise Evilution::ParseError.new("file not found: #{path}", file: path) unless File.exist?(path)
62
+ resolved = resolve_path(path)
63
+ raise Evilution::ParseError.new("file not found: #{path}", file: path) unless resolved
63
64
 
64
- source = read_source(path)
65
- result = parse_source(path, source)
65
+ source = read_source(resolved)
66
+ result = parse_source(resolved, source)
66
67
  collect_blocks(source, result, extract_comment_ranges(result))
67
68
  end
68
69
 
70
+ # Accept either a CWD-relative path (historical) or one resolvable against
71
+ # Evilution::PROJECT_ROOT — needed for isolators chdir'd into a per-mutation
72
+ # sandbox (EV-wqxu / GH #1278). The PROJECT_ROOT fallback is gated on the
73
+ # isolated-worker flag so unrelated callers that chdir intentionally (e.g.
74
+ # tests using a fixture project layout) do not accidentally resolve into
75
+ # the evilution dev tree.
76
+ def resolve_path(path)
77
+ return path if File.exist?(path)
78
+ return nil unless Evilution.in_isolated_worker?
79
+
80
+ expanded = File.expand_path(path, Evilution::PROJECT_ROOT)
81
+ return expanded if File.exist?(expanded)
82
+
83
+ nil
84
+ end
85
+
69
86
  def parse_source(path, source)
70
87
  result = Prism.parse(source)
71
88
  return result unless result.failure?
@@ -16,7 +16,7 @@ class Evilution::SpecResolver
16
16
  normalized = normalize_path(source_path)
17
17
  candidates = candidate_test_paths(normalized)
18
18
  candidates = filter_by_pattern(candidates, spec_pattern) if spec_pattern
19
- candidates.find { |path| File.exist?(path) }
19
+ candidates.find { |path| project_relative_exists?(path) }
20
20
  end
21
21
 
22
22
  def resolve_all(source_paths)
@@ -25,13 +25,27 @@ class Evilution::SpecResolver
25
25
 
26
26
  private
27
27
 
28
+ # Existence check that succeeds against the current CWD. When the caller
29
+ # is an isolated worker that chdir'd into a per-mutation sandbox (Evilution
30
+ # signals this via in_isolated_worker?), also try PROJECT_ROOT so the
31
+ # sandbox CWD does not break spec resolution (EV-wqxu / GH #1278).
32
+ def project_relative_exists?(path)
33
+ return true if File.exist?(path)
34
+ return false unless Evilution.in_isolated_worker?
35
+
36
+ File.exist?(File.expand_path(path, Evilution::PROJECT_ROOT))
37
+ end
38
+
28
39
  def filter_by_pattern(candidates, pattern)
29
40
  candidates.select { |path| File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
30
41
  end
31
42
 
32
43
  def normalize_path(path)
33
44
  path = path.delete_prefix("./")
34
- path = path.delete_prefix("#{Dir.pwd}/") if path.start_with?("/")
45
+ if path.start_with?("/")
46
+ path = path.delete_prefix("#{Dir.pwd}/")
47
+ path = path.delete_prefix("#{Evilution::PROJECT_ROOT}/") if Evilution.in_isolated_worker?
48
+ end
35
49
  path
36
50
  end
37
51
 
@@ -15,7 +15,7 @@ class Evilution::SpecSelector
15
15
 
16
16
  mapped = mapping_for(source_path)
17
17
  if mapped
18
- existing = mapped.select { |path| File.exist?(path) }
18
+ existing = mapped.select { |path| project_relative_exists?(path) }
19
19
  return existing unless existing.empty?
20
20
  end
21
21
 
@@ -33,7 +33,19 @@ class Evilution::SpecSelector
33
33
  return path if path.nil?
34
34
 
35
35
  normalized = path.to_s
36
- normalized = normalized.delete_prefix("#{Dir.pwd}/") if normalized.start_with?("/")
36
+ if normalized.start_with?("/")
37
+ normalized = normalized.delete_prefix("#{Dir.pwd}/")
38
+ normalized = normalized.delete_prefix("#{Evilution::PROJECT_ROOT}/") if Evilution.in_isolated_worker?
39
+ end
37
40
  normalized.delete_prefix("./")
38
41
  end
42
+
43
+ # Same semantics as Evilution::SpecResolver#project_relative_exists? — see
44
+ # that method for the EV-wqxu / GH #1278 rationale.
45
+ def project_relative_exists?(path)
46
+ return true if File.exist?(path)
47
+ return false unless Evilution.in_isolated_worker?
48
+
49
+ File.exist?(File.expand_path(path, Evilution::PROJECT_ROOT))
50
+ end
39
51
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Evilution
4
- VERSION = "0.30.4"
4
+ VERSION = "0.31.0"
5
5
  end
data/lib/evilution.rb CHANGED
@@ -129,6 +129,34 @@ require_relative "evilution/disable_comment"
129
129
  require_relative "evilution/runner"
130
130
 
131
131
  module Evilution
132
+ # Captured at load time, before any isolator can chdir into a per-mutation
133
+ # sandbox. Used as the anchor for resolving project-relative paths (spec
134
+ # files, source files for eval) from inside a chdir'd child so the CWD
135
+ # sandbox (EV-wqxu / GH #1278) cannot break spec resolution or eval __FILE__.
136
+ PROJECT_ROOT = Dir.pwd.freeze unless defined?(PROJECT_ROOT)
137
+
138
+ # Flag set by isolators (Evilution::Isolation::Fork in the forked child,
139
+ # Evilution::Isolation::InProcess around the test_command) so spec
140
+ # resolution and source eval anchor relative paths to PROJECT_ROOT instead
141
+ # of Dir.pwd. Without this gate, a caller that intentionally chdirs to a
142
+ # different project (e.g. a fixture layout in tests) would have its lookups
143
+ # inadvertently fall back to the evilution dev tree.
144
+ def self.in_isolated_worker!
145
+ @in_isolated_worker = true
146
+ end
147
+
148
+ def self.in_isolated_worker?
149
+ @in_isolated_worker == true
150
+ end
151
+
152
+ def self.with_isolated_worker
153
+ previous = @in_isolated_worker
154
+ @in_isolated_worker = true
155
+ yield
156
+ ensure
157
+ @in_isolated_worker = previous
158
+ end
159
+
132
160
  class Error < StandardError
133
161
  attr_reader :file
134
162
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evilution
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.4
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Kiselev
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-16 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -446,6 +446,7 @@ files:
446
446
  - lib/evilution/result/summary.rb
447
447
  - lib/evilution/runner.rb
448
448
  - lib/evilution/runner/baseline_runner.rb
449
+ - lib/evilution/runner/canary.rb
449
450
  - lib/evilution/runner/diagnostics.rb
450
451
  - lib/evilution/runner/isolation_resolver.rb
451
452
  - lib/evilution/runner/mutation_executor.rb