henitai 0.4.0 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +1 -1
  4. data/lib/henitai/cli.rb +1 -1
  5. data/lib/henitai/configuration.rb +9 -2
  6. data/lib/henitai/dirty_source_detector.rb +53 -0
  7. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  8. data/lib/henitai/equivalence_detector.rb +6 -23
  9. data/lib/henitai/excluded_test_filter.rb +47 -0
  10. data/lib/henitai/execution_engine.rb +5 -11
  11. data/lib/henitai/inherited_fd_registry.rb +66 -0
  12. data/lib/henitai/integration/base.rb +7 -2
  13. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  14. data/lib/henitai/integration/child_debug_log.rb +135 -0
  15. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  16. data/lib/henitai/integration/loaded_features.rb +38 -0
  17. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  18. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  19. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  20. data/lib/henitai/integration.rb +10 -7
  21. data/lib/henitai/mutation_skip_directives.rb +7 -1
  22. data/lib/henitai/operators/return_value.rb +1 -1
  23. data/lib/henitai/orphan_watchdog.rb +93 -0
  24. data/lib/henitai/process_liveness.rb +41 -0
  25. data/lib/henitai/reports_directory_lock.rb +12 -11
  26. data/lib/henitai/result.rb +30 -3
  27. data/lib/henitai/runner.rb +41 -123
  28. data/lib/henitai/runner_dependencies.rb +75 -0
  29. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  30. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  31. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  32. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  33. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  34. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  35. data/lib/henitai/slot_scheduler.rb +68 -80
  36. data/lib/henitai/source_file_selection.rb +76 -0
  37. data/lib/henitai/subject_selection.rb +33 -0
  38. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  39. data/lib/henitai/version.rb +1 -1
  40. data/lib/henitai.rb +8 -0
  41. data/sig/henitai.rbs +92 -38
  42. metadata +31 -9
  43. data/lib/henitai/integration/child_debug_support.rb +0 -119
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Answers whether a test file has already been required, by matching it
6
+ # against `$LOADED_FEATURES`.
7
+ #
8
+ # Both sides need normalizing: `$LOADED_FEATURES` holds absolute paths for
9
+ # required files but callers hand over repository-relative test paths, and
10
+ # either side may or may not carry the `.rb` suffix. A feature string that
11
+ # cannot be expanded (invalid encoding, for instance) falls back to its raw
12
+ # form rather than aborting the whole check — this runs inside a mutant
13
+ # child whose only job is diagnostics.
14
+ class LoadedFeatures
15
+ def include?(file)
16
+ candidates = candidates_for(file)
17
+ $LOADED_FEATURES.any? do |feature|
18
+ candidates.include?(feature) || candidates.include?(normalize(feature))
19
+ end
20
+ end
21
+
22
+ def map(files) = files.map { |file| [file, include?(file)] }
23
+
24
+ private
25
+
26
+ def candidates_for(file)
27
+ expanded = File.expand_path(file)
28
+ [expanded, "#{expanded}.rb", file, "#{file}.rb"].uniq
29
+ end
30
+
31
+ def normalize(feature)
32
+ File.expand_path(feature)
33
+ rescue StandardError
34
+ feature
35
+ end
36
+ end
37
+ end
38
+ end
@@ -47,7 +47,7 @@ module Henitai
47
47
  with_subprocess_env do
48
48
  suppress_simplecov!
49
49
  suppress_coverage!
50
- install_debug_timeout_trap if debug_child?
50
+ install_debug_timeout_trap if child_debug_log.enabled?
51
51
  with_non_interactive_stdin do
52
52
  run_child_activation_and_tests(mutant:, test_files:, log_paths:)
53
53
  end
@@ -63,11 +63,11 @@ module Henitai
63
63
  def run_child_activation_and_tests(mutant:, test_files:, log_paths:)
64
64
  scenario_log_support.with_coverage_dir(mutant.id) do
65
65
  scenario_log_support.capture_child_output(log_paths) do
