evilution 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +13 -0
  3. data/.rubocop_todo.yml +0 -1
  4. data/CHANGELOG.md +32 -0
  5. data/README.md +19 -0
  6. data/lib/evilution/baseline.rb +5 -4
  7. data/lib/evilution/cli/parser/options_builder.rb +7 -0
  8. data/lib/evilution/compare/diff_extractor/evilution.rb +22 -0
  9. data/lib/evilution/compare/diff_extractor/mutant.rb +30 -0
  10. data/lib/evilution/compare/diff_extractor.rb +6 -0
  11. data/lib/evilution/compare/fingerprint.rb +15 -72
  12. data/lib/evilution/compare/line_normalizer.rb +72 -0
  13. data/lib/evilution/compare/normalizer.rb +17 -4
  14. data/lib/evilution/config/validators/profile.rb +11 -0
  15. data/lib/evilution/config.rb +40 -23
  16. data/lib/evilution/integration/crash_detector.rb +2 -2
  17. data/lib/evilution/integration/loading/source_evaluator.rb +6 -2
  18. data/lib/evilution/integration/minitest_crash_detector.rb +2 -2
  19. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +11 -3
  20. data/lib/evilution/isolation/fork.rb +16 -11
  21. data/lib/evilution/isolation/in_process.rb +10 -6
  22. data/lib/evilution/mcp/info_tool.rb +0 -2
  23. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +5 -1
  24. data/lib/evilution/mcp/session_tool.rb +0 -2
  25. data/lib/evilution/mutation.rb +47 -27
  26. data/lib/evilution/mutator/base.rb +8 -8
  27. data/lib/evilution/mutator/operator/predicate_to_nil.rb +20 -0
  28. data/lib/evilution/mutator/registry.rb +20 -0
  29. data/lib/evilution/parallel/work_queue/channel/frame.rb +5 -1
  30. data/lib/evilution/parallel/work_queue/worker/loop.rb +1 -1
  31. data/lib/evilution/process_cleanup.rb +19 -0
  32. data/lib/evilution/reporter/html/baseline_keys.rb +1 -1
  33. data/lib/evilution/reporter/html/diff_formatter.rb +1 -1
  34. data/lib/evilution/reporter/html/escape.rb +1 -1
  35. data/lib/evilution/reporter/html/section.rb +1 -1
  36. data/lib/evilution/reporter/html/sections.rb +4 -2
  37. data/lib/evilution/reporter/html/stylesheet.rb +1 -1
  38. data/lib/evilution/reporter/html.rb +8 -3
  39. data/lib/evilution/reporter/suggestion/registry.rb +1 -5
  40. data/lib/evilution/reporter/suggestion/templates/generic.rb +1 -1
  41. data/lib/evilution/reporter/suggestion/templates/minitest.rb +349 -643
  42. data/lib/evilution/reporter/suggestion/templates/rspec.rb +351 -598
  43. data/lib/evilution/reporter/suggestion/templates.rb +6 -0
  44. data/lib/evilution/result/error_info.rb +20 -0
  45. data/lib/evilution/result/memory_stats.rb +20 -0
  46. data/lib/evilution/result/mutation_result.rb +30 -14
  47. data/lib/evilution/runner/baseline_runner.rb +1 -2
  48. data/lib/evilution/runner/diagnostics.rb +1 -2
  49. data/lib/evilution/runner/isolation_resolver.rb +1 -2
  50. data/lib/evilution/runner/mutation_executor/mutation_runner.rb +1 -3
  51. data/lib/evilution/runner/mutation_executor/neutralization_pipeline.rb +1 -2
  52. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +3 -10
  53. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +3 -10
  54. data/lib/evilution/runner/mutation_executor/neutralizer.rb +11 -0
  55. data/lib/evilution/runner/mutation_executor/result_cache.rb +1 -3
  56. data/lib/evilution/runner/mutation_executor/result_notifier.rb +1 -3
  57. data/lib/evilution/runner/mutation_executor/result_packer.rb +11 -9
  58. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +1 -3
  59. data/lib/evilution/runner/mutation_executor/strategy/sequential.rb +1 -3
  60. data/lib/evilution/runner/mutation_executor/strategy.rb +11 -0
  61. data/lib/evilution/runner/mutation_executor.rb +12 -20
  62. data/lib/evilution/runner/mutation_planner.rb +1 -2
  63. data/lib/evilution/runner/report_publisher.rb +1 -2
  64. data/lib/evilution/runner/subject_pipeline.rb +1 -2
  65. data/lib/evilution/runner.rb +33 -31
  66. data/lib/evilution/version.rb +1 -1
  67. data/lib/evilution.rb +1 -0
  68. data/script/memory_check +3 -1
  69. metadata +14 -3
  70. data/lib/evilution/reporter/html/namespace.rb +0 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12d485d3cce9569229a95a1e8f29403fbb11f8f067e0c438256919521f6d82dc
