henitai 0.5.3 → 0.6.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +222 -1
  3. data/README.md +100 -13
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +36 -10
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +7 -5
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +225 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module ForkServer
5
+ # The process primitives {SlotScheduler} drives, for a parallel run whose
6
+ # mutant processes belong to the fork server (ADR-15).
7
+ #
8
+ # The parent cannot wait on a grandchild, so a served pid completes when
9
+ # the server relays its +exited+ event. A pid forked locally (the per-mutant
10
+ # fallback) is waited on as usual. Signals go straight to the OS: every
11
+ # mutant process leads its own process group.
12
+ class Runtime
13
+ WAIT_SECONDS = 5.0
14
+ ANY_PID = -1
15
+
16
+ # @param client [Client]
17
+ # @param base [#clock_gettime, #wait2, #kill, #wait, #pause, #trap]
18
+ def initialize(client:, base: ProcessWorkerRunner::Runtime.new)
19
+ @client = client
20
+ @base = base
21
+ @served = []
22
+ @local = []
23
+ end
24
+
25
+ def served(pid)
26
+ @served << pid
27
+ end
28
+
29
+ def local(pid)
30
+ @local << pid
31
+ end
32
+
33
+ # Sockets the event loop must wake on besides its signal pipe.
34
+ def wakeup_ios
35
+ @client.lost? ? [] : [@client.io]
36
+ end
37
+
38
+ # An event already read off the socket does not wake a select on it.
39
+ def pending_events?
40
+ @client.pending_events?
41
+ end
42
+
43
+ def wait2(pid, flags = nil)
44
+ return any_exit(flags) if pid == ANY_PID
45
+ return served_exit(pid) if @served.include?(pid)
46
+
47
+ local_exit(pid, flags)
48
+ end
49
+
50
+ def wait(pid)
51
+ return @base.wait(pid) unless @served.include?(pid)
52
+
53
+ status = await_exit(pid)
54
+ @served.delete(pid) if status
55
+ status
56
+ end
57
+
58
+ # A served process leads no group until its bootstrap ran +setpgid+;
59
+ # until then the signal goes to the process itself.
60
+ def kill(signal, pid)
61
+ @base.kill(signal, pid)
62
+ rescue Errno::ESRCH
63
+ raise unless pid.negative? && @served.include?(-pid)
64
+
65
+ @base.kill(signal, -pid)
66
+ end
67
+
68
+ def clock_gettime(clock_id) = @base.clock_gettime(clock_id)
69
+ def pause(seconds) = @base.pause(seconds)
70
+ def trap(signal, handler = nil, &) = @base.trap(signal, handler, &)
71
+
72
+ private
73
+
74
+ def any_exit(flags)
75
+ finished = @client.poll_any_exit
76
+ if finished
77
+ @served.delete(finished.first)
78
+ return finished
79
+ end
80
+
81
+ @local.each do |pid|
82
+ result = local_exit(pid, flags)
83
+ return result if result
84
+ end
85
+ nil
86
+ end
87
+
88
+ def served_exit(pid)
89
+ status = @client.poll_exit(pid)
90
+ return nil unless status
91
+
92
+ @served.delete(pid)
93
+ [pid, status]
94
+ end
95
+
96
+ def local_exit(pid, flags)
97
+ result = @base.wait2(pid, flags)
98
+ @local.delete(pid) if result
99
+ result
100
+ end
101
+
102
+ def await_exit(pid)
103
+ @client.wait_exit(pid, WAIT_SECONDS)
104
+ rescue Client::ServerLost
105
+ @client.poll_exit(pid)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module ForkServer
5
+ # The request loop of the fork server process. It boots once (the boot
6
+ # callable loads the integration environment), then forks one mutant
7
+ # process per `run` request through the launcher and relays how each one
8
+ # ended, because only this process can wait on them.
9
+ class Server
10
+ POLL_SECONDS = 0.5
11
+
12
+ # @param socket [IO] the server end of the connection to the parent
13
+ # @param boot [#call] loads the environment; raising reports `boot_failed`
14
+ # @param launcher [#call] request Hash -> pid of the forked mutant process
15
+ # @param reaper [#call] -> Array of [pid, Status] for ended children
16
+ # @param wake_reader [IO, nil] readable when a child ended (CHLD wakeup)
17
+ # @param killer [#call] pid -> kills that pid's process group
18
+ # rubocop:disable Metrics/ParameterLists
19
+ def initialize(socket:, boot:, launcher:, reaper: Reaper.method(:reap), wake_reader: nil,
20
+ killer: GroupKiller.method(:kill))
21
+ # rubocop:enable Metrics/ParameterLists
22
+ @socket = socket
23
+ @reader = Protocol::Reader.new(socket)
24
+ @boot = boot
25
+ @launcher = launcher
26
+ @reaper = reaper
27
+ @wake_reader = wake_reader
28
+ @killer = killer
29
+ @live = []
30
+ @shutdown = false
31
+ end
32
+
33
+ def serve
34
+ return unless announce_boot
35
+
36
+ until @shutdown
37
+ wait_for_activity
38
+ relay_exits
39
+ handle_requests
40
+ end
41
+ ensure
42
+ @live.each { |pid| @killer.call(pid) }
43
+ end
44
+
45
+ private
46
+
47
+ def announce_boot
48
+ @boot.call
49
+ send_event("type" => "ready")
50
+ true
51
+ rescue StandardError, ScriptError, SystemExit => e
52
+ send_event("type" => "boot_failed", "reason" => "#{e.class}: #{e.message}")
53
+ false
54
+ end
55
+
56
+ def wait_for_activity
57
+ ready = IO.select([@socket, @wake_reader].compact, nil, nil, POLL_SECONDS)
58
+ drain_wake_reader if @wake_reader && ready&.first&.include?(@wake_reader)
59
+ end
60
+
61
+ def drain_wake_reader
62
+ loop { @wake_reader.read_nonblock(4096) }
63
+ rescue IO::WaitReadable, EOFError
64
+ nil
65
+ end
66
+
67
+ def relay_exits
68
+ @reaper.call.each do |pid, status|
69
+ next unless @live.delete(pid)
70
+
71
+ send_event({ "type" => "exited", "pid" => pid }.merge(status.to_event))
72
+ end
73
+ end
74
+
75
+ def handle_requests
76
+ loop do
77
+ message = @reader.next_message(0)
78
+ return @shutdown = true if message.nil?
79
+ return unless message.is_a?(Hash)
80
+
81
+ dispatch(message)
82
+ end
83
+ end
84
+
85
+ def dispatch(message)
86
+ case message["type"]
87
+ when "run" then launch(message)
88
+ when "shutdown" then @shutdown = true
89
+ end
90
+ end
91
+
92
+ def launch(request)
93
+ pid = @launcher.call(request)
94
+ @live << pid
95
+ send_event("type" => "spawned", "request_id" => request["request_id"], "pid" => pid)
96
+ rescue StandardError => e
97
+ send_event("type" => "spawn_failed", "request_id" => request["request_id"],
98
+ "reason" => "#{e.class}: #{e.message}")
99
+ end
100
+
101
+ def send_event(event)
102
+ @socket.write(Protocol.encode(event))
103
+ rescue IOError, SystemCallError
104
+ @shutdown = true
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+
6
+ module Henitai
7
+ module ForkServer
8
+ # Forks the fork server from the parent, after the mutants exist, and
9
+ # returns the parent's Client for it. In the server process: set up like
10
+ # any mutant child, send boot output to a log file, boot the integration
11
+ # environment once, then serve requests until told to stop.
12
+ class ServerProcess
13
+ def self.start(integration:, mutants:, test_files:, log_path:)
14
+ new(integration:, mutants:, test_files:, log_path:).start
15
+ end
16
+
17
+ def initialize(integration:, mutants:, test_files:, log_path:)
18
+ @integration = integration
19
+ @mutants = mutants.to_h { |mutant| [mutant.id, mutant] }
20
+ @test_files = test_files
21
+ @log_path = log_path
22
+ end
23
+
24
+ # @return [Client]
25
+ def start
26
+ FileUtils.mkdir_p(File.dirname(@log_path))
27
+ parent_socket, server_socket = UNIXSocket.pair
28
+ InheritedFdRegistry.register(parent_socket)
29
+ parent_pid = Process.pid
30
+ pid = Process.fork do
31
+ parent_socket.close
32
+ serve(server_socket, parent_pid)
33
+ end
34
+ server_socket.close
35
+ Client.new(socket: parent_socket, pid:)
36
+ end
37
+
38
+ private
39
+
40
+ def serve(socket, parent_pid)
41
+ Integration::ChildBootstrap.after_fork!(parent_pid:)
42
+ redirect_output
43
+ wake_reader, wake_writer = IO.pipe
44
+ [wake_reader, wake_writer].each { |io| InheritedFdRegistry.register(io) }
45
+ launcher = Launcher.new(integration: @integration, mutants: @mutants, socket:)
46
+ boot = -> { boot_environment(wake_writer) }
47
+ Server.new(socket:, boot:, launcher:, wake_reader:).serve
48
+ Process.exit!(0)
49
+ end
50
+
51
+ def boot_environment(wake_writer)
52
+ @integration.preload_environment(@test_files)
53
+ Signal.trap("CHLD") { wake(wake_writer) }
54
+ end
55
+
56
+ def wake(writer)
57
+ writer.write_nonblock(".")
58
+ rescue IO::WaitWritable, IOError
59
+ nil
60
+ end
61
+
62
+ def redirect_output
63
+ File.open(@log_path, "w") do |log|
64
+ $stdout.reopen(log)
65
+ $stderr.reopen(log)
66
+ end
67
+ $stdin.reopen(File::NULL)
68
+ $stdout.sync = true
69
+ $stderr.sync = true
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module ForkServer
5
+ # Stands in for Process::Status of a mutant process that the parent cannot
6
+ # wait on itself: the fork server reaps it and relays how it ended.
7
+ # Answers the subset MutantVerdict and ScenarioExecutionResult read.
8
+ class Status
9
+ attr_reader :exitstatus, :termsig
10
+
11
+ def self.from_event(event)
12
+ new(exitstatus: event["exitstatus"], termsig: event["termsig"])
13
+ end
14
+
15
+ def self.from_process_status(status)
16
+ new(exitstatus: status.exitstatus, termsig: status.termsig)
17
+ end
18
+
19
+ # Stands in for a process whose end the lost server never relayed. It
20
+ # says nothing about the mutant, so it must never become a verdict.
21
+ def self.lost
22
+ new(exitstatus: nil, termsig: 9, lost: true)
23
+ end
24
+
25
+ def initialize(exitstatus:, termsig:, lost: false)
26
+ @exitstatus = exitstatus
27
+ @termsig = termsig
28
+ @lost = lost
29
+ end
30
+
31
+ def lost?
32
+ @lost
33
+ end
34
+
35
+ def exited?
36
+ !signaled?
37
+ end
38
+
39
+ def signaled?
40
+ !termsig.nil?
41
+ end
42
+
43
+ def success?
44
+ !signaled? && exitstatus.to_i.zero?
45
+ end
46
+
47
+ def to_event
48
+ { "exitstatus" => exitstatus, "termsig" => termsig }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fork_server/protocol"
4
+ require_relative "fork_server/status"
5
+ require_relative "fork_server/reaper"
6
+ require_relative "fork_server/group_killer"
7
+ require_relative "fork_server/group_terminator"
8
+ require_relative "fork_server/server"
9
+ require_relative "fork_server/launcher"
10
+ require_relative "fork_server/client"
11
+ require_relative "fork_server/runtime"
12
+ require_relative "fork_server/server_process"
13
+ require_relative "fork_server/mutant_runner"
14
+
15
+ module Henitai
16
+ # Opt-in execution model (ADR-15): the test environment boots once in a
17
+ # long-lived server process and every mutant process is forked from it.
18
+ module ForkServer
19
+ BOOT_TIMEOUT = 300.0
20
+
21
+ # Starts a server for the run and returns a runner that sends mutants to
22
+ # it, or nil, after a warning, when the server cannot be booted. The
23
+ # caller then keeps the per-mutant boot.
24
+ #
25
+ # @return [MutantRunner, nil]
26
+ # rubocop:disable Metrics/ParameterLists
27
+ def self.start(integration:, mutants:, test_files:, log_path:, warner: nil, boot_timeout: BOOT_TIMEOUT,
28
+ fallback_timeout: nil)
29
+ warner ||= ->(message) { Kernel.warn(message) }
30
+ client = ServerProcess.start(integration:, mutants:, test_files:, log_path:)
31
+ begin
32
+ client.wait_until_ready(boot_timeout)
33
+ rescue Client::BootFailed, Client::ServerLost => e
34
+ client.shutdown
35
+ warner.call("henitai: the fork server failed to boot (#{e.message}); see #{log_path}; " \
36
+ "falling back to per-mutant boot")
37
+ return nil
38
+ end
39
+ MutantRunner.new(integration:, client:, warner:, fallback_timeout:)
40
+ end
41
+ # rubocop:enable Metrics/ParameterLists
42
+ end
43
+ end
@@ -18,7 +18,10 @@ module Henitai
18
18
  mutation-history.sqlite3