66
- debug_child_mutant_meta(mutant) if debug_child?
67
- debug_child_activation_start(mutant.id)
66
+ child_debug_log.mutant_meta(mutant)
67
+ child_debug_log.activation_start(mutant.id)
68
68
  activation_result = Mutant::Activator.activate!(mutant)
69
- debug_child_activation_check if debug_child?
70
- debug_child_activation_end(activation_result, test_files:)
69
+ child_debug_log.activation_check
70
+ child_debug_log.activation_end(activation_result, test_files:)
71
71
  activation_result == :compile_error ? 2 : run_tests(test_files)
72
72
  end
73
73
  end
@@ -9,19 +9,20 @@ module Henitai
9
9
  private
10
10
 
11
11
  def run_rspec_runner(test_files)
12
- debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
12
+ log = child_debug_log
13
+ log.write("[henitai-debug-child] build_rspec_runner_start")
13
14
  runner = build_rspec_runner
14
- debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
15
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
15
+ log.write("[henitai-debug-child] build_rspec_runner_return")
16
+ log.write("[henitai-debug-child] configure_rspec_runner_start")
16
17
  configure_rspec_runner(runner)
17
- debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
18
+ log.write("[henitai-debug-child] configure_rspec_runner_return")
18
19
  load_rspec_spec_files(test_files)
19
20
  run_rspec_specs(runner)
20
21
  rescue SystemExit => e