4
- data.tar.gz: 11a1adeca7c61fa905757137d6a737c6e389eae584b37fca91a8c9eed57926d0
3
+ metadata.gz: dda90ba09660e134411bad5df79ba56075ed9661f8e94aa3b366478cc3191755
4
+ data.tar.gz: c1c5606e0a2c082dd1d7ec24738f0558b7cc94bfc3632cd4c2fdd200d5ad5e11
5
5
  SHA512:
6
- metadata.gz: ce9208fa3f3ed3160d2844cdd5e1479cf7b2fa90985371cb95d6978177f371d38ec4bdccf473f79af318cd6c617fda2b1962efe4f389eab1912ab348cc48b475
7
- data.tar.gz: 57ff4d971829430d5d47525ac0bc5e488b326e6916657aaf9acb3faf0e8795e909523ec8238b6b9fbaace13bdd60c0f437f30d94efbfa82861b42da802a54aac
6
+ metadata.gz: 30d4d7dcdcb9d88615fa5d37205bd02a307f32b6150a7ee7fa55b8397b677d40dd26e5777043fb6dfeeddf2559c8e79ae23276574ede65627ccf95f9effcd31e
7
+ data.tar.gz: a657c55edcffd46c6883c4146cd97471db4c51940def54027a49f01ff89a29e7fd6fd4c28e758d9d7207623d664c320ccc22a9a01430abd5d26574afb1142932
@@ -245,3 +245,16 @@
245
245
  {"id":"int-9d182026","kind":"field_change","created_at":"2026-04-25T17:54:26.23542935Z","actor":"Denis Kiselev","issue_id":"EV-67yh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #897"}}
246
246
  {"id":"int-7ede49f5","kind":"field_change","created_at":"2026-04-25T18:16:18.358350457Z","actor":"Denis Kiselev","issue_id":"EV-zvhp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #898"}}
247
247
  {"id":"int-c28f7ee0","kind":"field_change","created_at":"2026-04-26T02:28:38.321564801Z","actor":"Denis Kiselev","issue_id":"EV-vev8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Merged via PR #899"}}
248
+ {"id":"int-111f631a","kind":"field_change","created_at":"2026-04-26T07:57:40.59622849Z","actor":"Denis Kiselev","issue_id":"EV-p5vh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Released in v0.27.0 (PR #901 merged, release PR #902 shipped)"}}
249
+ {"id":"int-5a159da9","kind":"field_change","created_at":"2026-04-29T11:51:40.86913186Z","actor":"Denis Kiselev","issue_id":"EV-vm61","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
250
+ {"id":"int-1787900f","kind":"field_change","created_at":"2026-04-30T02:40:52.288627178Z","actor":"Denis Kiselev","issue_id":"EV-v1i6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged via PR #911"}}
251
+ {"id":"int-08958dc0","kind":"field_change","created_at":"2026-04-30T03:15:14.369272687Z","actor":"Denis Kiselev","issue_id":"EV-x3s0","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged via PR #912"}}
252
+ {"id":"int-e013b002","kind":"field_change","created_at":"2026-04-30T05:46:44.70653253Z","actor":"Denis Kiselev","issue_id":"EV-eww3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged via PR #913"}}
253
+ {"id":"int-7b15a1b0","kind":"field_change","created_at":"2026-04-30T09:30:21.057039785Z","actor":"Denis Kiselev","issue_id":"EV-7rov","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged via PR #914"}}
254
+ {"id":"int-71c9f98d","kind":"field_change","created_at":"2026-04-30T12:30:36.528587334Z","actor":"Denis Kiselev","issue_id":"EV-318q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged"}}
255
+ {"id":"int-42eadbdc","kind":"field_change","created_at":"2026-04-30T16:09:00.539400752Z","actor":"Denis Kiselev","issue_id":"EV-voay","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged"}}
256
+ {"id":"int-cb4c0ebf","kind":"field_change","created_at":"2026-05-01T02:38:05.091612839Z","actor":"Denis Kiselev","issue_id":"EV-t918","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged via PR #918"}}
257
+ {"id":"int-ffa8f0b2","kind":"field_change","created_at":"2026-05-01T17:47:23.967998678Z","actor":"Denis Kiselev","issue_id":"EV-m3ta","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged"}}
258
+ {"id":"int-b9cb7738","kind":"field_change","created_at":"2026-05-01T18:06:39.17748775Z","actor":"Denis Kiselev","issue_id":"EV-gffv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"merged"}}
259
+ {"id":"int-983c2fe6","kind":"field_change","created_at":"2026-05-02T02:47:27.113692488Z","actor":"Denis Kiselev","issue_id":"EV-3t8l","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Audit confirmed feature already fully implemented in current master: --fail-fast CLI flag, .evilution.yml key, FailFast validator, ResultNotifier trips at threshold, sequential+parallel strategies short-circuit, summary.truncated? indicator, reporter notices (CLI/HTML/JSON), full spec coverage across notifier/sequential/parallel/runner/parser. CI signal available via summary.truncated? in reports."}}
260
+ {"id":"int-1790a8b7","kind":"field_change","created_at":"2026-05-02T17:53:56.73309749Z","actor":"Denis Kiselev","issue_id":"EV-2gpj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Audit confirmed refactor already done: ProcessCleanup.safe_kill / safe_wait helpers extracted to lib/evilution/process_cleanup.rb (lines 8-18), used by baseline.rb (lines 85,93,94) and parallel/work_queue/worker.rb. No Style/RescueModifier disables remain anywhere in lib/. bundle exec rubocop lib/evilution/baseline.rb clean. Existing specs pass."}}
data/.rubocop_todo.yml CHANGED
@@ -3,5 +3,4 @@
3
3
 