19
19
  mutation-history.json
20
20
  henitai_per_test.json
21
+ henitai_suite_boot.json
21
22
  henitai_dependency_manifest.json
23
+ henitai_flaky_tests.json
24
+ henitai_suite_outcomes.json
22
25
  .henitai-run.lock
23
26
  mutation-logs
24
27
  mutation-coverage
@@ -9,14 +9,23 @@ module Henitai
9
9
  # Shells out to git to discover changed files between two refs.
10
10
  #
11
11
  # By default the analyzer runs in the current working directory. Callers can
12
- # pass dir: to point it at another repository root without changing cwd.
12
+ # pass dir: to point it at another directory without changing cwd.
13
+ #
14
+ # Every path is reported relative to that directory, which need not be the
15
+ # repository root: `git diff` would otherwise print root-relative paths
16
+ # while `git ls-files` prints directory-relative ones. Paths are read
17
+ # NUL-separated so git never quotes non-ASCII names.
13
18
  class GitDiffAnalyzer
14
- def changed_files(from:, to:, dir: Dir.pwd)
15
- stdout, stderr, status = git_diff(dir, "--name-only", from, to)
19
+ NAME_ONLY = ["--name-only", "--relative", "-z"].freeze
16
20
 
17
- raise GitDiffError, stderr.strip unless status.success?
21
+ def changed_files(from:, to:, dir: Dir.pwd)
22
+ diff_paths(dir, from, to)
23
+ end
18
24
 
