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
@@ -6,12 +6,15 @@ require_relative "process_wakeup"
6
6
  require_relative "integration/child_bootstrap"
7
7
  require_relative "integration/rspec_process_runner"
8
8
  require_relative "integration/scenario_log_support"
9
+ require_relative "integration/child_report_store"
9
10
  require_relative "integration/coverage_suppression"
10
11
  require_relative "integration/child_debug_log"
11
12
  require_relative "integration/loaded_features"
12
13
  require_relative "integration/base"
13
14
  require_relative "integration/mutant_run_support"
14
15
  require_relative "integration/rspec_child_runner"
16
+ require_relative "rspec_outcomes"
17
+ require_relative "integration/rspec_preload"
15
18
  require_relative "integration/rspec_test_selection"
16
19
 
17
20
  module Henitai
@@ -50,6 +53,7 @@ module Henitai
50
53
  class Rspec < Base
51
54
  include MutantRunSupport
52
55
  include RspecChildRunner
56
+ include RspecPreload
53
57
  include RspecTestSelection
54
58
 
55
59
  DEFAULT_SUITE_TIMEOUT = 300.0
@@ -98,7 +102,7 @@ module Henitai
98
102
  File.open(log_paths[:stdout_path], "w") do |stdout_file|
99
103
  File.open(log_paths[:stderr_path], "w") do |stderr_file|