4
4
  Metrics/AbcSize:
5
5
  Exclude:
6
- - "lib/evilution/config.rb"
7
6
  - "lib/evilution/runner.rb"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0] - 2026-05-03
4
+
5
+ ### Added
6
+
7
+ - **Operator profiles: `default` (current 72-operator set) and `strict` (adds aggressive truthiness mutators)** — pre-merge audits can opt into a more sensitive operator mix. The `strict` profile registers `PredicateToNil`, which replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values. Wired through CLI (`--profile=strict`, `--strict` shortcut), `.evilution.yml` (`profile: strict`), and a new `Evilution::Mutator::Registry.for_profile(:default | :strict)` factory. `default` is unchanged, so existing CI runs are not affected (#920, PR #926)
8
+ - **Multi-file batch invocation documented** — `evilution path/a.rb path/b.rb path/c.rb` runs every file in a single Runner invocation so the framework (Rails, Sorbet, etc.) and the `preload` chain load **once** in the parent process. With `--isolation=fork` (default for Rails projects under `auto`), every per-mutation fork branches off the warmed parent — materially faster than `for f in ...; do bundle exec evilution run "$f"; done`. README now has a "5a. Multi-file batch scan" workflow section and an end-to-end runner spec covers two positional file paths; session save/load preserves per-file paths in `survived[].file` (#922, PR #927)
9
+
10
+ ### Fixed
11
+
12
+ - **`Compare::Normalizer` mis-classified Mutant payload lines whose mutated source started with `--` or `++` as unified-diff headers** — pre-existing bug in `extract_from_mutant_diff` that would, for example, drop a removed line `--flag` (emitted as `---flag` in the diff). The new `DiffExtractor::Mutant` requires a trailing space after `---`/`+++` to match a header, preserving real payload. Equivalent Evilution/Mutant mutations on such lines now hash identically and `compare` no longer reports false additions/removals (#917, PR #934)
13
+
14
+ ### Changed
15
+
16
+ - **Internal `Evilution::Compare::Fingerprint` SOLID refactor** — module-function form replaced with a class taking injectable `(extractor:, normalizer:)` collaborators and a `#call(diff:, file_path:, line:)` interface. Diff parsing extracted into `Evilution::Compare::DiffExtractor::{Evilution,Mutant}` strategy classes (one per format, common duck-typed interface), enabling open/closed extension for future tools without touching the orchestrator. `Compare::Normalizer` constructs both fingerprints once and reuses them across records (#917, PR #934)
17
+ - **`Evilution::Mutation` migrated to value-object composition** — sources, slice, parse status now Data.define-backed value objects (#822, PR #907)
18
+ - **`Evilution::Result::MutationResult` encapsulates memory and error state in dedicated Data.define value objects** — `MemoryStats` and `ErrorInfo` instead of flat positional fields (#823, PR #907)
19
+ - **`Reporter::Suggestion` registry/templates and the RSpec/Minitest template builders unified** — single `build` entrypoint per format (#824, PR #908; #849, PR #905; #850, PR #904)
20
+ - **`Reporter::HTML` namespace inlined into `report.rb`** — separate `namespace.rb` removed, autoload pattern adopted for sub-templates (#826, PR #909)
21
+ - **`Compare::LineNormalizer` extracted into its own class** — whitespace collapse separated from fingerprint orchestration (#829, PR #832)
22
+ - **`Evilution::Config` attribute assignment migrated to a transformation map** — single source of truth for type coercion across simple attributes (#830)
23
+ - **`Runner` `require` chain consolidated** — sub-component loading now centralized; circular-require pitfalls in `MutationExecutor` resolved with `Module#autoload` for child strategy/neutralizer files (#831)
24
+ - **Process cleanup helpers extracted into `Evilution::ProcessCleanup`** — `safe_kill(sig, pid)` and `safe_wait(pid)` shared by `Baseline`, `Isolation::Fork`, and `WorkQueue::Worker`, replacing scattered inline `rescue` modifiers swallowing `Errno::ESRCH`/`ECHILD` (#838)
25
+ - **`ProgressStreamer` and `Loop` error handling tightened** — generic `StandardError` rescues replaced with specific `Errno::EPIPE`/`Errno::EBADF`/etc.; once-only warning suppression added so a flood of failures cannot drown stderr (#827, #840)
26
+ - **Crash detector predicate methods renamed** — `has_*?` → `*?` per Ruby/RSpec conventions (`have_X` matcher calls `has_X?`; the renamed methods are still picked up by `be_X` matchers used in specs) (#839)
27
+ - **Rubocop hygiene sweep across 6 sites** — `Style/RescueModifier`, `Lint/UnusedMethodArgument`, `Lint/SuppressedException` (3 instances), `Security/Eval`, and `Security/MarshalLoad` (3 instances) inline disable comments removed in favor of either narrowed code, explanatory rescue-body comments, or main-`.rubocop.yml` per-file Excludes documented with the underlying trust boundary (#832, #833, #834, #835, #836, #837)
28
+
29
+ ### Documentation
30
+
31
+ - **README "Operator Profiles" subsection** — explains the `default` vs `strict` profiles, how to opt in (CLI, config, shortcut), and what `strict` adds today (#920, PR #926)
32
+ - **README "5a. Multi-file batch scan" workflow** — documents Rails-loads-once amortisation and qualifies the speed claim by isolation mode (`fork` vs `in_process`) (#922, PR #927)
33
+ - **`.evilution.yml` template gained a `profile:` block** — generated by `evilution init` (#920, PR #926)
34
+
3
35
  ## [0.27.0] - 2026-04-26
4
36
 
5
37
  ### Added
data/README.md CHANGED
@@ -118,6 +118,17 @@ The shorter alias `evil` ships alongside `evilution` and accepts identical argum
118
118
  | `--fallback-full-suite` | Boolean | false | When no matching spec/test resolves for a mutation, run the whole test suite instead of marking it `:unresolved` and skipping. |
119
119
  | `--baseline-session PATH` | String | _(none)_ | Saved session file for HTML report comparison. |
120
120
  | `-e CODE`, `--eval CODE` | String | _(none)_ | Inline Ruby code for `util mutation` command. |
121
+ | `--profile NAME` | String | `default` | Operator profile: `default` or `strict`. `strict` adds aggressive truthiness mutators (e.g. replaces `x.predicate?` with `nil`) intended for pre-merge audits. |
122
+ | `--strict` | Boolean | false | Shortcut for `--profile=strict`. |
123
+
124
+ ### Operator Profiles
125
+
126
+ Two profiles ship out of the box:
127
+
128
+ - **`default`** — the 72 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
129
+ - **`strict`** — adds extra truthiness mutators on top of `default`. Currently `PredicateToNil` (replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values). Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
130
+
131
+ Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
121
132
 
122
133
  ### Exit Codes
123
134
 
@@ -484,6 +495,14 @@ bundle exec evilution run lib/specific_file.rb --format json
484
495
 
485
496
  Use when you know which file was modified and want to verify its test coverage.
486
497
 
498
+ ### 5a. Multi-file batch scan
499
+
500
+ ```bash
501
+ bundle exec evilution run lib/models/user.rb lib/models/account.rb lib/models/order.rb
502
+ ```
503
+
504
+ Pass multiple file paths on a single invocation to amortise startup cost. The framework (Rails, Sorbet, etc.) and the `preload` chain (`spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`) load **once** in the parent process. When `--isolation=fork` is selected (the default `--isolation=auto` resolves to `fork` on Rails projects), every subsequent mutation across all files forks from that warmed parent — materially faster than scripting a `for f in ...; do bundle exec evilution run "$f"; done` loop, which pays the bootstrap per file. With `--isolation=in_process` (default for non-Rails projects under `auto`), there is no per-mutation fork, but the parent-process boot still runs once instead of N times. Per-file paths and line numbers are preserved in the report (`survived[].file`, HTML grouping by source file).
505
+
487
506
  ### 6. Fixing surviving mutants
488
507
 
489
508
  For each entry in `survived[]`:
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "spec_resolver"
4
+ require_relative "process_cleanup"
4
5
 
5
6
  class Evilution::Baseline
6
7
  Result = Struct.new(:failed_spec_files, :duration) do
@@ -72,7 +73,7 @@ class Evilution::Baseline
72
73
  Process.wait(pid)
73
74
  return false if data.empty?
74
75
 
75
- result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
76
+ result = Marshal.load(data)
76
77
  result[:passed]
77
78
  else
78
79
  terminate_child(pid)
@@ -81,7 +82,7 @@ class Evilution::Baseline
81
82
  end
82
83
 
83
84
  def terminate_child(pid)
84
- Process.kill("TERM", pid) rescue nil # rubocop:disable Style/RescueModifier
85
+ Evilution::ProcessCleanup.safe_kill("TERM", pid)
85
86
  _, status = Process.waitpid2(pid, Process::WNOHANG)
86
87
  return if status
87
88
 
@@ -89,8 +90,8 @@ class Evilution::Baseline
89
90
  _, status = Process.waitpid2(pid, Process::WNOHANG)
90
91
  return if status
91
92
 
92
- Process.kill("KILL", pid) rescue nil # rubocop:disable Style/RescueModifier
93
- Process.wait(pid) rescue nil # rubocop:disable Style/RescueModifier
93
+ Evilution::ProcessCleanup.safe_kill("KILL", pid)
94
+ Evilution::ProcessCleanup.safe_wait(pid)
94
95
  end
95
96
 
96
97
  private
@@ -21,6 +21,7 @@ class Evilution::CLI::Parser::OptionsBuilder
21
21
  add_core_options(opts)
22
22
  add_filter_options(opts)
23
23
  add_flag_options(opts)
24
+ add_profile_options(opts)
24
25
  add_extra_flag_options(opts)
25
26
  add_session_options(opts)
26
27
  add_compare_options(opts)
@@ -95,6 +96,12 @@ class Evilution::CLI::Parser::OptionsBuilder
95
96
  end
96
97
  end
97
98
 
99
+ def add_profile_options(opts)
100
+ opts.on("--profile NAME", "Operator profile: default, strict (default: default). " \
101
+ "strict adds aggressive truthiness mutators for pre-merge audits.") { |p| @options[:profile] = p }
102
+ opts.on("--strict", "Shortcut for --profile=strict") { @options[:profile] = "strict" }
103
+ end
104
+
98
105
  def add_extra_flag_options(opts)
99
106
  opts.on("--skip-heredoc-literals", "Skip all string literal mutations inside heredocs") { @options[:skip_heredoc_literals] = true }
100
107
  opts.on("--related-specs-heuristic", "Append related request/integration/feature/system specs for includes() mutations") do
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diff_extractor"
4
+
5
+ # Extracts {minus:, plus:} payload arrays from Evilution-format diffs.
6
+ # Evilution diffs use "- " / "+ " line prefixes (note the trailing space) and
7
+ # do not carry unified-diff headers or hunk markers.
8
+ class Evilution::Compare::DiffExtractor::Evilution
9
+ def call(diff)
10
+ minus = []
11
+ plus = []
12
+ diff.to_s.each_line do |line|
13
+ line = line.chomp
14
+ if line.start_with?("- ")
15
+ minus << line[2..]
16
+ elsif line.start_with?("+ ")
17
+ plus << line[2..]
18
+ end
19
+ end
20
+ { minus: minus, plus: plus }
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../diff_extractor"
4
+
5
+ # Extracts {minus:, plus:} payload arrays from Mutant unified-diff format.
6
+ # Skips the "--- <name>", "+++ <name>", and "@@ ... @@" header lines and
7
+ # returns each remaining payload line with its single leading "-" or "+"
8
+ # marker stripped.
9
+ #
10
+ # Header detection requires a trailing space after "---"/"+++" so that a
11
+ # payload line whose mutated source starts with "--" (emitted as "---var")
12
+ # or "++" (emitted as "+++var") is preserved rather than misclassified as
13
+ # a header.
14
+ class Evilution::Compare::DiffExtractor::Mutant
15
+ def call(diff)
16
+ minus = []
17
+ plus = []
18
+ diff.to_s.each_line do |line|
19
+ line = line.chomp
20
+ next if line.start_with?("--- ", "+++ ", "@@")
21
+
22
+ if line.start_with?("-")
23
+ minus << line[1..]
24
+ elsif line.start_with?("+")
25
+ plus << line[1..]
26
+ end
27
+ end
28
+ { minus: minus, plus: plus }
29
+ end
30
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ module Evilution::Compare::DiffExtractor
6
+ end
@@ -3,80 +3,23 @@
3
3
  require "digest"
4
4
  require_relative "../compare"
5
5
 
6
- module Evilution::Compare::Fingerprint
7
- module_function
8
-
9
- def extract_from_evilution_diff(diff)
10
- minus = []
11
- plus = []
12
- diff.to_s.each_line do |line|
13
- line = line.chomp
14
- if line.start_with?("- ")
15
- minus << line[2..]
16
- elsif line.start_with?("+ ")
17
- plus << line[2..]
18
- end
19
- end
20
- { minus: minus, plus: plus }
21
- end
22
-
23
- def extract_from_mutant_diff(diff)
24
- minus = []
25
- plus = []
26
- diff.to_s.each_line do |line|
27
- line = line.chomp
28
- next if line.start_with?("---", "+++", "@@")
29
-
30
- if line.start_with?("-")
31
- minus << line[1..]
32
- elsif line.start_with?("+")
33
- plus << line[1..]
34
- end
35
- end
36
- { minus: minus, plus: plus }
37
- end
38
-
39
- # v1 limitation: only " and ' literals are preserved. Regex literals (/.../),
40
- # heredocs, %w[], %q{} forms are treated as ordinary code — whitespace runs
41
- # inside them collapse. A mutation touching whitespace inside a regex may
42
- # false-match across tools.
43
- # rubocop:disable Metrics/PerceivedComplexity, Style/MultipleComparison
44
- def normalize_line(line)
45
- out = +""
46
- i = 0
47
- in_literal = nil
48
- last_was_space = false
49
- chars = line.chars
50
- while i < chars.length
51
- ch = chars[i]
52
- if in_literal
53
- out << ch
54
- if ch == "\\" && i + 1 < chars.length
55
- out << chars[i + 1]
56
- i += 2
57
- next
58
- end
59
- in_literal = nil if ch == in_literal
60
- elsif ch == '"' || ch == "'"
61
- in_literal = ch
62
- out << ch
63
- last_was_space = false
64
- elsif ch == " " || ch == "\t"
65
- out << " " unless last_was_space || out.empty?
66
- last_was_space = true
67
- else
68
- out << ch
69
- last_was_space = false
70
- end
71
- i += 1
72
- end
73
- out.rstrip
6
+ # Composes a stable SHA256 fingerprint from a mutation diff for cross-tool
7
+ # matching (Evilution vs Mutant). Orchestrates two collaborators along
8
+ # distinct change axes:
9
+ #
10
+ # - extractor: parses a tool-specific diff format into {minus:, plus:}
11
+ # - normalizer: collapses whitespace per line so cosmetic differences
12
+ # don't perturb the hash
13
+ class Evilution::Compare::Fingerprint
14
+ def initialize(extractor:, normalizer:)
15
+ @extractor = extractor
16
+ @normalizer = normalizer
74
17
  end
75
- # rubocop:enable Metrics/PerceivedComplexity, Style/MultipleComparison
76
18
 
77
- def compute(file_path:, line:, body:)
78
- minus = body[:minus].map { |l| normalize_line(l) }
79
- plus = body[:plus].map { |l| normalize_line(l) }
19
+ def call(diff:, file_path:, line:)
20
+ body = @extractor.call(diff)
21
+ minus = body[:minus].map { |l| @normalizer.call(l) }
22
+ plus = body[:plus].map { |l| @normalizer.call(l) }
80
23
  payload = [file_path, line.to_s, minus.join("\n"), plus.join("\n")].join("\x00")
81
24
  Digest::SHA256.hexdigest(payload)
82
25
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../compare"
4
+
5
+ # Collapses whitespace runs in source-code text while preserving the contents
6
+ # of "..." and '...' string literals. Used for fingerprinting mutation diffs
7
+ # so that whitespace-only differences do not cause false fingerprint mismatches
8
+ # across tooling (evilution vs mutant).
9
+ #
10
+ # v1 limitation: only " and ' literals are preserved. Regex literals (/.../),
11
+ # heredocs, %w[], %q{} forms are treated as ordinary code — whitespace runs
12
+ # inside them collapse. A mutation touching whitespace inside a regex may
13
+ # false-match across tools.
14
+ class Evilution::Compare::LineNormalizer
15
+ QUOTES = ['"', "'"].freeze
16
+ WHITESPACE = [" ", "\t"].freeze
17
+ private_constant :QUOTES, :WHITESPACE
18
+
19
+ def call(line)
20
+ @chars = line.chars
21
+ @i = 0
22
+ @out = +""
23
+ @in_literal = nil
24
+ @last_was_space = false
25
+
26
+ @i += step while @i < @chars.length
27
+ result = @out.rstrip
28
+ @chars = nil
29
+ @out = nil
30
+ result
31
+ end
32
+
33
+ private
34
+
35
+ def step
36
+ ch = @chars[@i]
37
+ return step_in_literal(ch) if @in_literal
38
+ return step_open_quote(ch) if QUOTES.include?(ch)
39
+ return step_whitespace if WHITESPACE.include?(ch)
40
+
41
+ append_regular(ch)
42
+ end
43
+
44
+ def step_in_literal(ch)
45
+ @out << ch
46
+ if ch == "\\" && @i + 1 < @chars.length
47
+ @out << @chars[@i + 1]
48
+ return 2
49
+ end
50
+ @in_literal = nil if ch == @in_literal
51
+ 1
52
+ end
53
+
54
+ def step_open_quote(ch)
55
+ @in_literal = ch
56
+ @out << ch
57
+ @last_was_space = false
58
+ 1
59
+ end
60
+
61
+ def step_whitespace
62
+ @out << " " unless @last_was_space || @out.empty?
63
+ @last_was_space = true
64
+ 1
65
+ end
66
+
67
+ def append_regular(ch)
68
+ @out << ch
69
+ @last_was_space = false
70
+ 1
71
+ end
72
+ end
@@ -3,6 +3,9 @@
3
3
  require_relative "../compare"
4
4
  require_relative "record"
5
5
  require_relative "fingerprint"
6
+ require_relative "line_normalizer"
7
+ require_relative "diff_extractor/evilution"
8
+ require_relative "diff_extractor/mutant"
6
9
 
7
10
  class Evilution::Compare::Normalizer
8
11
  EVILUTION_BUCKETS = %w[killed survived timed_out errors neutral equivalent unresolved unparseable].freeze
@@ -17,6 +20,18 @@ class Evilution::Compare::Normalizer
17
20
  "unparseable" => :unparseable
18
21
  }.freeze
19
22
 
23
+ def initialize
24
+ line_normalizer = Evilution::Compare::LineNormalizer.new
25
+ @evilution_fingerprint = Evilution::Compare::Fingerprint.new(
26
+ extractor: Evilution::Compare::DiffExtractor::Evilution.new,
27
+ normalizer: line_normalizer
28
+ )
29
+ @mutant_fingerprint = Evilution::Compare::Fingerprint.new(
30
+ extractor: Evilution::Compare::DiffExtractor::Mutant.new,
31
+ normalizer: line_normalizer
32
+ )
33
+ end
34
+
20
35
  def from_evilution(json)
21
36
  records = []
22
37
  EVILUTION_BUCKETS.each do |bucket|
@@ -47,13 +62,12 @@ class Evilution::Compare::Normalizer
47
62
  diff = entry["diff"].to_s
48
63
  status = EVILUTION_STATUS_MAP[entry["status"]] ||
49
64
  raise(Evilution::Compare::InvalidInput.new("unknown status #{entry["status"].inspect}", index: index))
50
- body = Evilution::Compare::Fingerprint.extract_from_evilution_diff(diff)
51
65
  Evilution::Compare::Record.new(
52
66
  source: :evilution,
53
67
  file_path: file_path,
54
68
  line: line,
55
69
  status: status,
56
- fingerprint: Evilution::Compare::Fingerprint.compute(file_path: file_path, line: line, body: body),
70
+ fingerprint: @evilution_fingerprint.call(diff: diff, file_path: file_path, line: line),
57
71
  operator: entry["operator"],
58
72
  diff_body: diff,
59
73
  raw: entry
@@ -67,13 +81,12 @@ class Evilution::Compare::Normalizer
67
81
  line = parse_mutant_line(ident, index)
68
82
  diff = mr["mutation_diff"].to_s
69
83
  status = derive_mutant_status(mr, cr, index)
70
- body = Evilution::Compare::Fingerprint.extract_from_mutant_diff(diff)
71
84
  Evilution::Compare::Record.new(
72
85
  source: :mutant,
73
86
  file_path: source_path,
74
87
  line: line,
75
88
  status: status,
76
- fingerprint: Evilution::Compare::Fingerprint.compute(file_path: source_path, line: line, body: body),
89
+ fingerprint: @mutant_fingerprint.call(diff: diff, file_path: source_path, line: line),
77
90
  operator: nil,
78
91
  diff_body: diff,
79
92
  raw: { "mutation_result" => mr, "criteria_result" => cr, "source_path" => source_path }
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ class Evilution::Config::Validators::Profile < Evilution::Config::Validators::Base
6
+ ALLOWED = %i[default strict].freeze
7
+
8
+ def self.call(value)
9
+ coerce_symbol!(value, allowed: ALLOWED, name: "profile")
10
+ end
11
+ end
@@ -17,7 +17,8 @@ class Evilution::Config
17
17
  spec_mappings: {}, spec_pattern: nil, example_targeting: true,
18
18
  example_targeting_fallback: :full_file,
19
19
  example_targeting_cache: { max_files: 50, max_blocks: 10_000 },
20
- quiet_children: false, quiet_children_dir: "tmp/evilution_children"
20
+ quiet_children: false, quiet_children_dir: "tmp/evilution_children",
21
+ profile: :default
21
22
  }.freeze
22
23
 
23
24
  attr_reader :target_files, :timeout, :format,
@@ -28,7 +29,7 @@ class Evilution::Config
28
29
  :skip_heredoc_literals, :related_specs_heuristic,
29
30
  :fallback_to_full_suite, :preload, :spec_mappings, :spec_pattern,
30
31
  :example_targeting, :example_targeting_fallback, :example_targeting_cache,
31
- :spec_selector, :quiet_children, :quiet_children_dir
32
+ :spec_selector, :quiet_children, :quiet_children_dir, :profile
32
33
 
33
34
  def initialize(**options)
34
35
  skip_file = options.delete(:skip_config_file) ? true : false
@@ -183,6 +184,11 @@ class Evilution::Config
183
184
  # ignore_patterns:
184
185
  # - "call{name=info, receiver=call{name=logger}}"
185
186
  # - "call{name=debug|warn}"
187
+
188
+ # Operator profile: default or strict (default: default).
189
+ # strict adds aggressive truthiness mutators (e.g. replaces
190
+ # `x.predicate?` with `nil`) intended for pre-merge audits.
191
+ # profile: default
186
192
  YAML
187
193
  end
188
194
 
@@ -200,28 +206,37 @@ class Evilution::Config
200
206
  )
201
207
  end
202
208
 
209
+ SIMPLE_ATTR_TRANSFORMS = {
210
+ target_files: ->(v) { Array(v) },
211
+ timeout: nil,
212
+ format: :to_sym.to_proc,
213
+ target: nil,
214
+ min_score: :to_f.to_proc,
215
+ verbose: nil,
216
+ quiet: nil,
217
+ baseline: nil,
218
+ incremental: nil,
219
+ suggest_tests: nil,
220
+ progress: nil,
221
+ save_session: nil,
222
+ line_ranges: ->(v) { v || {} },
223
+ spec_files: ->(v) { Array(v) },
224
+ show_disabled: nil,
225
+ baseline_session: nil,
226
+ skip_heredoc_literals: nil,
227
+ related_specs_heuristic: nil,
228
+ fallback_to_full_suite: nil,
229
+ quiet_children: nil,
230
+ quiet_children_dir: nil
231
+ }.freeze
232
+ private_constant :SIMPLE_ATTR_TRANSFORMS
233
+
203
234
  def assign_simple_attributes(merged)
204
- @target_files = Array(merged[:target_files])
205
- @timeout = merged[:timeout]
206
- @format = merged[:format].to_sym
207
- @target = merged[:target]
208
- @min_score = merged[:min_score].to_f
209
- @verbose = merged[:verbose]
210
- @quiet = merged[:quiet]
211
- @baseline = merged[:baseline]
212
- @incremental = merged[:incremental]
213
- @suggest_tests = merged[:suggest_tests]
214
- @progress = merged[:progress]
215
- @save_session = merged[:save_session]
216
- @line_ranges = merged[:line_ranges] || {}
217
- @spec_files = Array(merged[:spec_files])
218
- @show_disabled = merged[:show_disabled]
219
- @baseline_session = merged[:baseline_session]
220
- @skip_heredoc_literals = merged[:skip_heredoc_literals]
221
- @related_specs_heuristic = merged[:related_specs_heuristic]
222
- @fallback_to_full_suite = merged[:fallback_to_full_suite]
223
- @quiet_children = merged[:quiet_children]
224
- @quiet_children_dir = merged[:quiet_children_dir]
235
+ SIMPLE_ATTR_TRANSFORMS.each do |key, transform|
236
+ value = merged[key]
237
+ value = transform.call(value) if transform
238
+ instance_variable_set(:"@#{key}", value)
239
+ end
225
240
  end
226
241
 
227
242
  def assign_validated_attributes(merged)
@@ -234,6 +249,7 @@ class Evilution::Config
234
249
  @preload = Validators::Preload.call(merged[:preload])
235
250
  @spec_mappings = Validators::SpecMappings.call(merged[:spec_mappings])
236
251
  @spec_pattern = Validators::SpecPattern.call(merged[:spec_pattern])
252
+ @profile = Validators::Profile.call(merged[:profile])
237
253
  end
238
254
 
239
255
  def assign_example_targeting(merged)
@@ -259,6 +275,7 @@ require_relative "config/validators/spec_pattern"
259
275
  require_relative "config/validators/spec_mappings"
260
276
  require_relative "config/validators/example_targeting_fallback"
261
277
  require_relative "config/validators/example_targeting_cache"
278
+ require_relative "config/validators/profile"
262
279
  require_relative "config/builders"
263
280
  require_relative "config/builders/spec_resolver"
264
281
  require_relative "config/builders/spec_selector"
@@ -26,11 +26,11 @@ class Evilution::Integration::CrashDetector
26
26
  end
27
27
  end
28
28
 
29
- def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
29
+ def assertion_failure?
30
30
  @assertion_failures.positive?
31
31
  end
32
32
 
33
- def has_crash? # rubocop:disable Naming/PredicatePrefix
33
+ def crashed?
34
34
  @crashes.any?
35
35
  end
36
36
 
@@ -5,11 +5,15 @@ require_relative "../loading"
5
5
  # Evaluate source with __FILE__ set to the absolute original path so that
6
6
  # `require_relative` and `__dir__` resolve against the real source tree, where
7
7
  # sibling files actually exist.
8
+ #
9
+ # Trust boundary: `source` is never user-supplied. It is always the original
10
+ # on-disk source from a file the user already pointed Evilution at, with
11
+ # byte-level mutations applied by AST::SourceSurgeon. The only difference
12
+ # between this eval path and a plain `require` of the same file is that we
13
+ # substitute the mutated bytes — the privilege level is identical.
8
14
  class Evilution::Integration::Loading::SourceEvaluator
9
15
  def call(source, file_path)
10
16
  absolute = File.expand_path(file_path)
11
- # rubocop:disable Security/Eval
12
17
  eval(source, TOPLEVEL_BINDING, absolute, 1)
13
- # rubocop:enable Security/Eval
14
18
  end
15
19
  end
@@ -34,11 +34,11 @@ class Evilution::Integration::MinitestCrashDetector
34
34
  end
35
35
  end
36
36
 
37
- def has_assertion_failure? # rubocop:disable Naming/PredicatePrefix
37
+ def assertion_failure?
38
38
  @assertion_failures.positive?
39
39
  end
40
40
 
41
- def has_crash? # rubocop:disable Naming/PredicatePrefix
41
+ def crashed?
42
42
  @crashes.any?
43
43
  end
44
44