19
- stdout.split("\n").reject(&:empty?)
25
+ # Changes on HEAD since its merge base with ref. Commits that landed on
26
+ # ref after the fork point are not part of this diff.
27
+ def changed_files_since(ref, dir: Dir.pwd)
28
+ diff_paths(dir, "#{ref}...HEAD")
20
29
  end
21
30
 
22
31
  def working_tree_changed_files(dir: Dir.pwd)
@@ -55,7 +64,7 @@ module Henitai
55
64
  end
56
65
 
57
66
  def changed_line_ranges(path, from:, to:, dir:)
58
- stdout, stderr, status = git_diff(dir, "--unified=0", from, to, "--", path)
67
+ stdout, stderr, status = git_diff(dir, "--unified=0", "--relative", from, to, "--", path)
59
68
 
60
69
  raise GitDiffError, stderr.strip unless status.success?
61
70
 
@@ -94,23 +103,31 @@ module Henitai
94
103
  Open3.capture3(*command)
95
104
  end
96
105
 
97
- def working_tree_tracked_files(dir)
98
- stdout, stderr, status = git_diff(dir, "--name-only", "HEAD")
106
+ def diff_paths(dir, *revisions)
107
+ stdout, stderr, status = git_diff(dir, *NAME_ONLY, *revisions)
99
108
 