100
104
  Process.spawn(
101
- subprocess_env,
105
+ suite_process_env,
102
106
  *suite_command(test_files),
103
107
  out: stdout_file,
104
108
  err: stderr_file,
@@ -110,14 +114,23 @@ module Henitai
110
114
 
111
115
  private
112
116
 
113
- def run_tests(test_files)
117
+ def outcome_recording_hook(outcomes, after_load)
118
+ lambda do
119
+ RspecOutcomes.register(outcomes, ::RSpec.__send__(:configuration).reporter)
120
+ after_load.nil? || after_load.call
121
+ end
122
+ end
123
+
124
+ # Yields once the spec files are loaded and before they run; a falsy
125
+ # block result skips the run (see MutantRunSupport#run_tests_in_child).
126
+ def run_tests(test_files, outcomes, &after_load)
114
127
  require "rspec/core"
115
128
  ::RSpec.__send__(:configuration).fail_if_no_examples = true
116
129
  log = child_debug_log
117
130
  log.rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
118
131
  log.example_count("before_run")
119
132
  log.write("[henitai-debug-child] runner_run_start")
120
- status = run_rspec_runner(test_files)
133
+ status = run_rspec_runner(test_files, &outcome_recording_hook(outcomes, after_load))
121
134
  log.write("[henitai-debug-child] runner_run_return status=#{status.inspect}")
122
135
  log.example_count("after_run")
123
136
  log.rspec_exit(status)
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "minitest"
4
4
  require "henitai/per_test_coverage_collector"
5
+ require "henitai/test_file_outcomes"
6
+ require "henitai/minitest_result_file"
5
7
 
6
8
  module Henitai
7
9
  # Minitest reporter that collects per-test line coverage deltas.
@@ -11,26 +13,40 @@ module Henitai
11
13
  # to PerTestCoverageCollector so the JSON output format is identical to the
12
14
  # RSpec integration.
13
15
  class MinitestCoverageReporter < Minitest::Reporter
14
- def initialize(io = $stdout, options = {})
16
+ def initialize(io = $stdout, options = {}, boot_clock: SuiteBootClock.from_env(ENV))
17
+ super(io, options)
18
+ @collector = PerTestCoverageCollector.new(boot_clock:)
19
+ @outcomes = TestFileOutcomes.new
20
+ end
21
+
22
+ def prerecord(klass, name)
15
23
  super
16
- @collector = PerTestCoverageCollector.new
24
+ @collector.test_started
17
25
  end
18
26
 
19
27
  def record(result)
20
28
  super
21
- @collector.record_test(
22
- relative_to_pwd(result.source_location.first),
23
- duration: result.time
24
- )
29
+ record_coverage(result)
30
+ MinitestResultFile.record(@outcomes, result)
25
31
  end
26
32
 
27
33
  def report
28
34
  super
29
35
  @collector.write_report
36
+ @outcomes.write(TestFileOutcomes.default_report_path)
30
37
  end
31
38
 
32
39
  private
33
40
 
41
+ # A result without a resolvable file is skipped, so its coverage delta is
42
+ # not attributed to the next recorded test.
43
+ def record_coverage(result)
44
+ file = MinitestResultFile.for(result)
45
+ return @collector.skip_test unless file
46
+
47
+ @collector.record_test(relative_to_pwd(file), duration: result.time)
48
+ end
49
+
34
50
  def relative_to_pwd(path)
35
51
  prefix = "#{Dir.pwd}#{File::SEPARATOR}"
36
52
  path.start_with?(prefix) ? path.sub(prefix, "") : path
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Finds the test file a Minitest result belongs to.
5
+ #
6
+ # `Result#source_location` is where the test method is defined. For tests
7
+ # inherited from a base class or an included module that is a shared file,
8
+ # not the file of the test class that ran, so the file that defines the
9
+ # result's class is used for those. A method defined directly on the class
10
+ # keeps its own file: the class may have been reopened in another test file.
11
+ # The method location is also the fallback for classes that cannot be
12
+ # resolved (anonymous or removed).
13
+ module MinitestResultFile
14
+ module_function
15
+
16
+ # Records the result's outcome; a failure with no usable file is
17
+ # recorded as unattributed rather than dropped.
18
+ def record(outcomes, result)
19
+ failed = !result.passed? && !result.skipped?
20
+ file = self.for(result)
21
+ if file
22
+ outcomes.record(file, failed:)
23
+ elsif failed
24
+ outcomes.record_unattributed_failure
25
+ end
26
+ end
27
+
28
+ # @return [String, nil] the test file, or nil when none can be found
29
+ def for(result)
30
+ return usable(method_file(result)) || usable(class_file(result)) if defined_on_class?(result)
31
+
32
+ usable(class_file(result)) || usable(method_file(result))
33
+ end
34
+
35
+ def defined_on_class?(result)
36
+ klass = Object.const_get(result.klass.to_s)
37
+ klass.instance_method(result.name).owner == klass
38
+ rescue NameError
39
+ false
40
+ end
41
+
42
+ def class_file(result)
43
+ class_file_of(result.klass.to_s)
44
+ end
45
+
46
+ # The file that defines the named class; nil for anonymous or unknown names.
47
+ def class_file_of(name)
48
+ usable(Object.const_source_location(name)&.first)
49
+ rescue NameError
50
+ nil
51
+ end
52
+
53
+ def method_file(result)
54
+ result.source_location&.first
55
+ end
56
+
57
+ # Minitest reports no location for some results and "unknown" for others.
58
+ def usable(file)
59
+ file if file.is_a?(String) && !file.empty? && file != "unknown" && file != "(eval)"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # The Ruby source that puts a mutant in place, and how to evaluate it.
6
+ #
7
+ # +mode+ is :toplevel for a real +def+ re-evaluated inside the original
8
+ # module and class openers (ADR-13), evaluated at the top level with
9
+ # +file+ and +line+ so +__FILE__+, +__LINE__+ and backtraces stay those of
10
+ # the original source; +def_offset+ is the index of the +def+ line within
11
+ # +source+. +mode+ is :class_eval for the +define_method+ fallback,
12
+ # evaluated inside the target module.
13
+ #
14
+ # For :toplevel, +lexical_scope+ holds the opener lines (e.g. "module A",
15
+ # "class B"), +owner+ the expression the +def+ is defined on ("self" or
16
+ # the receiver of +def Other.name+), and +singleton+ whether it is a
17
+ # +defs+. They let a replayed recipe reopen the scope without the AST.
18
+ Activation = Struct.new(:source, :mode, :file, :line, :def_offset, :lexical_scope, :owner, :singleton,
19
+ keyword_init: true) do
20
+ def self.from_h(hash)
21
+ new(source: hash.fetch("source"), mode: hash.fetch("mode").to_sym, file: hash["file"],
22
+ line: hash["line"], def_offset: hash["defOffset"], lexical_scope: hash["lexicalScope"],
23
+ owner: hash["owner"], singleton: hash["singleton"])
24
+ end
25
+
26
+ def to_h
27
+ { "source" => source, "mode" => mode.to_s, "file" => file, "line" => line, "defOffset" => def_offset,
28
+ "lexicalScope" => lexical_scope, "owner" => owner, "singleton" => singleton }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -7,10 +7,16 @@ module Henitai
7
7
  class Mutant
8
8
  # Activates a mutant inside the forked child process.
9
9
  class Activator
10
+ SubjectNotLoadedError = LexicalTarget::SubjectNotLoadedError
11
+
12
+ # File name the activation source is evaluated under. It decides what
13
+ # +__FILE__+ and +__LINE__+ mean inside an activated mutant.
14
+ EVAL_FILE = __FILE__
15
+
10
16
  # Filters "already initialized constant" C-level warnings that fire when
11
17
  # a source file is loaded into a process that already has the constant
12
18
  # defined via require. Uses a thread-local flag so the filter is active
13
- # only during load_source_file, leaving all other warnings untouched.
19
+ # only during LexicalTarget#load_source_file, leaving all other warnings untouched.
14
20
  module ConstantRedefinitionFilter
15
21
  PATTERN = /already initialized constant|previous definition of/
16
22
  private_constant :PATTERN
@@ -27,23 +33,34 @@ module Henitai
27
33
  new.activate!(mutant)
28
34
  end
29
35
 
30
- # Returns the +define_method+ source string for +mutant+ without
31
- # actually evaluating it. Used to pre-compute activation recipes.
32
- # Returns nil if the source cannot be computed (e.g. unsupported AST node).
33
- def self.activation_source_for(mutant)
34
- new.send(:method_source, mutant)
36
+ # Returns the Activation for +mutant+ without evaluating it: a real
37
+ # +def+ in the original lexical scope when the subject can be reopened
38
+ # (ADR-13), otherwise the +define_method+ fallback. Used by the fidelity
39
+ # check and to pre-compute activation recipes. Returns nil if the source
40
+ # cannot be computed (e.g. unsupported AST node).
41
+ def self.activation_for(mutant)
42
+ new.activation_for(mutant)
35
43
  rescue StandardError
36
44
  nil
37
45
  end
38
46
 
47
+ def self.activation_source_for(mutant)
48
+ activation_for(mutant)&.source
49
+ end
50
+
51
+ def activation_for(mutant)
52
+ DefInjection.build(mutant, def_replacement(mutant)) ||
53
+ Activation.new(source: method_source(mutant), mode: :class_eval, file: EVAL_FILE)
54
+ end
55
+
39
56
  def activate!(mutant)
40
57
  subject = mutant.subject
41
58
  raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
42
59
 
43
- source = mutant.precomputed_activation_source || method_source(mutant)
44
- target = target_for(subject)
60
+ activation = mutant.precomputed_activation || activation_for(mutant)
61
+ target = activation.mode == :toplevel ? LexicalTarget.resolve(subject, activation) : target_for(subject)
45
62
  Henitai::WarningSilencer.silence do
46
- target.class_eval(source, __FILE__, __LINE__ + 1)
63
+ evaluate(activation, target, subject)
47
64
  nil
48
65
  end
49
66
  rescue Unparser::UnsupportedNodeError, SyntaxError
@@ -52,8 +69,44 @@ module Henitai
52
69
 
53
70
  private
54
71
 
72
+ def evaluate(activation, target, subject)
73
+ return inject_def(activation, target, subject) if activation.mode == :toplevel
74
+
75
+ target.class_eval(activation.source, EVAL_FILE, __LINE__)
76
+ end
77
+
78
+ # A re-evaluated +def+ is public, so the method's visibility -- or its
79
+ # +module_function+ copy -- is read before and restored after. +target+
80
+ # is already the singleton class for a class method.
81
+ def inject_def(activation, target, subject)
82
+ name = subject.method_name.to_sym
83
+ visibility = visibility_of(target, name)
84
+ copy = module_function_copy?(target, name, subject)
85
+ TOPLEVEL_BINDING.eval(activation.source, activation.file, activation.line)
86
+ return target.send(:module_function, name) if copy
87
+
88
+ target.send(visibility, name) if visibility
89
+ end
90
+
91
+ def visibility_of(owner, name)
92
+ return :private if owner.private_method_defined?(name)
93
+
94
+ :protected if owner.protected_method_defined?(name)
95
+ end
96
+
97
+ def module_function_copy?(target, name, subject)
98
+ subject.method_type == :instance && !target.is_a?(Class) &&
99
+ target.private_method_defined?(name) && target.singleton_class.method_defined?(name) &&
100
+ target.instance_method(name).source_location == target.method(name).source_location
101
+ end
102
+
103
+ def def_replacement(mutant)
104
+ original_range = mutant.original_node&.location&.expression
105
+ replacement_source(mutant, original_range) if original_range
106
+ end
107
+
55
108
  def target_for(subject)
56
- target = load_target(subject)
109
+ target = LexicalTarget.load_target(subject)
57
110
  subject.method_type == :class ? target.singleton_class : target
58
111
  end
59
112
 
@@ -73,7 +126,8 @@ module Henitai
73
126
  <<~RUBY
74
127
  __henitai_module_function__ = !is_a?(Class) &&
75
128
  private_method_defined?(:#{method_name}) &&
76
- singleton_class.method_defined?(:#{method_name})
129
+ singleton_class.method_defined?(:#{method_name}) &&
130
+ instance_method(:#{method_name}).source_location == method(:#{method_name}).source_location
77
131
  define_method(:#{method_name}) do |#{parameters}|
78
132
  #{replacement}
79
133
  end
@@ -96,8 +150,13 @@ module Henitai
96
150
  location = body.location
97
151
  return source_body(location, body) unless original_range && location
98
152
 
99
- replacement = compile_safe_unparse(mutant.mutated_node)
100
- body_source_for_location(location, original_range, replacement, body)
153
+ body_source_for_location(location, original_range, replacement_source(mutant, original_range), body)
154
+ end
155
+
156
+ # An unmutated control keeps the original text: re-rendering it would
157
+ # test the unparser, not the activation path.
158
+ def replacement_source(mutant, original_range)
159
+ Replacement.render(mutant, original_range)
101
160
  end
102
161
 
103
162
  def body_source_for_location(location, original_range, replacement, body)
@@ -157,55 +216,16 @@ module Henitai
157
216
  end
158
217
 
159
218
  def expression_source(location, original_range, replacement)
160
- source_range = location.expression
161
- return unless source_range
162
-
163
- replace_source_fragment(source_range, original_range, replacement)
164
- end
165
-
166
- def load_target(subject)
167
- Object.const_get(subject.namespace.delete_prefix("::"))
168
- rescue NameError
169
- load_source_file(subject)
170
- Object.const_get(subject.namespace.delete_prefix("::"))
171
- end
172
-
173
- def load_source_file(subject)
174
- source_file = subject.source_file || source_file_from_ast(subject)
175
- return unless source_file && File.file?(source_file)
176
-
177
- Thread.current[:henitai_filter_const_warnings] = true
178
- load(source_file)
179
- loaded_feature = File.expand_path(source_file)
180
- $LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
181
- ensure
182
- Thread.current[:henitai_filter_const_warnings] = false
183
- end
184
-
185
- def source_file_from_ast(subject)
186
- ast_node = subject.ast_node
187
- return unless ast_node
188
-
189
- location = ast_node.location
190
- return unless location
191
-
192
- expression = location.expression
193
- return unless expression
194
-
195
- expression.source_buffer.name
219
+ replace_source_fragment(location.expression, original_range, replacement)
196
220
  end
197
221
 
198
222
  def replace_source_fragment(source_range, original_range, replacement)
199
223
  source = source_range.source
200
224
  start = original_range.begin_pos - source_range.begin_pos
201
225
  stop = original_range.end_pos - source_range.begin_pos
202
- return unless start >= 0 && stop <= source.bytesize && start <= stop
203
-
204
- prefix = source.byteslice(0, start)
205
- suffix = source.byteslice(stop, source.bytesize - stop)
206
- return unless prefix && suffix
226
+ return unless start >= 0 && stop <= source.length && start <= stop
207
227
 
208
- prefix + replacement + suffix
228
+ source[0, start] + replacement + source[stop..]
209
229
  end
210
230
 
211
231
  def compile_safe_unparse(node)
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # Builds the :toplevel activation for a mutant (ADR-13): the subject's
6
+ # own +def+, with the mutation spliced in, wrapped in the module and class
7
+ # openers that surround it in the source.
8
+ #
9
+ # Re-evaluating a real +def+ in its lexical scope keeps everything a
10
+ # +define_method+ block loses: constant lookup through the enclosing
11
+ # modules, implicit +super+, +yield+ and +block_given?+, the exact
12
+ # parameter list, +__FILE__+ and +__LINE__+, and the file's
13
+ # +frozen_string_literal+ comment. Visibility is restored by the Activator.
14
+ #
15
+ # Returns nil when the subject cannot be reopened this way (a
16
+ # +define_method+ subject, a method in +class << obj+, a subject without
17
+ # recorded scope); the Activator then falls back to +define_method+.
18
+ class DefInjection
19
+ # Matches +# key: value+ and the emacs form +# -*- key: value -*-+ only,
20
+ # so prose that merely mentions a magic comment does not match.
21
+ MAGIC_COMMENT = /
22
+ \A\#\s*(?:-\*-(?:[^;:]*:[^;]*;)*\s*)?
23
+ (?:frozen_string_literal|shareable_constant_value|warn_indent)\s*:
24
+ /ix
25
+
26
+ def self.build(mutant, replacement)
27
+ new.build(mutant, replacement)
28
+ end
29
+
30
+ def build(mutant, replacement)
31
+ subject = mutant.subject
32
+ node = subject.ast_node
33
+ scope = subject.lexical_scope
34
+ return unless injectable?(node, scope)
35
+
36
+ def_source = spliced_def(node.location.expression, mutant.original_node, replacement)
37
+ return unless def_source
38
+
39
+ assemble(node, scope, def_source)
40
+ end
41
+
42
+ private
43
+
44
+ # A top-level +def Other.name+ has no openers; an instance method needs
45
+ # at least one, or it would land on Object.
46
+ def injectable?(node, scope)
47
+ return false unless node && scope
48
+
49
+ node.type == :defs || (node.type == :def && !scope.empty?)
50
+ end
51
+
52
+ # Splices by character offsets, as parser ranges count characters. A
53
+ # heredoc's expression range covers only its opener, so the bodies of
54
+ # heredocs inside the replaced node are removed as well; otherwise they
55
+ # would stay behind as code.
56
+ def spliced_def(def_range, original_node, replacement)
57
+ original_range = original_node&.location&.expression
58
+ return unless original_range && within?(original_range, def_range)
59
+ # An unmutated control splices the original text back. Its heredoc
60
+ # bodies must stay, so the def is used verbatim.
61
+ return def_range.source if replacement == original_range.source
62
+
63
+ edits = heredoc_bodies(original_node).map { |range| [range, ""] } << [original_range, replacement]
64
+ apply(def_range, edits)
65
+ end
66
+
67
+ # Applies non-overlapping edits from the last to the first, so earlier
68
+ # offsets stay valid.
69
+ def apply(def_range, edits)
70
+ edits.sort_by { |range, _| -range.begin_pos }.each_with_object(def_range.source.dup) do |(range, insert), text|
71
+ start = range.begin_pos - def_range.begin_pos
72
+ text[start...(range.end_pos - def_range.begin_pos)] = insert
73
+ end
74
+ end
75
+
76
+ def heredoc_bodies(node)
77
+ return [] unless node.is_a?(Parser::AST::Node)
78
+
79
+ own = node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
80
+ bodies = own ? [own.join(node.location.heredoc_end)] : []
81
+ bodies + node.children.flat_map { |child| heredoc_bodies(child) }
82
+ end
83
+
84
+ def within?(inner, outer)
85
+ inner.begin_pos.between?(outer.begin_pos, inner.end_pos) && inner.end_pos <= outer.end_pos
86
+ end
87
+
88
+ def assemble(node, scope, def_source)
89
+ def_range = node.location.expression
90
+ prefix = prefix_lines(def_range)
91
+ Activation.new(source: source_lines(prefix, scope, def_source), mode: :toplevel,
92
+ **location_fields(def_range, prefix), **owner_fields(node, scope))
93
+ end
94
+
95
+ # Only magic-comment header lines precede the def; the openers share
96
+ # its line, so the def keeps its original line number.
97
+ def prefix_lines(def_range)
98
+ magic_comments(def_range.source_buffer.source)
99
+ end
100
+
101
+ def location_fields(def_range, prefix)
102
+ { file: File.expand_path(def_range.source_buffer.name),
103
+ line: [def_range.line - prefix.size, 1].max, def_offset: prefix.size }
104
+ end
105
+
106
+ def owner_fields(node, scope)
107
+ { lexical_scope: scope, owner: owner_source(node), singleton: node.type == :defs }
108
+ end
109
+
110
+ # The expression the +def+ is defined on, as the openers must evaluate
111
+ # it: +self+, or the receiver of +def Other.name+.
112
+ def owner_source(node)
113
+ node.type == :defs ? node.children.first.location.expression.source : "self"
114
+ end
115
+
116
+ # The expression starts at column 0, so its leading indent is dropped.
117
+ def source_lines(prefix, scope, def_source)
118
+ lines = prefix + [wrap_in_scope(def_source.lstrip, scope)]
119
+ "#{lines.join("\n")}\n"
120
+ end
121
+
122
+ # Openers and closers go on the def's own line; with an empty scope
123
+ # the def stands alone.
124
+ def wrap_in_scope(definition, scope)
125
+ return definition if scope.empty?
126
+
127
+ "#{scope.join('; ')}; #{definition}; #{Array.new(scope.size, 'end').join('; ')}"
128
+ end
129
+
130
+ def magic_comments(file_source)
131
+ header = file_source.each_line.take_while { |line| line.strip.empty? || line.start_with?("#") }
132
+ header.map(&:chomp).grep(MAGIC_COMMENT)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # Decides whether the scope openers of an activation can be reopened.
6
+ #
7
+ # Evaluating an opener such as "class Inner" creates the class when it is
8
+ # missing, which would inject a mutant into an empty phantom class. This
9
+ # check walks the openers the way Ruby resolves them, but never evaluates
10
+ # them, so it never defines a constant.
11
+ class LexicalScopeCheck
12
+ CLASS_EVAL_SUFFIX = ".class_eval do"
13
+ SINGLETON_PREFIX = "class << "
14
+
15
+ # The lexical nesting (innermost first, ending with Object, mirroring
16
+ # Ruby's cref chain) and the current +self+, which a class_eval block
17
+ # changes without changing the nesting.
18
+ Scope = Data.define(:nesting, :receiver)
19
+
20
+ def self.resolves?(openers)
21
+ new.resolves?(openers)
22
+ end
23
+
24
+ def resolves?(openers)
25
+ scope = Scope.new(nesting: [Object], receiver: Object)
26
+ openers.all? { |opener| scope = next_scope(opener, scope) }
27
+ end
28
+
29
+ private
30
+
31
+ def next_scope(opener, scope)
32
+ return singleton_scope(opener, scope) if opener.start_with?(SINGLETON_PREFIX)
33
+ return class_eval_scope(opener, scope) if opener.end_with?(CLASS_EVAL_SUFFIX)
34
+
35
+ target = opener_target(opener, scope)
36
+ target && Scope.new(nesting: [target, *scope.nesting], receiver: target)
37
+ end
38
+
39
+ # A singleton opener cannot create a constant, but its receiver must
40
+ # resolve; its singleton class becomes both the cref and +self+.
41
+ def singleton_scope(opener, scope)
42
+ found, value = resolve_expression(opener.delete_prefix(SINGLETON_PREFIX), scope)
43
+ return unless found
44
+
45
+ singleton = value.singleton_class
46
+ Scope.new(nesting: [singleton, *scope.nesting], receiver: singleton)
47
+ rescue TypeError
48
+ nil
49
+ end
50
+
51
+ # A block evaluated with class_eval keeps the enclosing constant lookup
52
+ # and only changes +self+ to its receiver.
53
+ def class_eval_scope(opener, scope)
54
+ found, value = resolve_expression(opener.delete_suffix(CLASS_EVAL_SUFFIX), scope)
55
+ found && value.is_a?(Module) && scope.with(receiver: value)
56
+ end
57
+
58
+ def opener_target(opener, scope)
59
+ kind, path = opener.split(" ", 2)
60
+ segments = path.delete_prefix("::").split("::")
61
+ parent = path.start_with?("::") ? walk(Object, segments[0...-1]) : parent_of(segments[0...-1], scope)
62
+ value = parent.is_a?(Module) && constant_value(parent, segments.last)
63
+ value if matches_kind?(value, kind)
64
+ end
65
+
66
+ # Resolves a constant expression such as +Point+, +self::Inner+ or
67
+ # +::Outer::Point+. Returns whether it resolved and its value.
68
+ def resolve_expression(path, scope)
69
+ return [true, scope.receiver] if path == "self"
70
+
71
+ value = path.match?(/\A\w+\z/) ? lexical_lookup(path, scope.nesting) : qualified_value(path, scope)
72
+ [!value.nil?, value]
73
+ end
74
+
75
+ def qualified_value(path, scope)
76
+ segments = path.delete_prefix("::").split("::")
77
+ base = path.start_with?("::") ? Object : parent_of(segments.first(1), scope)
78
+ walk(base, segments.drop(path.start_with?("::") ? 0 : 1))
79
+ end
80
+
81
+ # A leading +self+ (as in +class self::Inner+) is the current receiver.
82
+ def parent_of(segments, scope)
83
+ return scope.nesting.first if segments.empty?
84
+
85
+ first = segments.first == "self" ? scope.receiver : lexical_lookup(segments.first, scope.nesting)
86
+ walk(first, segments.drop(1))
87
+ end
88
+
89
+ def walk(base, segments)
90
+ segments.reduce(base) { |current, name| current.is_a?(Module) ? own_constant(current, name) : nil }
91
+ end
92
+
93
+ # Ruby's order: the own constants of each lexical scope (innermost
94
+ # first, without Object), then the ancestors of the innermost scope,
95
+ # then the top level.
96
+ def lexical_lookup(name, nesting)
97
+ scope = nesting[0...-1].find { |candidate| candidate.const_defined?(name, false) }
98
+ return scope.const_get(name, false) if scope
99
+
100
+ innermost = nesting.first
101
+ innermost.const_get(name) if innermost.const_defined?(name) || Object.const_defined?(name)
102
+ end
103
+
104
+ def own_constant(base, name)
105
+ base.const_get(name, false) if base.const_defined?(name, false)
106
+ end
107
+
108
+ def constant_value(parent, name)
109
+ inherit = parent.equal?(Object)
110
+ parent.const_get(name, inherit) if parent.const_defined?(name, inherit)
111
+ end
112
+
113
+ def matches_kind?(value, kind)
114
+ return false unless value.is_a?(Module)
115
+
116
+ kind == "class" ? value.is_a?(Class) : !value.is_a?(Class)
117
+ end
118
+ end
119
+ end
120
+ end