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 +4 -4
- data/.beads/interactions.jsonl +6 -0
- data/.rubocop_todo.yml +6 -0
- data/CHANGELOG.md +11 -0
- data/README.md +2 -0
- data/docs/isolation.md +28 -0
- data/lib/evilution/cli/parser/options_builder.rb +5 -0
- data/lib/evilution/config.rb +13 -3
- data/lib/evilution/integration/loading/mutation_applier.rb +17 -8
- data/lib/evilution/integration/loading/source_evaluator.rb +5 -1
- data/lib/evilution/integration/minitest.rb +2 -1
- data/lib/evilution/integration/rspec.rb +38 -1
- data/lib/evilution/isolation/fork.rb +9 -0
- data/lib/evilution/isolation/in_process.rb +20 -3
- data/lib/evilution/runner/canary.rb +130 -0
- data/lib/evilution/runner.rb +18 -0
- data/lib/evilution/spec_ast_cache.rb +20 -3
- data/lib/evilution/spec_resolver.rb +16 -2
- data/lib/evilution/spec_selector.rb +14 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 923190b1b1b35a2e5bad46d1866a0a001bcd6afa48d208f962e73d73af6e7577
|
|
4
|
+
data.tar.gz: 76edef4423129342ce415bead32545894927070d042f615294d5b04df2c6b5ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ff3ec8345986e7ad9b51e7a7ec75ed3b2acbb95eb00c11d1edd3ca0c41163b7a24fa8ba68e82e941608644c4075f50a2734c1c1ee6787ac48f794ae47bfda8ae
|
|
7
|
+
data.tar.gz: 826763c5a82048db39a81a9343f586aac488ecf1910d56bc086456cb5dbe7ee975a6c4de9651ee87f7210a9f26635599db347ee02c6d6ec43623caa151cd5e14
|
data/.beads/interactions.jsonl
CHANGED
|
@@ -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
|
|
data/lib/evilution/config.rb
CHANGED
|
@@ -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.
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/evilution/runner.rb
CHANGED
|
@@ -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
|
-
|
|
62
|
+
resolved = resolve_path(path)
|
|
63
|
+
raise Evilution::ParseError.new("file not found: #{path}", file: path) unless resolved
|
|
63
64
|
|
|
64
|
-
source = read_source(
|
|
65
|
-
result = parse_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|
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
data/lib/evilution/version.rb
CHANGED
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.
|
|
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-
|
|
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
|