100
109
  raise GitDiffError, stderr.strip unless status.success?
101
110
 
102
- stdout.split("\n").reject(&:empty?)
111
+ paths(stdout)
112
+ end
113
+
114
+ def working_tree_tracked_files(dir)
115
+ diff_paths(dir, "HEAD")
103
116
  end
104
117
 
105
118
  def untracked_files(dir)
106
119
  command = ["git"]
107
120
  command += ["-C", dir] if dir
108
- command += ["ls-files", "--others", "--exclude-standard"]
121
+ command += ["ls-files", "--others", "--exclude-standard", "-z"]
109
122
  stdout, stderr, status = Open3.capture3(*command)
110
123
 
111
124
  raise GitDiffError, stderr.strip unless status.success?
112
125
 
113
- stdout.split("\n").reject(&:empty?)
126
+ paths(stdout)
127
+ end
128
+
129
+ def paths(stdout)
130
+ stdout.dup.force_encoding(Encoding::UTF_8).split("\0").reject(&:empty?)
114
131
  end
115
132
  end
116
133
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "signature_line"
3
4
  require_relative "verdict_fingerprint"
4
5
 
5
6
  module Henitai
@@ -8,23 +9,26 @@ module Henitai
8
9
  #
9
10
  # Reuse rules (conservative — any doubt resolves to re-execution):
