henitai 0.5.1 → 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 +296 -1
  3. data/README.md +109 -16
  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 +57 -15
  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 +17 -7
  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 +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -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
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class Mutant
5
+ # Resolves the object a re-evaluated +def+ must be defined on, and loads
6
+ # the subject's source file when its namespace is still undefined.
7
+ class LexicalTarget
8
+ # Raised when a subject's namespace is still undefined after its source
9
+ # file was loaded, so there is no class to reopen.
10
+ class SubjectNotLoadedError < StandardError; end
11
+
12
+ def self.resolve(subject, activation)
13
+ new.resolve(subject, activation)
14
+ end
15
+
16
+ def self.load_target(subject)
17
+ new.load_target(subject)
18
+ end
19
+
20
+ # The object the re-evaluated +def+ defines its method on, as Ruby
21
+ # resolves it: the openers are evaluated with +self+ -- or the receiver
22
+ # of +def Other.name+ -- as their body. The subject's namespace is only
23
+ # the resolver's static reading of the source, which cannot tell
24
+ # whether +Other+ inside +module Outer+ is +Outer::Other+ or +::Other+.
25
+ # The scope, owner and singleton flag come from the activation, so a
26
+ # replayed recipe does not need the AST. The subject's file is loaded
27
+ # first when needed (see +ensure_loaded+), so that evaluating the openers
28
+ # reopens the loaded class instead of creating an empty one.
29
+ def resolve(subject, activation)
30
+ scope = activation.lexical_scope
31
+ ensure_loaded(subject, scope)
32
+ source = "#{scope.join('; ')}\n#{activation.owner}\n#{Array.new(scope.size, 'end').join('; ')}"
33
+ owner = TOPLEVEL_BINDING.eval(source, activation.file, activation.line)
34
+ activation.singleton ? owner.singleton_class : owner
35
+ end
36
+
37
+ def load_target(subject)
38
+ Object.const_get(subject.namespace)
39
+ rescue NameError
40
+ load_source_file(subject)
41
+ Object.const_get(subject.namespace)
42
+ end
43
+
44
+ private
45
+
46
+ # Evaluating the openers must reopen existing constants, never create
47
+ # them: a missing class would become an empty phantom that swallows the
48
+ # mutant. The scope is therefore checked without evaluation first. If it
49
+ # does not resolve, the subject's file is loaded and checked again. If it
50
+ # still does not resolve, activation fails instead of creating anything.
51
+ def ensure_loaded(subject, scope)
52
+ return load_toplevel_source(subject) if scope.empty?
53
+ return if LexicalScopeCheck.resolves?(scope)
54
+
55
+ load_subject_source(subject)
56
+ return if LexicalScopeCheck.resolves?(scope)
57
+
58
+ raise SubjectNotLoadedError,
59
+ "cannot activate #{subject.expression}: its source did not define #{subject.namespace}"
60
+ end
61
+
62
+ # Without openers no phantom class can be created; the file is loaded
63
+ # only when the subject's namespace constant is still missing. A file
64
+ # that fails to load fails the activation instead of crashing the child.
65
+ def load_toplevel_source(subject)
66
+ load_target(subject)
67
+ rescue NameError
68
+ nil
69
+ rescue StandardError, ScriptError => e
70
+ raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
71
+ end
72
+
73
+ def load_subject_source(subject)
74
+ load_source_file(subject)
75
+ rescue StandardError, ScriptError => e
76
+ raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
77
+ end
78
+
79
+ def load_source_file(subject)
80
+ source_file = subject.source_file || source_file_from_ast(subject)
81
+ return unless source_file && File.file?(source_file)
82
+
83
+ Thread.current[:henitai_filter_const_warnings] = true
84
+ load(source_file)
85
+ loaded_feature = File.expand_path(source_file)
86
+ $LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
87
+ ensure
88
+ Thread.current[:henitai_filter_const_warnings] = false
89
+ end
90
+
91
+ def source_file_from_ast(subject)
92
+ ast_node = subject.ast_node
93
+ return unless ast_node
94
+
95
+ location = ast_node.location
96
+ return unless location
97
+
98
+ expression = location.expression
99
+ return unless expression
100
+
101
+ expression.source_buffer.name
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unparser"
4
+
5
+ module Henitai
6
+ class Mutant
7
+ # Renders the source text spliced in place of a mutant's original node.
8
+ #
9
+ # The text replaces only the original node's range, so it must parse as
10
+ # one expression wherever it lands. An operator expression such as
11
+ # `a || b` does not: spliced into `a && b && c` it reads as
12
+ # `a || (b && c)`. Operator expressions are therefore parenthesised;
13
+ # parentheses never change an expression's meaning, missing ones can. An
14
+ # unmutated control keeps the original text.
15
+ class Replacement
16
+ OPERATOR_METHODS = %i[
17
+ + - * / % ** == != < > <= >= <=> === =~ !~ << >> & | ^ ! ~ -@ +@
18
+ ].freeze
19
+
20
+ def self.render(mutant, original_range)
21
+ new(mutant, original_range).render
22
+ end
23
+
24
+ def initialize(mutant, original_range)
25
+ @mutant = mutant
26
+ @original_range = original_range
27
+ end
28
+
29
+ def render
30
+ return @original_range.source if @mutant.mutated_node.equal?(@mutant.original_node)
31
+
32
+ text = unparse(@mutant.mutated_node)
33
+ operator_expression?(@mutant.mutated_node) ? "(#{text})" : text
34
+ end
35
+
36
+ private
37
+
38
+ def unparse(node)
39
+ Unparser.unparse(node)
40
+ rescue StandardError => e
41
+ raise Unparser::UnsupportedNodeError, e.message
42
+ end
43
+
44
+ def operator_expression?(node)
45
+ return false unless node.is_a?(Parser::AST::Node)
46
+ return true if %i[and or].include?(node.type)
47
+
48
+ %i[send csend].include?(node.type) && OPERATOR_METHODS.include?(node.children[1])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -21,6 +21,11 @@ module Henitai
21
21
 