21
- debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
22
+ log.write("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
22
23
  raise
23
24
  ensure
24
- debug_child_puts("[henitai-debug-child] runner_run_ensure")
25
+ child_debug_log.write("[henitai-debug-child] runner_run_ensure")
25
26
  end
26
27
 
27
28
  def build_rspec_runner
@@ -32,28 +33,28 @@ module Henitai
32
33
  end
33
34
 
34
35
  def configure_rspec_runner(runner)
35
- debug_child_puts("[henitai-debug-child] trap_interrupt_start")
36
+ child_debug_log.write("[henitai-debug-child] trap_interrupt_start")
36
37
  ::RSpec::Core::Runner.__send__(:trap_interrupt)
37
- debug_child_puts("[henitai-debug-child] trap_interrupt_return")
38
- debug_child_puts("[henitai-debug-child] runner_configure_start")
38
+ child_debug_log.write("[henitai-debug-child] trap_interrupt_return")
39
+ child_debug_log.write("[henitai-debug-child] runner_configure_start")
39
40
  runner.send(:configure, $stderr, $stdout)
40
- debug_child_puts("[henitai-debug-child] runner_configure_return")
41
+ child_debug_log.write("[henitai-debug-child] runner_configure_return")
41
42
  end
42
43
 
43
44
  def load_rspec_spec_files(test_files)
44
- debug_child_puts("[henitai-debug-child] load_spec_files_start")
45
+ child_debug_log.write("[henitai-debug-child] load_spec_files_start")
45
46
  ::RSpec.configuration.files_to_run = test_files.map do |file|
46
47
  File.expand_path(file)
47
48
  end
48
49
  ::RSpec.configuration.load_spec_files
49
- debug_child_example_count("after_load")
50
- debug_child_puts("[henitai-debug-child] load_spec_files_return")
50
+ child_debug_log.example_count("after_load")
51
+ child_debug_log.write("[henitai-debug-child] load_spec_files_return")
51
52
  end
52
53
 
53
54
  def run_rspec_specs(runner)
54
- debug_child_puts("[henitai-debug-child] run_specs_start")
55
+ child_debug_log.write("[henitai-debug-child] run_specs_start")
55
56
  result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
56
- debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
+ child_debug_log.write("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
58
  result
58
59
  end
59
60
  end
@@ -32,7 +32,7 @@ module Henitai
32
32
  ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  @mutex.synchronize do
34
34
  @live_count -= 1
35
- entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
35
+ entry = @intervals.reverse_each.find { |i| i[:pid] == pid && i[:ended_at].nil? }
36
36
  entry[:ended_at] = ended_at if entry
37
37
  end
38
38
  end
@@ -84,8 +84,13 @@ module Henitai
84
84
  # Forks a child, sets process group, activates the mutant, runs tests.
85
85
  # Returns a ChildHandle with the forked pid and log_paths.
86
86
  def spawn_mutant(integration, mutant:, test_files:, log_paths:)
87
+ # Captured here, in the parent. Reading Process.ppid inside the child
88
+ # would race the very death the watchdog looks for: a parent dying
89
+ # between fork and that read leaves the child with ppid 1 as its
90
+ # baseline, so it would never consider itself orphaned.
91
+ parent_pid = Process.pid
87
92
  pid = Process.fork do
88
- Process.setpgid(0, 0)
93
+ ChildBootstrap.after_fork!(parent_pid:)
89
94
  ENV["HENITAI_MUTANT_ID"] = mutant.id
90
95
  Process.exit(
91
96
  integration.run_in_child(
@@ -3,10 +3,12 @@
3
3
  require "fileutils"
4
4
  require "stringio"
5
5
  require_relative "process_wakeup"
6
+ require_relative "integration/child_bootstrap"
6
7
  require_relative "integration/rspec_process_runner"
7
8
  require_relative "integration/scenario_log_support"
8
9
  require_relative "integration/coverage_suppression"
9
- require_relative "integration/child_debug_support"
10
+ require_relative "integration/child_debug_log"
11
+ require_relative "integration/loaded_features"
10
12
  require_relative "integration/base"
11
13
  require_relative "integration/mutant_run_support"
12
14
  require_relative "integration/rspec_child_runner"
@@ -111,13 +113,14 @@ module Henitai
111
113
  def run_tests(test_files)
112
114
  require "rspec/core"
113
115
  ::RSpec.__send__(:configuration).fail_if_no_examples = true
114
- debug_child_rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
115
- debug_child_example_count("before_run") # steep:ignore Ruby::NoMethod
116
- debug_child_puts("[henitai-debug-child] runner_run_start")
116
+ log = child_debug_log
117
+ log.rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
118
+ log.example_count("before_run")
119
+ log.write("[henitai-debug-child] runner_run_start")
117
120
  status = run_rspec_runner(test_files)
118
- debug_child_puts("[henitai-debug-child] runner_run_return status=#{status.inspect}")
119
- debug_child_example_count("after_run") # steep:ignore Ruby::NoMethod
120
- debug_child_rspec_exit(status)
121
+ log.write("[henitai-debug-child] runner_run_return status=#{status.inspect}")
122
+ log.example_count("after_run")
123
+ log.rspec_exit(status)
121
124
  return status if status.is_a?(Integer)
122
125
 
123
126
  status == true ? 0 : 1
@@ -25,7 +25,13 @@ module Henitai
25
25
  # Matching mutants are reported as ignored by {StaticFilter}, not dropped.
26
26
  class MutationSkipDirectives
27
27
  DIRECTIVE = /\A#\s*henitai:disable(?<kind>-start|-end)?(?<rest>[:\s].*)?\z/
28
- VALID_OPERATOR_NAMES = Operator::FULL_SET
28
+
29
+ # The whitelist is the operator *registry*, not the configured set: the
30
+ # widest set names every operator henitai knows. Narrowing it to the
31
+ # configured set would reject a directive for a registered operator the
32
+ # current run happens not to enable — hard-set names above all, which is
33
+ # precisely where the escape hatch is needed (ADR-12).
34
+ VALID_OPERATOR_NAMES = Operator::HARD_SET
29
35
 
30
36
  # A parsed directive: +operators+ is nil (all) or a Set of canonical
31
37
  # operator names; +reason+ is optional free text shown in reports.
@@ -65,7 +65,7 @@ module Henitai
65
65
  body = method_node.children.last
66
66
  return body unless body&.type == :begin
67
67
 
68
- body.children.rfind { |child| child.is_a?(Parser::AST::Node) }
68
+ body.children.reverse_each.find { |child| child.is_a?(Parser::AST::Node) }
69
69
  end
70
70
 
71
71
  # rubocop:disable Lint/BooleanSymbol
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Makes a forked mutant child exit when its parent dies.
5
+ #
6
+ # Children `setpgid(0, 0)` into their own process group, and all parent-side
7
+ # cleanup (timeout kills, graceful drain, signal traps) only runs while the
8
+ # parent's event loop is alive. If the parent is SIGKILLed, OOM-killed, or
9
+ # crashes, its children receive no signal at all: they reparent to init and
10
+ # keep running, each holding a full Ruby and test-framework image. Runs have
11
+ # been observed leaving a dozen such orphans behind, several gigabytes in
12
+ # total.
13
+ #
14
+ # A child cannot be told to die by a parent that is already gone, so it has
15
+ # to notice by itself. This is a poll: portable, and cheap enough at a
16
+ # multi-second interval that it costs nothing next to running a test suite.
17
+ # PDEATHSIG (Linux) and kqueue NOTE_EXIT (macOS) would be prompter but are
18
+ # platform-specific; they can be layered on later behind the same interface.
19
+ class OrphanWatchdog
20
+ DEFAULT_INTERVAL = 1.5
21
+ ENV_ENABLED = "HENITAI_CHILD_WATCHDOG"
22
+ ENV_INTERVAL = "HENITAI_CHILD_WATCHDOG_INTERVAL"
23
+
24
+ # Exit code used when the watchdog fires. 2 classifies as :compile_error
25
+ # (see ScenarioExecutionResult.status_for), which surfaces in reports and
26
+ # logs. Codes at 3 and above classify as :killed, which would let a
27
+ # false positive silently inflate the mutation score -- so a visibly wrong
28
+ # verdict is preferred to an invisibly wrong one. In the true-positive case
29
+ # the parent is dead and nothing classifies this child at all.
30
+ ORPHAN_EXIT_CODE = 2
31
+
32
+ # Captured at load time, for the same reason as ProcessLiveness::KILL: the
33
+ # mutant child runs the host project's own suite, and a spec there stubbing
34
+ # `Process.ppid` would otherwise make this child believe it had been
35
+ # reparented and exit itself.
36
+ PPID = Process.method(:ppid)
37
+
38
+ # Opt-OUT, deliberately inverted relative to HENITAI_DEBUG_CHILD's opt-in:
39
+ # a watchdog that defaulted to off would never protect the runs it exists
40
+ # for. Set HENITAI_CHILD_WATCHDOG=0 to disable.
41
+ def self.enabled?(env = ENV)
42
+ env[ENV_ENABLED] != "0"
43
+ end
44
+
45
+ def self.poll_interval(env = ENV)
46
+ seconds = Float(env[ENV_INTERVAL], exception: false)
47
+ seconds&.positive? ? seconds : DEFAULT_INTERVAL
48
+ end
49
+
50
+ # Starts the watchdog in a background thread. Call in the child, right
51
+ # after fork, with the pid captured in the parent beforehand.
52
+ #
53
+ # @return [Thread, nil] nil when disabled
54
+ def self.start(parent_pid:, env: ENV)
55
+ return nil unless enabled?(env)
56
+
57
+ watchdog = new(parent_pid:, interval: poll_interval(env))
58
+ Thread.new { watchdog.run }
59
+ end
60
+
61
+ # Every collaborator is injectable so the decision logic can be specced
62
+ # without forking a process or waiting on a real clock.
63
+ # rubocop:disable Metrics/ParameterLists
64
+ def initialize(parent_pid:, interval: DEFAULT_INTERVAL, liveness: ProcessLiveness,
65
+ ppid: PPID, on_orphan: nil, sleeper: nil)
66
+ @parent_pid = parent_pid
67
+ @interval = interval
68
+ @liveness = liveness
69
+ @ppid = ppid
70
+ @on_orphan = on_orphan || -> { Kernel.exit!(ORPHAN_EXIT_CODE) }
71
+ @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
72
+ end
73
+ # rubocop:enable Metrics/ParameterLists
74
+
75
+ # Two arms, because neither alone is sufficient. A changed ppid is
76
+ # definitive -- we have been reparented, and no pid reuse can fake that --
77
+ # but it stays equal while the parent lingers as a zombie, which the
78
+ # liveness probe catches.
79
+ def orphaned?
80
+ @ppid.call != @parent_pid || !@liveness.alive?(@parent_pid)
81
+ end
82
+
83
+ # Polls until orphaned, then hands over to the orphan handler -- which by
84
+ # default calls exit! and so never returns. Checks before sleeping, so a
85
+ # child forked from an already-dead parent dies immediately rather than
86
+ # after one interval.
87
+ def run
88
+ @sleeper.call(@interval) until orphaned?
89
+
90
+ @on_orphan.call
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Answers whether a process id is still running.
5
+ #
6
+ # Extracted so the one subtle rule here lives in a single place: EPERM means
7
+ # the process exists but belongs to someone else, so it counts as *alive*.
8
+ # Only ESRCH proves it is gone. Getting that backwards would let
9
+ # OrphanWatchdog kill live children, and would make the reports-directory
10
+ # lock report a running owner as dead.
11
+ module ProcessLiveness
12
+ # Captured at load time, before any test double can replace
13
+ # `Process.kill`. A `Method` object keeps pointing at the original
14
+ # definition even after the singleton method is redefined, which is what
15
+ # makes this immune to stubbing.
16
+ #
17
+ # This is not defensiveness for its own sake: a mutant child runs the host
18
+ # project's own suite, and a spec in that suite stubbing `Process.kill` to
19
+ # raise ESRCH made this answer "parent is dead" while the parent was very
20
+ # much alive. OrphanWatchdog then exited the child, which the scheduler
21
+ # recorded as CompileError. Observed on henitai's own dogfood run.
22
+ KILL = Process.method(:kill)
23
+
24
+ # @param pid [Integer, nil] process id to probe
25
+ # @param kill [#call] signalling primitive; injected only by specs, which
26
+ # cannot reach {KILL} by stubbing and must not be able to
27
+ # @return [Boolean] false only when the pid provably does not exist
28
+ def self.alive?(pid, kill: KILL)
29
+ return false unless pid.is_a?(Integer)
30
+
31
+ kill.call(0, pid)
32
+ true
33
+ rescue Errno::ESRCH
34
+ false
35
+ rescue StandardError
36
+ # EPERM and anything unexpected: assume alive, the conservative answer
37
+ # for every caller.
38
+ true
39
+ end
40
+ end
41
+ end
@@ -18,7 +18,17 @@ module Henitai
18
18
  File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |file|
19
19
  acquire(file)
20
20
  write_owner(file)
21
- yield
21
+ # Registered so a forked mutant child can close its inherited copy: the
22
+ # flock lives on the shared open file description, so a child that
23
+ # outlives its parent would otherwise keep this lock held. Unregistered
24
+ # inside the File.open block, keeping the registration's lifetime a
25
+ # subset of the handle's.
26
+ InheritedFdRegistry.register(file)
27
+ begin
28
+ yield
29
+ ensure
30
+ InheritedFdRegistry.unregister(file)
31
+ end
22
32
  end
23
33
  end
24
34
 
@@ -55,16 +65,7 @@ module Henitai
55
65
 
56
66
  # True only when the recorded owner pid provably no longer exists. EPERM
57
67
  # means the process is alive but owned by someone else — treated as alive.
58
- def dead_owner?(pid)
59
- return false unless pid.is_a?(Integer)
60
-
61
- Process.kill(0, pid)
62
- false
63
- rescue Errno::ESRCH
64
- true
65
- rescue StandardError
66
- false
67
- end
68
+ def dead_owner?(pid) = !ProcessLiveness.alive?(pid)
68
69
 
69
70
  def write_owner(file)
70
71
  file.rewind
@@ -15,8 +15,28 @@ module Henitai
15
15
  SCHEMA_VERSION = "1.0"
16
16
  DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
17
17
 
18
+ # Which outcomes count as "the test suite detected this mutant" for the MS
19
+ # numerator. Duplicated from Configuration rather than read from it: Result
20
+ # is a domain object and must not depend on configuration loading. The same
21
+ # arrangement already exists for DEFAULT_THRESHOLDS, and result_spec asserts
22
+ # the two constants stay equal.
23
+ DEFAULT_COVERAGE_CRITERIA = {
24
+ test_result: true,
25
+ timeout: true,
26
+ process_abort: true
27
+ }.freeze
28
+
29
+ # Maps each criterion to the mutant status it governs. process_abort has no
30
+ # producer in lib/ yet -- nothing classifies a mutant as :runtime_error --
31
+ # so that entry is forward-looking wiring rather than a live code path.
32
+ CRITERION_STATUSES = {
33
+ test_result: :killed,
34
+ timeout: :timeout,
35
+ process_abort: :runtime_error
36
+ }.freeze
37
+
18
38
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
19
- :session_id, :git_sha, :since
39
+ :session_id, :git_sha, :since, :coverage_criteria
20
40
 
21
41
  # @param source_provider [#call] maps a file path to its source string.
22
42
  # Injected so the domain object performs no disk IO; the caller (which
@@ -33,7 +53,7 @@ module Henitai
33
53
  partial_rerun: false, survivor_stats: nil,
34
54
  session_id: SecureRandom.uuid, git_sha: nil,
35
55
  source_provider: ->(_file) { "" }, authoritative: true,
36
- since: nil)
56
+ since: nil, coverage_criteria: nil)
37
57
  @mutants = mutants
38
58
  @started_at = started_at
39
59
  @finished_at = finished_at
@@ -45,6 +65,7 @@ module Henitai
45
65
  @source_provider = source_provider
46
66
  @authoritative = authoritative
47
67
  @since = since
68
+ @coverage_criteria = DEFAULT_COVERAGE_CRITERIA.merge(coverage_criteria || {})
48
69
  end
49
70
  # rubocop:enable Metrics/ParameterLists
50
71
 
@@ -140,7 +161,13 @@ module Henitai
140
161
  private
141
162
 
142
163
  def detected_in(list)
143
- list.count { |m| %i[killed timeout runtime_error].include?(m.status) }
164
+ list.count { |m| detected_statuses.include?(m.status) }
165
+ end
166
+
167
+ def detected_statuses
168
+ @detected_statuses ||= CRITERION_STATUSES.filter_map do |criterion, status|
169
+ status if @coverage_criteria[criterion]
170
+ end
144
171
  end
145
172
 
146
173
  def mutation_score_for(list)
@@ -30,9 +30,16 @@ module Henitai
30
30
 
31
31
  # @param mode [Hash] execution-mode flags: +dry_run:+ stops before Gate 4,
32
32
  # +incremental:+ reuses still-valid Killed verdicts from history.
33
+ # +deps+ is assigned in the body, not defaulted in the signature: the
34
+ # default needs @config, and `config:` itself defaults to a load.
35
+ #
36
+ # rubocop:disable Metrics/ParameterLists -- these are the CLI's own flags
37
+ # plus the dependency seam; a params object would only move the list.
33
38
  def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil,
34
- mode: {})
39
+ mode: {}, deps: nil)
40
+ # rubocop:enable Metrics/ParameterLists
35
41
  @config = config