10
11
  #
12
+ # * Both verdicts: the stored {ReuseContext} fingerprint (test
13
+ # dependencies, verdict-relevant configuration, henitai version) must
14
+ # equal the current one. Rows recorded without a context are never
15
+ # reused.
11
16
  # * Killed: the stored verdict must carry both fingerprints, and both the
12
17
  # subject's source and every recorded covering test file must be
13
18
  # byte-identical to what was recorded. Killed is monotone, so no live
14
19
  # coverage is needed.
15
20
  # * Survived: additionally the LIVE covering set (the full-map
16
21
  # intersection computed via {PerTestCoverage}) must equal the recorded
17
- # set in membership, and the run-level dependency fingerprint must be
18
- # unchanged — a new or edited test reaching the mutant always forces a
19
- # re-run (ADR-11). Without a live per-test map or dependency
20
- # fingerprint, survivors always re-execute.
22
+ # set in membership — a new or edited test reaching the mutant always
23
+ # forces a re-run (ADR-11). Without a live per-test map or a context,
24
+ # survivors always re-execute.
21
25
  #
22
26
  # Timeouts, errors, unknown ids and legacy rows always re-execute.
23
27
  class IncrementalFilter
24
- def initialize(history_store:, per_test_coverage: nil, dependency_fingerprint: nil)
28
+ def initialize(history_store:, per_test_coverage: nil, reuse_context: nil)
25
29
  @history_store = history_store
26
30
  @per_test_coverage = per_test_coverage
27
- @dependency_fingerprint = dependency_fingerprint
31
+ @reuse_context = reuse_context
28
32
  end
29
33
 
30
34
  # @return [Array<Mutant>] the same collection; cache hits get the stored
@@ -48,7 +52,7 @@ module Henitai
48
52
 
49
53
  private
50
54
 
51
- attr_reader :history_store, :per_test_coverage, :dependency_fingerprint
55
+ attr_reader :history_store, :per_test_coverage, :reuse_context
52
56
 
53
57
  # MutantIdentity deliberately omits source coordinates (line-drift
54
58
  # tolerance), so distinct mutants inside one subject can share a stable
@@ -75,7 +79,9 @@ module Henitai
75
79
 
76
80
  def killed_reusable?(mutant, verdict)
77
81
  subject_source_unchanged?(mutant, verdict) &&
78
- VerdictFingerprint.tests_fingerprint_current?(verdict.fetch(:covered_tests_fingerprint))
82
+ VerdictFingerprint.tests_fingerprint_current?(
83
+ verdict.fetch(:covered_tests_fingerprint), context_sha: context_sha
84
+ )
79
85
  end
80
86
 
81
87
  # The filter checks per-test map availability itself instead of trusting
@@ -84,17 +90,21 @@ module Henitai
84
90
  # unknowable means re-execute.
85
91
  def survived_reusable?(mutant, verdict)
86
92
  return false unless per_test_coverage&.available?
93
+ return false if SignatureLine.on?(mutant)
87
94
  return false unless subject_source_unchanged?(mutant, verdict)
88
95
 
89
96
  VerdictFingerprint.survivor_fingerprint_current?(
90
97
  verdict.fetch(:covered_tests_fingerprint),
91
98
  live_paths: per_test_coverage.tests_covering(mutant),
92
- dependency_sha: dependency_fingerprint
99
+ context_sha: context_sha
93
100
  )
94
101
  end
95
102
 
103
+ def context_sha = reuse_context&.fingerprint
104
+
96
105
  def subject_source_unchanged?(mutant, verdict)
97
- VerdictFingerprint.subject_source_hash(mutant) == verdict.fetch(:subject_source_hash)
106
+ current = VerdictFingerprint.subject_source_hash(mutant)
107
+ !current.nil? && current == verdict.fetch(:subject_source_hash)
98
108
  end
99
109
  end
100
110
  end
@@ -15,6 +15,9 @@ module Henitai
15
15
  class Base
16
16
  include ChildRuntimeControl
17
17
 
