henitai 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +182 -0
  5. data/assets/schema/henitai.schema.json +123 -0
  6. data/exe/henitai +6 -0
  7. data/lib/henitai/arid_node_filter.rb +97 -0
  8. data/lib/henitai/cli.rb +341 -0
  9. data/lib/henitai/configuration.rb +132 -0
  10. data/lib/henitai/configuration_validator.rb +293 -0
  11. data/lib/henitai/coverage_bootstrapper.rb +75 -0
  12. data/lib/henitai/coverage_formatter.rb +112 -0
  13. data/lib/henitai/equivalence_detector.rb +85 -0
  14. data/lib/henitai/execution_engine.rb +174 -0
  15. data/lib/henitai/git_diff_analyzer.rb +82 -0
  16. data/lib/henitai/integration.rb +417 -0
  17. data/lib/henitai/mutant/activator.rb +234 -0
  18. data/lib/henitai/mutant.rb +68 -0
  19. data/lib/henitai/mutant_generator.rb +158 -0
  20. data/lib/henitai/mutant_history_store.rb +279 -0
  21. data/lib/henitai/operator.rb +96 -0
  22. data/lib/henitai/operators/arithmetic_operator.rb +46 -0
  23. data/lib/henitai/operators/array_declaration.rb +52 -0
  24. data/lib/henitai/operators/assignment_expression.rb +78 -0
  25. data/lib/henitai/operators/block_statement.rb +31 -0
  26. data/lib/henitai/operators/boolean_literal.rb +70 -0
  27. data/lib/henitai/operators/conditional_expression.rb +184 -0
  28. data/lib/henitai/operators/equality_operator.rb +41 -0
  29. data/lib/henitai/operators/hash_literal.rb +66 -0
  30. data/lib/henitai/operators/logical_operator.rb +84 -0
  31. data/lib/henitai/operators/method_expression.rb +56 -0
  32. data/lib/henitai/operators/pattern_match.rb +66 -0
  33. data/lib/henitai/operators/range_literal.rb +40 -0
  34. data/lib/henitai/operators/return_value.rb +105 -0
  35. data/lib/henitai/operators/safe_navigation.rb +34 -0
  36. data/lib/henitai/operators/string_literal.rb +64 -0
  37. data/lib/henitai/operators.rb +25 -0
  38. data/lib/henitai/parser_current.rb +7 -0
  39. data/lib/henitai/reporter.rb +432 -0
  40. data/lib/henitai/result.rb +170 -0
  41. data/lib/henitai/runner.rb +183 -0
  42. data/lib/henitai/sampling_strategy.rb +33 -0
  43. data/lib/henitai/scenario_execution_result.rb +71 -0
  44. data/lib/henitai/source_parser.rb +41 -0
  45. data/lib/henitai/static_filter.rb +186 -0
  46. data/lib/henitai/stillborn_filter.rb +34 -0
  47. data/lib/henitai/subject.rb +71 -0
  48. data/lib/henitai/subject_resolver.rb +232 -0
  49. data/lib/henitai/syntax_validator.rb +16 -0
  50. data/lib/henitai/test_prioritizer.rb +55 -0
  51. data/lib/henitai/unparse_helper.rb +24 -0
  52. data/lib/henitai/version.rb +5 -0
  53. data/lib/henitai/warning_silencer.rb +16 -0
  54. data/lib/henitai.rb +51 -0
  55. data/sig/configuration_validator.rbs +29 -0
  56. data/sig/henitai.rbs +594 -0
  57. data/sig/unparser.rbs +3 -0
  58. metadata +153 -0
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+ require "unparser"
5
+
6
+ module Henitai
7
+ class Mutant
8
+ # Activates a mutant inside the forked child process.
9
+ class Activator
10
+ SERIALIZER_METHODS = {
11
+ arg: :argument_parameter_fragment,
12
+ optarg: :optional_parameter_fragment,
13
+ restarg: :rest_parameter_fragment,
14
+ kwarg: :keyword_parameter_fragment,
15
+ kwoptarg: :optional_keyword_parameter_fragment,
16
+ kwrestarg: :keyword_rest_parameter_fragment,
17
+ blockarg: :block_parameter_fragment,
18
+ forward_arg: :forward_parameter_fragment
19
+ }.freeze
20
+
21
+ def self.activate!(mutant)
22
+ new.activate!(mutant)
23
+ end
24
+
25
+ def activate!(mutant)
26
+ subject = mutant.subject
27
+ raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
28
+
29
+ Henitai::WarningSilencer.silence do
30
+ target_for(subject).class_eval(method_source(mutant), __FILE__, __LINE__ + 1)
31
+ nil
32
+ end
33
+ rescue Unparser::UnsupportedNodeError
34
+ :compile_error
35
+ end
36
+
37
+ private
38
+
39
+ def target_for(subject)
40
+ target = load_target(subject)
41
+ subject.method_type == :class ? target.singleton_class : target
42
+ end
43
+
44
+ def method_source(mutant)
45
+ method_name = mutant.subject.method_name
46
+ parameters = parameter_source(mutant)
47
+ replacement = body_source(mutant)
48
+
49
+ <<~RUBY
50
+ define_method(:#{method_name}) do |#{parameters}|
51
+ #{replacement}
52
+ end
53
+ RUBY
54
+ end
55
+
56
+ def body_source(mutant)
57
+ subject_node = mutant.subject.ast_node
58
+ return compile_safe_unparse(mutant.mutated_node) unless subject_node
59
+
60
+ mutated_subject = replace_node(
61
+ subject_node,
62
+ mutant.original_node,
63
+ mutant.mutated_node
64
+ )
65
+ body = method_body(mutated_subject) || Parser::AST::Node.new(:nil, [])
66
+ compile_safe_unparse(body)
67
+ end
68
+
69
+ def replace_node(node, original_node, mutated_node)
70
+ return mutated_node if same_node?(node, original_node)
71
+ return node unless node.is_a?(Parser::AST::Node)
72
+
73
+ updated_children = node.children.map do |child|
74
+ replace_child(child, original_node, mutated_node)
75
+ end
76
+
77
+ return node if updated_children == node.children
78
+
79
+ Parser::AST::Node.new(node.type, updated_children)
80
+ end
81
+
82
+ def same_node?(left, right)
83
+ left_location = node_location_signature(left)
84
+ right_location = node_location_signature(right)
85
+ return left.equal?(right) unless left_location && right_location
86
+
87
+ left_location == right_location
88
+ end
89
+
90
+ def replace_child(child, original_node, mutated_node)
91
+ case child
92
+ when Parser::AST::Node
93
+ replace_node(child, original_node, mutated_node)
94
+ when Array
95
+ child.map { |item| replace_child(item, original_node, mutated_node) }
96
+ else
97
+ child
98
+ end
99
+ end
100
+
101
+ def method_body(subject_node)
102
+ case subject_node.type
103
+ when :def
104
+ subject_node.children[2]
105
+ when :defs
106
+ subject_node.children[3]
107
+ when :block
108
+ block_body(subject_node)
109
+ else
110
+ subject_node
111
+ end
112
+ end
113
+
114
+ def parameter_source(mutant)
115
+ args_node = method_arguments(mutant.subject.ast_node)
116
+ return "" unless args_node
117
+ return forward_parameter_fragment(nil) if args_node.type == :forward_args
118
+
119
+ args_node.children.filter_map do |argument|
120
+ parameter_fragment(argument)
121
+ end.join(", ")
122
+ end
123
+
124
+ def method_arguments(subject_node)
125
+ case subject_node&.type
126
+ when :def
127
+ subject_node.children[1]
128
+ when :defs
129
+ subject_node.children[2]
130
+ when :block
131
+ block_arguments(subject_node)
132
+ end
133
+ end
134
+
135
+ def parameter_fragment(argument)
136
+ method_name = SERIALIZER_METHODS[argument&.type]
137
+ return unless method_name
138
+
139
+ send(method_name, argument)
140
+ end
141
+
142
+ def argument_parameter_fragment(argument)
143
+ argument.children[0].to_s
144
+ end
145
+
146
+ def optional_parameter_fragment(argument)
147
+ "#{argument.children[0]} = #{compile_safe_unparse(argument.children[1])}"
148
+ end
149
+
150
+ def rest_parameter_fragment(argument)
151
+ prefixed_parameter(argument, "*")
152
+ end
153
+
154
+ def keyword_parameter_fragment(argument)
155
+ "#{argument.children[0]}:"
156
+ end
157
+
158
+ def optional_keyword_parameter_fragment(argument)
159
+ "#{argument.children[0]}: #{compile_safe_unparse(argument.children[1])}"
160
+ end
161
+
162
+ def keyword_rest_parameter_fragment(argument)
163
+ prefixed_parameter(argument, "**")
164
+ end
165
+
166
+ def block_parameter_fragment(argument)
167
+ "&#{argument.children[0]}"
168
+ end
169
+
170
+ def forward_parameter_fragment(_argument)
171
+ "*args, **kwargs, &block"
172
+ end
173
+
174
+ def prefixed_parameter(argument, prefix)
175
+ name = argument.children[0]
176
+ name ? "#{prefix}#{name}" : prefix
177
+ end
178
+
179
+ def block_body(subject_node)
180
+ subject_node.children[2]
181
+ end
182
+
183
+ def block_arguments(subject_node)
184
+ subject_node.children[1]
185
+ end
186
+
187
+ def load_target(subject)
188
+ Object.const_get(subject.namespace.delete_prefix("::"))
189
+ rescue NameError
190
+ load_source_file(subject)
191
+ Object.const_get(subject.namespace.delete_prefix("::"))
192
+ end
193
+
194
+ def load_source_file(subject)
195
+ source_file = subject.source_file || source_file_from_ast(subject)
196
+ return unless source_file && File.file?(source_file)
197
+
198
+ load(source_file)
199
+ end
200
+
201
+ def source_file_from_ast(subject)
202
+ ast_node = subject.ast_node
203
+ return unless ast_node
204
+
205
+ location = ast_node.location
206
+ return unless location
207
+
208
+ expression = location.expression
209
+ return unless expression
210
+
211
+ expression.source_buffer.name
212
+ end
213
+
214
+ def node_location_signature(node)
215
+ expression = node&.location&.expression
216
+ return unless expression
217
+
218
+ [
219
+ expression.source_buffer.name,
220
+ expression.line,
221
+ expression.column,
222
+ expression.last_line,
223
+ expression.last_column
224
+ ]
225
+ end
226
+
227
+ def compile_safe_unparse(node)
228
+ Unparser.unparse(node)
229
+ rescue StandardError => e
230
+ raise Unparser::UnsupportedNodeError, e.message
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Henitai
6
+ # Represents a single syntactic mutation applied to a Subject.
7
+ #
8
+ # A Mutant holds:
9
+ # - the original and mutated AST nodes
10
+ # - the operator that generated it
11
+ # - the source location of the mutation
12
+ # - its current status in the pipeline
13
+ #
14
+ # Statuses follow the Stryker mutation-testing-report-schema vocabulary:
15
+ # :pending, :killed, :survived, :timeout, :compile_error, :runtime_error,
16
+ # :ignored, :no_coverage
17
+ class Mutant
18
+ autoload :Activator, "henitai/mutant/activator"
19
+
20
+ # Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
21
+ # :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
22
+ # aber in der Scoring-Berechnung separat behandelt: confirmed equivalent mutants
23
+ # werden aus dem Nenner der MS-Berechnung herausgenommen).
24
+ STATUSES = %i[
25
+ pending
26
+ killed
27
+ survived
28
+ timeout
29
+ compile_error
30
+ runtime_error
31
+ ignored
32
+ no_coverage
33
+ equivalent
34
+ ].freeze
35
+
36
+ attr_reader :id, :subject, :operator, :original_node, :mutated_node,
37
+ :mutation_type, :description, :location
38
+ attr_accessor :status, :killing_test, :duration
39
+
40
+ # @param subject [Subject] the subject being mutated
41
+ # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
42
+ # @param nodes [Hash] AST nodes with :original and :mutated entries
43
+ # @param description [String] human-readable description of the mutation
44
+ # @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
45
+ def initialize(subject:, operator:, nodes:, description:, location:)
46
+ @id = SecureRandom.uuid
47
+ @subject = subject
48
+ @operator = operator
49
+ @original_node = nodes.fetch(:original)
50
+ @mutated_node = nodes.fetch(:mutated)
51
+ @description = description
52
+ @location = location
53
+ @status = :pending
54
+ @killing_test = nil
55
+ @duration = nil
56
+ end
57
+
58
+ def killed? = @status == :killed
59
+ def survived? = @status == :survived
60
+ def pending? = @status == :pending
61
+ def ignored? = @status == :ignored
62
+ def equivalent? = @status == :equivalent
63
+
64
+ def to_s
65
+ "#{operator}@#{location[:file]}:#{location[:start_line]} — #{description}"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser_current"
4
+ require_relative "source_parser"
5
+
6
+ module Henitai
7
+ # Traverses a subject's AST and asks operators to build mutants.
8
+ class MutantGenerator
9
+ def generate(subjects, operators, config: nil)
10
+ normalized_operators = normalize_operators(operators)
11
+ arid_node_filter = AridNodeFilter.new
12
+ syntax_validator = SyntaxValidator.new
13
+ sampling_strategy = SamplingStrategy.new
14
+
15
+ mutants = Array(subjects).flat_map do |subject|
16
+ generate_for_subject(
17
+ subject,
18
+ normalized_operators,
19
+ config:,
20
+ arid_node_filter:,
21
+ syntax_validator:
22
+ )
23
+ end
24
+
25
+ sample_mutants(mutants, config:, sampling_strategy:)
26
+ end
27
+
28
+ private
29
+
30
+ def normalize_operators(operators)
31
+ Array(operators).map do |operator|
32
+ operator.is_a?(Class) ? operator.new : operator
33
+ end
34
+ end
35
+
36
+ def generate_for_subject(subject, operators, config:, arid_node_filter:, syntax_validator:)
37
+ return [] unless subject.source_file && subject.source_range
38
+
39
+ visitor = SubjectVisitor.new(
40
+ subject,
41
+ operators,
42
+ config:,
43
+ arid_node_filter:,
44
+ syntax_validator:
45
+ )
46
+ visitor.process(SourceParser.parse_file(subject.source_file))
47
+ prune_mutants_per_line(
48
+ visitor.mutants,
49
+ max_mutants_per_line: config&.max_mutants_per_line || 1
50
+ )
51
+ end
52
+
53
+ # Depth-first pre-order AST visitor for a single subject.
54
+ class SubjectVisitor
55
+ attr_reader :mutants
56
+
57
+ def initialize(subject, operators, config:, arid_node_filter:, syntax_validator:)
58
+ @subject = subject
59
+ @config = config
60
+ @mutants = []
61
+ @arid_node_filter = arid_node_filter
62
+ @syntax_validator = syntax_validator
63
+ @operators_by_node_type = operators.each_with_object(
64
+ Hash.new { |hash, key| hash[key] = [] }
65
+ ) do |operator, map|
66
+ operator.class.node_types.each do |node_type|
67
+ map[node_type] << operator
68
+ end
69
+ end
70
+ end
71
+
72
+ def process(node)
73
+ walk(node)
74
+ end
75
+
76
+ private
77
+
78
+ def walk(node)
79
+ return unless node.is_a?(Parser::AST::Node)
80
+
81
+ apply_operators(node) if node_within_subject_range?(node)
82
+ node.children.each do |child|
83
+ walk(child)
84
+ end
85
+ end
86
+
87
+ def apply_operators(node)
88
+ return if @arid_node_filter.suppressed?(node, @config)
89
+
90
+ @operators_by_node_type[node.type].each do |operator|
91
+ operator.mutate(node, subject: @subject).each do |mutant|
92
+ @mutants << mutant if @syntax_validator.valid?(mutant)
93
+ end
94
+ end
95
+ end
96
+
97
+ def node_within_subject_range?(node)
98
+ location = node.location&.expression
99
+ return true unless location && @subject.source_range
100
+
101
+ node_range = location.line..location.last_line
102
+ ranges_overlap?(node_range, @subject.source_range)
103
+ end
104
+
105
+ def ranges_overlap?(left, right)
106
+ left.begin <= right.end && right.begin <= left.end
107
+ end
108
+ end
109
+
110
+ def prune_mutants_per_line(mutants, max_mutants_per_line:)
111
+ grouped = mutants.each_with_object({}) do |mutant, selected|
112
+ key = line_key(mutant)
113
+ selected[key] ||= []
114
+ selected[key] << mutant
115
+ end
116
+
117
+ grouped.values.flat_map do |mutants_for_line|
118
+ mutants_for_line.sort_by { |mutant| mutant_priority_key(mutant) }.take(max_mutants_per_line)
119
+ end
120
+ end
121
+
122
+ def sample_mutants(mutants, config:, sampling_strategy:)
123
+ sampling = config&.sampling
124
+ return mutants unless sampling
125
+ return mutants if sampling[:ratio].nil?
126
+
127
+ sampling_strategy.sample(
128
+ mutants,
129
+ ratio: sampling[:ratio],
130
+ strategy: sampling[:strategy] || :stratified
131
+ )
132
+ end
133
+
134
+ def line_key(mutant)
135
+ [
136
+ mutant.location[:file],
137
+ mutant.location[:start_line]
138
+ ]
139
+ end
140
+
141
+ def mutant_priority_key(mutant)
142
+ [
143
+ operator_priority(mutant.operator),
144
+ mutant.location[:start_col] || 0,
145
+ mutant.description
146
+ ]
147
+ end
148
+
149
+ def operator_priority(operator_name)
150
+ operator_priority_map.fetch(operator_name, operator_priority_map.length)
151
+ end
152
+
153
+ def operator_priority_map
154
+ # The constant order defines signal priority for per-line pruning.
155
+ @operator_priority_map ||= Operator::FULL_SET.each_with_index.to_h
156
+ end
157
+ end
158
+ end