42
+ @deps = deps || RunnerDependencies.new(config: @config)
36
43
  @subjects = subjects
37
44
  @since = since
38
45
  @survivors_from = survivors_from
@@ -88,13 +95,13 @@ module Henitai
88
95
  end
89
96
 
90
97
  def resolve_subjects(source_files = self.source_files)
91
- subjects = subject_resolver.resolve_from_files(source_files)
92
- return subjects if pattern_subjects.empty?
98
+ subject_selection.resolve(source_files)
99
+ end
93
100
 
94
- selected_subjects = pattern_subjects.flat_map do |pattern|
95
- subject_resolver.apply_pattern(subjects, pattern.expression)
96
- end
97
- unique_subjects(selected_subjects)
101
+ def subject_selection
102
+ @subject_selection ||= SubjectSelection.new(
103
+ subject_resolver: subject_resolver, patterns: @subjects
104
+ )
98
105
  end
99
106
 
100
107
  def generate_mutants(subjects)
@@ -164,6 +171,7 @@ module Henitai
164
171
  started_at:,
165
172
  finished_at:,
166
173
  thresholds: result_thresholds,
174
+ coverage_criteria: result_coverage_criteria,
167
175
  partial_rerun: survivor_rerun?,
168
176
  survivor_stats: survivor_strategy.survivor_stats,