18
+ EXITED_GROUP_POLL_INTERVAL = 0.05
19
+ EXITED_GROUP_POLL_COUNT = 40
20
+
18
21
  # Child-run diagnostics. Public so the child-side modules mixed into
19
22
  # concrete adapters can reach it without `send`.
20
23
  def child_debug_log
@@ -57,6 +60,20 @@ module Henitai
57
60
  false
58
61
  end
59
62
 
63
+ # True when the integration can boot once in a fork server (ADR-15) and
64
+ # run mutants from the booted state.
65
+ def fork_server_supported?
66
+ false
67
+ end
68
+
69
+ # Loads the test environment and the given test files without running
70
+ # anything; only called inside the fork server process.
71
+ #
72
+ # @param test_files [Array<String>]
73
+ def preload_environment(test_files)
74
+ raise NotImplementedError
75
+ end
76
+
60
77
  def wait_with_timeout(pid, timeout)
61
78
  wakeup = Henitai.const_get(:ProcessWakeup).new.install
62
79
  return Process.last_status if wait_nonblocking(pid)
@@ -78,6 +95,29 @@ module Henitai
78
95
  end
79
96
 
80
97
  def cleanup_process_group(pid)
98
+ if leader_exited?(pid)
99
+ terminate_exited_group(pid)
100
+ else
101
+ terminate_running_group(pid)
102
+ end
103
+ rescue Errno::EPERM
104
+ cleanup_child_process(pid)
105
+ rescue Errno::ESRCH
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ # A leader that already exited normally cannot be waited on again, so
112
+ # the group is polled for members instead of burning the grace period.
113
+ def terminate_exited_group(pid)
114
+ Process.kill(:SIGTERM, -pid)
115
+ return unless group_members_remain?(pid)
116
+
117
+ Process.kill(:SIGKILL, -pid)
118
+ end
119
+
120
+ def terminate_running_group(pid)
81
121
  grace_period = 2.0
82
122
  wakeup = Henitai.const_get(:ProcessWakeup).new.install
83
123
  Process.kill(:SIGTERM, -pid)
@@ -88,15 +128,34 @@ module Henitai
88
128
  return if wait_nonblocking(pid)
89
129
 
90
130
  Process.kill(:SIGKILL, -pid)
91
- rescue Errno::EPERM
92
- cleanup_child_process(pid)
93
- rescue Errno::ESRCH
94
- nil
95
131
  ensure
96
132
  wakeup&.close
97
133
  end
98
134
 
99
- private
135
+ def leader_exited?(pid)
136
+ !Process.wait(pid, Process::WNOHANG).nil?
137
+ rescue Errno::ECHILD, Errno::ESRCH
138
+ true
139
+ end
140
+
141
+ def group_members_remain?(pid)
142
+ EXITED_GROUP_POLL_COUNT.times do
143
+ return false unless group_alive?(pid)
144
+
145
+ pause(EXITED_GROUP_POLL_INTERVAL)
146
+ end
147
+ group_alive?(pid)
148
+ end
149
+
150
+ # Signal 0 probes existence only: ESRCH means the group is empty.
151
+ def group_alive?(pid)
152
+ Process.kill(0, -pid)
153
+ true
154
+ rescue Errno::ESRCH
155
+ false
156
+ rescue Errno::EPERM
157
+ true
158
+ end
100
159
 
101
160
  def pause(seconds)
102
161
  sleep(seconds)
@@ -133,6 +192,10 @@ module Henitai
133
192
  { "PARALLEL_WORKERS" => "1" }
134
193
  end
135
194
 
195
+ def suite_process_env
196
+ subprocess_env.merge(SuiteBootClock::STARTED_AT_ENV => Time.now.to_f.to_s)
197
+ end
198
+
136
199
  def wait_nonblocking(pid)
137
200
  Process.wait(pid, Process::WNOHANG)
138
201
  rescue Errno::ECHILD, Errno::ESRCH
@@ -143,6 +206,10 @@ module Henitai
143
206
  @scenario_log_support ||= ScenarioLogSupport.new
144
207
  end
145
208
 
209
+ def child_report_store
210
+ @child_report_store ||= ChildReportStore.new
211
+ end
212
+
146
213
  def with_subprocess_env
147
214
  original_env = {} # : Hash[String, String?]
148
215
  subprocess_env.each do |key, value|