22
22
  autoload :Activator, "henitai/mutant/activator"
23
23
  autoload :ParameterSource, "henitai/mutant/parameter_source"
24
+ autoload :Activation, "henitai/mutant/activation"
25
+ autoload :LexicalScopeCheck, "henitai/mutant/lexical_scope_check"
26
+ autoload :LexicalTarget, "henitai/mutant/lexical_target"
27
+ autoload :DefInjection, "henitai/mutant/def_injection"
28
+ autoload :Replacement, "henitai/mutant/replacement"
24
29
 
25
30
  # Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
26
31
  # :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
@@ -40,9 +45,12 @@ module Henitai
40
45
 
41
46
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
42
47
  :mutation_type, :description, :location,
43
- :precomputed_stable_id, :precomputed_activation_source
48
+ :precomputed_stable_id, :precomputed_activation, :precomputed_replacement
44
49
  attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed,
45
- :ignore_reason, :from_cache
50
+ :ignore_reason, :status_reason, :from_cache
51
+ # Set by MutantGenerator only for mutants whose identity would otherwise
52
+ # collide with another one (see MutantIdentity).
53
+ attr_accessor :identity_discriminator
46
54
 
47
55
  # @param subject [Subject] the subject being mutated
48
56
  # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
@@ -51,7 +59,8 @@ module Henitai
51
59
  # @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
52
60
  # rubocop:disable Metrics/ParameterLists
53
61
  def initialize(subject:, operator:, nodes:, description:, location:,
54
- precomputed_stable_id: nil, precomputed_activation_source: nil)
62
+ precomputed_stable_id: nil, precomputed_activation: nil,
63
+ precomputed_replacement: nil)
55
64
  @id = SecureRandom.uuid
56
65
  @subject = subject
57
66
  @operator = operator
@@ -60,10 +69,9 @@ module Henitai
60
69
  @description = description
61
70
  @location = location
62
71
  @precomputed_stable_id = precomputed_stable_id
63
- @precomputed_activation_source = precomputed_activation_source
64
- @status = :pending
65
- @killing_test = @duration = @covered_by = @tests_completed = @ignore_reason = nil
66
- @from_cache = false
72
+ @precomputed_activation = precomputed_activation
73
+ @precomputed_replacement = precomputed_replacement
74
+ reset_outcome
67
75
  end
68
76
  # rubocop:enable Metrics/ParameterLists
69
77
 
@@ -73,7 +81,9 @@ module Henitai
73
81
 
74
82
  # Unparsing is expensive and the value is needed by both the JSON and
75
83
  # the terminal reporter — compute once per mutant.
76
- def mutated_source = @mutated_source ||= safe_unparse(mutated_node)
84
+ def mutated_source
85
+ @mutated_source ||= @precomputed_replacement || safe_unparse(mutated_node)
86
+ end
77
87
 
78
88
  def killed? = @status == :killed
79
89
  def from_cache? = @from_cache == true
@@ -85,5 +95,14 @@ module Henitai
85
95
  def to_s
86
96
  "#{operator}@#{location[:file]}:#{location[:start_line]} — #{description}"
87
97
  end
98
+
99
+ private
100
+
101
+ def reset_outcome
102
+ @status = :pending
103
+ @killing_test = @duration = @covered_by = @tests_completed = nil
104
+ @ignore_reason = @status_reason = nil
105
+ @from_cache = false
106
+ end
88
107
  end
89
108
  end