169
177
  git_sha: safe_head_sha,
@@ -173,20 +181,6 @@ module Henitai
173
181
  )
174
182
  end
175
183
 
176
- # Reads each source file once and caches it, so Result consumes source
177
- # content while performing no disk IO of its own. Returns "" for files that
178
- # cannot be read (e.g. recipe stubs with synthetic locations).
179
- def source_provider
180
- cache = {} # : Hash[String, String]
181
- lambda do |file|
182
- cache[file] ||= begin
183
- File.read(file)
184
- rescue StandardError
185
- ""
186
- end
187
- end
188
- end
189
-
190
184
  def safe_head_sha
191
185
  git_diff_analyzer.head_sha
192
186
  rescue StandardError
@@ -199,123 +193,47 @@ module Henitai
199
193
  coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
200
194
  end
201
195
 
202
- def subject_resolver = @subject_resolver ||= SubjectResolver.new
203
-
204
- def git_diff_analyzer = @git_diff_analyzer ||= GitDiffAnalyzer.new
205
-
206
- def mutant_generator = @mutant_generator ||= MutantGenerator.new
207
-
208
- def static_filter = @static_filter ||= StaticFilter.new
209
-
210
- def execution_engine = @execution_engine ||= ExecutionEngine.new
196
+ attr_reader :deps
211
197
 
