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,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # rubocop:disable Metrics/ModuleLength
5
+ # Internal validator for configuration data loaded from YAML and CLI overrides.
6
+ module ConfigurationValidator
7
+ VALID_TOP_LEVEL_KEYS = %i[
8
+ integration
9
+ includes
10
+ mutation
11
+ coverage_criteria
12
+ thresholds
13
+ reporters
14
+ reports_dir
15
+ all_logs
16
+ dashboard
17
+ jobs
18
+ ].freeze
19
+ VALID_MUTATION_KEYS = %i[operators timeout ignore_patterns max_mutants_per_line max_flaky_retries sampling].freeze
20
+ VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
21
+ VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
22
+ VALID_THRESHOLDS_KEYS = %i[high low].freeze
23
+ VALID_DASHBOARD_KEYS = %i[project base_url].freeze
24
+ VALID_INTEGRATION_KEYS = %i[name].freeze
25
+ VALID_OPERATORS = %i[light full].freeze
26
+ VALIDATION_STEPS = %i[
27
+ validate_top_level_keys
28
+ validate_integration
29
+ validate_includes
30
+ validate_jobs
31
+ validate_reporters
32
+ validate_reports_dir
33
+ validate_all_logs
34
+ validate_dashboard
35
+ validate_mutation
36
+ validate_coverage_criteria
37
+ validate_thresholds
38
+ ].freeze
39
+
40
+ def self.validate!(raw)
41
+ ensure_hash!(raw, "configuration")
42
+ VALIDATION_STEPS.each { |step| send(step, raw) }
43
+ end
44
+
45
+ class << self
46
+ private
47
+
48
+ def validate_top_level_keys(raw)
49
+ warn_unknown_keys(raw, VALID_TOP_LEVEL_KEYS)
50
+ end
51
+
52
+ def validate_integration(raw)
53
+ value = raw[:integration]
54
+ return if value.nil?
55
+ return if value.is_a?(String)
56
+
57
+ ensure_hash!(value, "integration")
58
+ warn_unknown_keys(value, VALID_INTEGRATION_KEYS, "integration")
59
+ validate_optional_string(value[:name], "integration.name")
60
+ end
61
+
62
+ def validate_includes(raw)
63
+ validate_string_array(raw[:includes], "includes")
64
+ end
65
+
66
+ def validate_jobs(raw)
67
+ value = raw[:jobs]
68
+ return if value.nil?
69
+ return if value.is_a?(Integer)
70
+
71
+ configuration_error("Invalid configuration value for jobs: expected Integer, got #{value.class}")
72
+ end
73
+
74
+ def validate_reporters(raw)
75
+ validate_string_array(raw[:reporters], "reporters")
76
+ end
77
+
78
+ def validate_reports_dir(raw)
79
+ validate_optional_string(raw[:reports_dir], "reports_dir")
80
+ end
81
+
82
+ def validate_all_logs(raw)
83
+ value = raw[:all_logs]
84
+ return if value.nil?
85
+
86
+ validate_boolean(value, "all_logs")
87
+ end
88
+
89
+ def validate_dashboard(raw)
90
+ value = raw[:dashboard]
91
+ return if value.nil?
92
+
93
+ ensure_hash!(value, "dashboard")
94
+ warn_unknown_keys(value, VALID_DASHBOARD_KEYS, "dashboard")
95
+ validate_optional_string(value[:project], "dashboard.project")
96
+ validate_optional_string(value[:base_url], "dashboard.base_url")
97
+ end
98
+
99
+ def validate_mutation(raw)
100
+ value = raw[:mutation]
101
+ return if value.nil?
102
+
103
+ ensure_hash!(value, "mutation")
104
+ warn_unknown_keys(value, VALID_MUTATION_KEYS, "mutation")
105
+ validate_operator(value[:operators])
106
+ validate_mutation_limits(value)
107
+ validate_mutation_filters(value)
108
+ validate_sampling(value[:sampling])
109
+ end
110
+
111
+ def validate_mutation_limits(value)
112
+ validate_timeout(value[:timeout])
113
+ validate_max_mutants_per_line(value[:max_mutants_per_line])
114
+ validate_max_flaky_retries(value[:max_flaky_retries])
115
+ end
116
+
117
+ def validate_mutation_filters(value)
118
+ validate_string_array(value[:ignore_patterns], "mutation.ignore_patterns")
119
+ validate_ignore_patterns(value[:ignore_patterns])
120
+ end
121
+
122
+ def validate_coverage_criteria(raw)
123
+ value = raw[:coverage_criteria]
124
+ return if value.nil?
125
+
126
+ ensure_hash!(value, "coverage_criteria")
127
+ warn_unknown_keys(value, VALID_COVERAGE_CRITERIA_KEYS, "coverage_criteria")
128
+ value.each do |key, flag|
129
+ validate_boolean(flag, "coverage_criteria.#{key}")
130
+ end
131
+ end
132
+
133
+ def validate_thresholds(raw)
134
+ value = raw[:thresholds]
135
+ return if value.nil?
136
+
137
+ ensure_hash!(value, "thresholds")
138
+ warn_unknown_keys(value, VALID_THRESHOLDS_KEYS, "thresholds")
139
+ value.each do |key, threshold|
140
+ validate_threshold(threshold, "thresholds.#{key}")
141
+ end
142
+ end
143
+
144
+ def validate_operator(value)
145
+ return if value.nil?
146
+
147
+ operator = value.respond_to?(:to_sym) ? value.to_sym : nil
148
+ return if VALID_OPERATORS.include?(operator)
149
+
150
+ configuration_error(
151
+ "Invalid configuration value for mutation.operators: expected one of " \
152
+ "#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
153
+ )
154
+ end
155
+
156
+ def validate_timeout(value)
157
+ return if value.nil?
158
+ return if value.is_a?(Numeric)
159
+
160
+ configuration_error("Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}")
161
+ end
162
+
163
+ def validate_threshold(value, path)
164
+ return if value.is_a?(Integer) && value.between?(0, 100)
165
+
166
+ configuration_error(
167
+ "Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
168
+ "got #{value.inspect}"
169
+ )
170
+ end
171
+
172
+ def validate_boolean(value, path)
173
+ return if [true, false].include?(value)
174
+
175
+ configuration_error("Invalid configuration value for #{path}: expected true or false, got #{value.inspect}")
176
+ end
177
+
178
+ def validate_optional_string(value, path)
179
+ return if value.nil?
180
+ return if value.is_a?(String)
181
+
182
+ configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
183
+ end
184
+
185
+ def validate_string_array(value, path)
186
+ return if value.nil?
187
+ return if value.is_a?(Array) && value.all?(String)
188
+
189
+ configuration_error(
190
+ "Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
191
+ )
192
+ end
193
+
194
+ def validate_ignore_patterns(value)
195
+ Array(value).each do |pattern|
196
+ Regexp.new(pattern)
197
+ rescue RegexpError => e
198
+ configuration_error(
199
+ "Invalid configuration value for mutation.ignore_patterns: " \
200
+ "invalid regular expression #{pattern.inspect}: #{e.message}"
201
+ )
202
+ end
203
+ end
204
+
205
+ def validate_max_mutants_per_line(value)
206
+ return if value.nil?
207
+ return if value.is_a?(Integer) && value >= 1
208
+
209
+ configuration_error(
210
+ "Invalid configuration value for mutation.max_mutants_per_line: expected Integer >= 1, got #{value.inspect}"
211
+ )
212
+ end
213
+
214
+ def validate_max_flaky_retries(value)
215
+ return if value.nil?
216
+ return if value.is_a?(Integer) && value >= 0
217
+
218
+ configuration_error(
219
+ "Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
220
+ )
221
+ end
222
+
223
+ def validate_sampling(value)
224
+ return if value.nil?
225
+
226
+ ensure_hash!(value, "mutation.sampling")
227
+ warn_unknown_keys(value, VALID_SAMPLING_KEYS, "mutation.sampling")
228
+ validate_sampling_completeness(value)
229
+ validate_sampling_ratio(value[:ratio])
230
+ validate_sampling_strategy(value[:strategy])
231
+ end
232
+
233
+ def validate_sampling_ratio(value)
234
+ return if value.nil?
235
+ return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
236
+
237
+ configuration_error(
238
+ "Invalid configuration value for mutation.sampling.ratio: " \
239
+ "expected Numeric between 0 and 1, got #{value.inspect}"
240
+ )
241
+ end
242
+
243
+ def validate_sampling_strategy(value)
244
+ return if value.nil?
245
+
246
+ strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
247
+ return if strategy == :stratified
248
+
249
+ configuration_error(
250
+ "Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
251
+ )
252
+ end
253
+
254
+ def validate_sampling_completeness(value)
255
+ return if value.key?(:ratio) && value.key?(:strategy)
256
+
257
+ configuration_error(
258
+ "Invalid configuration value for mutation.sampling: expected both ratio and strategy"
259
+ )
260
+ end
261
+
262
+ def warn_unknown_keys(raw, allowed_keys, path = nil)
263
+ raw.each_key do |key|
264
+ next if allowed_keys.include?(key)
265
+
266
+ warn "Unknown configuration key: #{key_path(path, key)}"
267
+ end
268
+ end
269
+
270
+ def key_path(path, key)
271
+ path ? "#{path}.#{key}" : key.to_s
272
+ end
273
+
274
+ def ensure_hash!(value, path)
275
+ return if value.is_a?(Hash)
276
+
277
+ configuration_error("Invalid configuration value for #{path}: expected Hash, got #{value.class}")
278
+ end
279
+
280
+ def describe_array_type(value)
281
+ return value.class.name unless value.is_a?(Array)
282
+
283
+ element_types = value.map { |item| item.class.name }.uniq.join(", ")
284
+ "Array<#{element_types}>"
285
+ end
286
+
287
+ def configuration_error(message)
288
+ raise Henitai::ConfigurationError, message
289
+ end
290
+ end
291
+ end
292
+ # rubocop:enable Metrics/ModuleLength
293
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Ensures coverage data exists before the mutation pipeline starts.
5
+ class CoverageBootstrapper
6
+ def initialize(static_filter: StaticFilter.new)
7
+ @static_filter = static_filter
8
+ end
9
+
10
+ def ensure!(source_files:, config:, integration:)
11
+ return if source_files.empty?
12
+ return if coverage_available?(source_files, config)
13
+
14
+ bootstrap_coverage(integration, config)
15
+ return if coverage_available?(source_files, config)
16
+
17
+ raise CoverageError,
18
+ "Coverage data is unavailable for the configured source files"
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :static_filter
24
+
25
+ def coverage_available?(source_files, config)
26
+ coverage_lines = static_filter.coverage_lines_for(config)
27
+
28
+ source_file_paths(source_files).any? do |path|
29
+ Array(coverage_lines[path]).any?
30
+ end
31
+ end
32
+
33
+ def source_file_paths(source_files)
34
+ Array(source_files).map { |path| File.expand_path(path) }
35
+ end
36
+
37
+ def bootstrap_coverage(integration, config)
38
+ with_coverage_dir(config) do
39
+ result = integration.run_suite(integration.test_files)
40
+ return if result == :survived
41
+
42
+ raise CoverageError, build_bootstrap_error(result)
43
+ end
44
+ end
45
+
46
+ def build_bootstrap_error(result)
47
+ return "Configured test suite failed while generating coverage" unless result.respond_to?(:log_path)
48
+
49
+ tail = result.tail(12).strip
50
+ message = +"Configured test suite failed while generating coverage"
51
+ message << " (see #{result.log_path})"
52
+ message << "\n#{tail}" unless tail.empty?
53
+ message
54
+ end
55
+
56
+ def with_coverage_dir(config)
57
+ original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
58
+ ENV["HENITAI_COVERAGE_DIR"] = coverage_dir(config)
59
+ yield
60
+ ensure
61
+ if original_coverage_dir.nil?
62
+ ENV.delete("HENITAI_COVERAGE_DIR")
63
+ else
64
+ ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
65
+ end
66
+ end
67
+
68
+ def coverage_dir(config)
69
+ reports_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
70
+ return "coverage" if reports_dir.nil? || reports_dir.empty?
71
+
72
+ File.join(reports_dir, "coverage")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "coverage"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Henitai
8
+ # Collects per-test coverage data for static filtering heuristics.
9
+ class CoverageFormatter
10
+ REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
11
+ REPORT_FILE_NAME = "henitai_per_test.json"
12
+
13
+ RSpec::Core::Formatters.register self, :example_finished, :dump_summary
14
+
15
+ def initialize(_output)
16
+ @coverage_by_test = Hash.new do |hash, test_file|
17
+ hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
18
+ end
19
+ @previous_snapshot = {}
20
+ @warned_missing_coverage = false
21
+ end
22
+
23
+ def example_finished(notification)
24
+ snapshot = current_snapshot
25
+ return warn_missing_coverage unless snapshot
26
+
27
+ test_file = notification.example.metadata[:file_path]
28
+ new_lines(snapshot).each do |source_file, lines|
29
+ @coverage_by_test[test_file][source_file].concat(lines)
30
+ @coverage_by_test[test_file][source_file].uniq!
31
+ @coverage_by_test[test_file][source_file].sort!
32
+ end
33
+ @previous_snapshot = snapshot
34
+ end
35
+
36
+ def dump_summary(_summary)
37
+ return if @coverage_by_test.empty?
38
+
39
+ FileUtils.mkdir_p(File.dirname(report_path))
40
+ File.write(report_path, JSON.pretty_generate(serializable_report))
41
+ end
42
+
43
+ private
44
+
45
+ def report_path
46
+ File.join(reports_dir, REPORT_FILE_NAME)
47
+ end
48
+
49
+ def reports_dir
50
+ ENV.fetch(REPORT_DIR_ENV, "coverage")
51
+ end
52
+
53
+ def current_snapshot
54
+ Coverage.peek_result
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ def warn_missing_coverage
60
+ return if @warned_missing_coverage
61
+
62
+ warn "Per-test coverage unavailable; skipping coverage formatter output"
63
+ @warned_missing_coverage = true
64
+ end
65
+
66
+ def new_lines(snapshot)
67
+ snapshot.each_with_object({}) do |(source_file, file_coverage), result|
68
+ next unless source_file?(source_file)
69
+
70
+ lines = new_line_numbers(
71
+ file_coverage,
72
+ previous_line_counts(source_file)
73
+ )
74
+ result[source_file] = lines unless lines.empty?
75
+ end
76
+ end
77
+
78
+ def new_line_numbers(file_coverage, previous_counts)
79
+ line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
80
+ next unless count.to_i.positive?
81
+ next if previous_counts.fetch(index, 0).to_i.positive?
82
+
83
+ index + 1
84
+ end
85
+ end
86
+
87
+ def previous_line_counts(source_file)
88
+ line_counts_for(@previous_snapshot.fetch(source_file, []))
89
+ end
90
+
91
+ def line_counts_for(file_coverage)
92
+ case file_coverage
93
+ when Hash
94
+ Array(file_coverage["lines"])
95
+ else
96
+ Array(file_coverage)
97
+ end
98
+ end
99
+
100
+ def source_file?(path)
101
+ expanded = File.expand_path(path)
102
+ expanded.start_with?(Dir.pwd) &&
103
+ !expanded.include?("#{File::SEPARATOR}spec#{File::SEPARATOR}")
104
+ end
105
+
106
+ def serializable_report
107
+ @coverage_by_test.transform_values do |source_map|
108
+ source_map.transform_values { |lines| lines.uniq.sort }
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser_current"
4
+
5
+ module Henitai
6
+ # Detects obvious equivalent mutants before execution.
7
+ #
8
+ # The detector is intentionally conservative: it only marks mutations as
9
+ # equivalent when the AST shape and the operand literals make the equivalence
10
+ # obvious enough to be useful.
11
+ class EquivalenceDetector
12
+ def analyze(mutant)
13
+ return mutant unless equivalent_arithmetic_mutation?(mutant)
14
+
15
+ mutant.status = :equivalent
16
+ mutant
17
+ end
18
+
19
+ private
20
+
21
+ def equivalent_arithmetic_mutation?(mutant)
22
+ original = mutant.original_node
23
+ mutated = mutant.mutated_node
24
+ return false unless binary_send?(original) && binary_send?(mutated)
25
+ return false unless same_receiver?(original, mutated)
26
+
27
+ additive_equivalent?(original, mutated) ||
28
+ multiplicative_equivalent?(original, mutated)
29
+ end
30
+
31
+ def binary_send?(node)
32
+ node.is_a?(Parser::AST::Node) && node.type == :send && node.children.size >= 3
33
+ end
34
+
35
+ def same_receiver?(original, mutated)
36
+ same_node?(original.children[0], mutated.children[0])
37
+ end
38
+
39
+ def additive_equivalent?(original, mutated)
40
+ additive_operator?(original.children[1]) &&
41
+ additive_operator?(mutated.children[1]) &&
42
+ zero_operand?(original) &&
43
+ zero_operand?(mutated)
44
+ end
45
+
46
+ def multiplicative_equivalent?(original, mutated)
47
+ multiplicative_operator?(original.children[1]) &&
48
+ multiplicative_operator?(mutated.children[1]) &&
49
+ one_operand?(original) &&
50
+ one_operand?(mutated)
51
+ end
52
+
53
+ def additive_operator?(operator)
54
+ %i[+ -].include?(operator)
55
+ end
56
+
57
+ def multiplicative_operator?(operator)
58
+ %i[* / **].include?(operator)
59
+ end
60
+
61
+ def zero_operand?(node)
62
+ numeric_operand?(node, 0)
63
+ end
64
+
65
+ def one_operand?(node)
66
+ numeric_operand?(node, 1)
67
+ end
68
+
69
+ def numeric_operand?(node, value)
70
+ operand = node.children[2]
71
+ return false unless operand.is_a?(Parser::AST::Node)
72
+
73
+ case operand.type
74
+ when :int, :float
75
+ operand.children.first == value || operand.children.first == value.to_i
76
+ else
77
+ false
78
+ end
79
+ end
80
+
81
+ def same_node?(left, right)
82
+ left == right
83
+ end
84
+ end
85
+ end