212
- def coverage_bootstrapper = @coverage_bootstrapper ||= CoverageBootstrapper.new
198
+ def subject_resolver = deps.subject_resolver
199
+ def git_diff_analyzer = deps.git_diff_analyzer
200
+ def mutant_generator = deps.mutant_generator
201
+ def static_filter = deps.static_filter
202
+ def execution_engine = deps.execution_engine
203
+ def coverage_bootstrapper = deps.coverage_bootstrapper
204
+ def integration = deps.integration
205
+ def operators = deps.operators
206
+ def history_store = deps.history_store
207
+ def per_test_coverage = deps.per_test_coverage
208
+ def source_provider = deps.source_provider
213
209
 
214
- def integration
215
- @integration ||= Integration.for(config.integration).new
216
- end
217
-
218
- def operators
219
- @operators ||= Operator.for_set(config.operators)
220
- end
221
-
222
- # Fans progress out to the terminal reporter (when enabled) and the
223
- # checkpoint writer (when enabled and a file report is configured), so a
224
- # long run persists partial results incrementally.
225
- def progress_reporter
226
- CompositeProgressReporter.for(config:, source_provider:, full_run: full_run?)
227
- end
228
-
229
- def history_store
230
- @history_store ||= MutantHistoryStore.new(
231
- path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME), per_test_coverage:
232
- )
233
- end
234
-
235
- # One shared live view of the per-test coverage map: the incremental
236
- # filter proves survivor reuse against it and the history store records
237
- # the same intersection set — one implementation, one snapshot.
238
- def per_test_coverage
239
- @per_test_coverage ||= PerTestCoverage.new(reports_dir: config.reports_dir)
240
- end
210
+ def progress_reporter = deps.progress_reporter(full_run: full_run?)
241
211
 
242
212
  def source_files
243
- @source_files ||= filter_changed(reject_excluded(included_source_files))
213
+ @source_files ||= source_file_selection.call
244
214
  end
245
215
 
246
- def included_source_files
247
- Array(config.includes).flat_map do |include_path|
248
- Dir.glob(File.join(include_path, "**", "*.rb"))
249
- end.uniq
250
- end
251
-
252
- # Drops files matched by any `excludes:` glob (e.g. standalone entry points
253
- # that cannot be mutation-tested in-process). Excludes apply regardless of
254
- # the --since filter.
255
- def reject_excluded(files)
256
- excluded = excluded_source_files
257
- return files if excluded.empty?
258
-
259
- files.reject { |path| excluded.include?(normalize_path(path)) }
260
- end
261
-
262
- def excluded_source_files
263
- Array(config.excludes)
264
- .flat_map { |pattern| Dir.glob(pattern) }
265
- .map { |path| normalize_path(path) }
266
- end
267
-
268
- def filter_changed(files)
269
- return files unless @since
270
-
271
- changed_file_set = changed_paths_since.map { |path| normalize_path(path) }
272
- changed_file_set += covered_sources_for_changed_tests(changed_file_set)
273
- files.select { |path| changed_file_set.include?(normalize_path(path)) }
274
- end
275
-
276
- # Committed changes since the ref plus the current working tree (tracked
277
- # dirty and untracked files): the working tree is what gets tested, so it
278
- # is always part of "changed since REF".
279
- def changed_paths_since
280
- git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
281
- git_diff_analyzer.working_tree_changed_files
282
- end
283
-
284
- # Changed test files select the source files they cover, so an edited
285
- # test re-tests the subjects it can kill. Uses the per-test map from the
286
- # previous run — Gate 1 runs before this run's bootstrap finishes.
287
- def covered_sources_for_changed_tests(changed_paths)
288
- changed_paths
289
- .flat_map { |path| per_test_coverage.source_files_covered_by(path) }
290
- .map { |path| normalize_path(path) }
291
- end
292
-
293
- def pattern_subjects
294
- Array(@subjects)
295
- end
296
-
297
- def unique_subjects(subjects)
298
- subjects.uniq { |subject| [subject.expression, subject.source_file] }
216
+ def source_file_selection
217
+ @source_file_selection ||= SourceFileSelection.new(
218
+ config: config, since: @since,
219
+ git_diff_analyzer: git_diff_analyzer, per_test_coverage: per_test_coverage
220
+ )
299
221
  end
300
222
 
301
- def normalize_path(path)
302
- File.expand_path(path)
303
- end
223
+ def result_thresholds = optional_config(:thresholds)
304
224
 
305
- def result_thresholds
306
- return nil unless config.respond_to?(:thresholds)
225
+ def result_coverage_criteria = optional_config(:coverage_criteria)
307
226
 
308
- config.thresholds
309
- end
227
+ # Specs pass bare config doubles that expose only what the example needs, so
228
+ # scoring inputs are read defensively rather than assumed present.
229
+ def optional_config(name) = config.respond_to?(name) ? config.public_send(name) : nil
310
230
 
311
- def survivor_rerun?
312
- !@survivors_from.nil?
313
- end
231
+ def survivor_rerun? = !@survivors_from.nil?
314
232
 
315
233
  # Mutation-scope full run, controlling Result#authoritative? — distinct
316
234
  # from the per-test-coverage plan's test-suite-scope "full run".
317
235
  def full_run?
318
- pattern_subjects.empty? && @since.nil? && !survivor_rerun?
236
+ Array(@subjects).empty? && @since.nil? && !survivor_rerun?
319
237
  end
320
238
 
321
239
  def survivor_strategy