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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Orchestrates the full mutation testing pipeline.
5
+ #
6
+ # Pipeline phases (Phase-Gate model):
7
+ #
8
+ # Gate 1 — Subject selection
9
+ # Resolve source files from includes, apply --since filter (incremental),
10
+ # build Subject list from AST.
11
+ #
12
+ # Gate 2 — Mutant generation
13
+ # Apply operators to each Subject's AST. Filter arid (non-productive)
14
+ # nodes via ignore_patterns. Produces the initial mutant list.
15
+ #
16
+ # Gate 3 — Static filtering
17
+ # Remove ignored mutants (pattern matches), compile-time errors.
18
+ # Apply per-test coverage data: mark :no_coverage for uncovered mutants.
19
+ #
20
+ # Gate 4 — Mutant execution
21
+ # Run surviving mutants in isolated child processes (fork isolation).
22
+ # Each child process loads the test suite with the mutated method
23
+ # injected via Module#define_method. Collect kill/survive/timeout results.
24
+ #
25
+ # Gate 5 — Reporting
26
+ # Write results to configured reporters (terminal, html, json, dashboard).
27
+ #
28
+ class Runner
29
+ attr_reader :config, :result
30
+
31
+ def initialize(config: Configuration.load, subjects: nil, since: nil)
32
+ @config = config
33
+ @subjects = subjects
34
+ @since = since
35
+ end
36
+
37
+ # Entry point — runs the full pipeline and returns a Result.
38
+ # @return [Result]
39
+ def run
40
+ started_at = Time.now
41
+ source_files = self.source_files
42
+ bootstrap_coverage(source_files)
43
+ subjects = resolve_subjects(source_files)
44
+ mutants = generate_mutants(subjects)
45
+ mutants = filter_mutants(mutants)
46
+ mutants = execute_mutants(mutants)
47
+ finished_at = Time.now
48
+
49
+ build_result(mutants, started_at, finished_at)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_subjects(source_files = self.source_files)
55
+ subjects = subject_resolver.resolve_from_files(source_files)
56
+ return subjects if pattern_subjects.empty?
57
+
58
+ selected_subjects = pattern_subjects.flat_map do |pattern|
59
+ subject_resolver.apply_pattern(subjects, pattern.expression)
60
+ end
61
+ unique_subjects(selected_subjects)
62
+ end
63
+
64
+ def generate_mutants(subjects)
65
+ mutant_generator.generate(subjects, operators, config:)
66
+ end
67
+
68
+ def filter_mutants(mutants)
69
+ static_filter.apply(mutants, config)
70
+ end
71
+
72
+ def execute_mutants(mutants)
73
+ execution_engine.run(
74
+ mutants,
75
+ integration,
76
+ config,
77
+ progress_reporter: progress_reporter
78
+ )
79
+ end
80
+
81
+ def report(result)
82
+ Reporter.run_all(names: config.reporters, result:, config:)
83
+ end
84
+
85
+ def persist_history(result, recorded_at)
86
+ history_store.record(
87
+ result,
88
+ version: Henitai::VERSION,
89
+ recorded_at:
90
+ )
91
+ end
92
+
93
+ def build_result(mutants, started_at, finished_at)
94
+ @result = Result.new(
95
+ mutants:,
96
+ started_at:,
97
+ finished_at:
98
+ )
99
+ persist_history(@result, finished_at)
100
+ report(@result)
101
+ @result
102
+ end
103
+
104
+ def subject_resolver
105
+ @subject_resolver ||= SubjectResolver.new
106
+ end
107
+
108
+ def git_diff_analyzer
109
+ @git_diff_analyzer ||= GitDiffAnalyzer.new
110
+ end
111
+
112
+ def mutant_generator
113
+ @mutant_generator ||= MutantGenerator.new
114
+ end
115
+
116
+ def static_filter
117
+ @static_filter ||= StaticFilter.new
118
+ end
119
+
120
+ def execution_engine
121
+ @execution_engine ||= ExecutionEngine.new
122
+ end
123
+
124
+ def coverage_bootstrapper
125
+ @coverage_bootstrapper ||= CoverageBootstrapper.new
126
+ end
127
+
128
+ def bootstrap_coverage(source_files)
129
+ coverage_bootstrapper.ensure!(source_files:, config:, integration:)
130
+ end
131
+
132
+ def integration
133
+ @integration ||= Integration.for(config.integration).new
134
+ end
135
+
136
+ def operators
137
+ @operators ||= Operator.for_set(config.operators)
138
+ end
139
+
140
+ def progress_reporter
141
+ return nil unless Array(config.reporters).map(&:to_s).include?("terminal")
142
+
143
+ Reporter::Terminal.new(config:)
144
+ end
145
+
146
+ def history_store
147
+ @history_store ||= MutantHistoryStore.new(
148
+ path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
149
+ )
150
+ end
151
+
152
+ def source_files
153
+ @source_files ||= begin
154
+ included_files = Array(config.includes).flat_map do |include_path|
155
+ Dir.glob(File.join(include_path, "**", "*.rb"))
156
+ end.uniq
157
+
158
+ if @since
159
+ changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
160
+ changed_file_set = changed_files.map { |path| normalize_path(path) }
161
+
162
+ included_files.select do |path|
163
+ changed_file_set.include?(normalize_path(path))
164
+ end
165
+ else
166
+ included_files
167
+ end
168
+ end
169
+ end
170
+
171
+ def pattern_subjects
172
+ Array(@subjects)
173
+ end
174
+
175
+ def unique_subjects(subjects)
176
+ subjects.uniq { |subject| [subject.expression, subject.source_file] }
177
+ end
178
+
179
+ def normalize_path(path)
180
+ File.expand_path(path)
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Samples mutants in a strategy-aware, deterministic way.
5
+ class SamplingStrategy
6
+ def sample(mutants, ratio:, strategy: :stratified)
7
+ strategy = strategy.to_sym if strategy.respond_to?(:to_sym)
8
+
9
+ case strategy
10
+ when :stratified
11
+ stratified_sample(Array(mutants), ratio:)
12
+ else
13
+ raise ArgumentError, "Unsupported sampling strategy: #{strategy}"
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def stratified_sample(mutants, ratio:)
20
+ return [] if ratio.to_f <= 0.0
21
+
22
+ mutants.group_by { |mutant| mutant.subject.expression }.flat_map do |_subject, group|
23
+ group.take(sample_count(group.size, ratio))
24
+ end
25
+ end
26
+
27
+ def sample_count(size, ratio)
28
+ count = (size * ratio).ceil
29
+ count = 1 if count < 1
30
+ [count, size].min
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Captures the result of one baseline or mutant test run.
5
+ class ScenarioExecutionResult
6
+ attr_reader :status, :stdout, :stderr, :exit_status, :log_path
7
+
8
+ def initialize(status:, stdout:, stderr:, log_path:, exit_status: nil)
9
+ @status = status
10
+ @stdout = stdout.to_s
11
+ @stderr = stderr.to_s
12
+ @log_path = log_path
13
+ @exit_status = exit_status
14
+ end
15
+
16
+ def survived?
17
+ status == :survived
18
+ end
19
+
20
+ def killed?
21
+ status == :killed
22
+ end
23
+
24
+ def timeout?
25
+ status == :timeout
26
+ end
27
+
28
+ def ==(other)
29
+ return status == other.status if other.respond_to?(:status)
30
+ return status == other if other.is_a?(Symbol)
31
+
32
+ super
33
+ end
34
+
35
+ def log_text
36
+ @log_text ||= if File.exist?(log_path)
37
+ File.read(log_path)
38
+ else
39
+ combined_output
40
+ end
41
+ end
42
+
43
+ def combined_output
44
+ [
45
+ (stdout.empty? ? nil : stream_section("stdout", stdout)),
46
+ (stderr.empty? ? nil : stream_section("stderr", stderr))
47
+ ].compact.join("\n")
48
+ end
49
+
50
+ def tail(lines = 12)
51
+ log_text.lines.last(lines).join
52
+ end
53
+
54
+ def should_show_logs?(all_logs: false)
55
+ all_logs || timeout?
56
+ end
57
+
58
+ def failure_tail(all_logs: false, lines: 12)
59
+ return combined_output if all_logs
60
+ return "" unless should_show_logs?(all_logs:)
61
+
62
+ tail(lines)
63
+ end
64
+
65
+ private
66
+
67
+ def stream_section(name, content)
68
+ "#{name}:\n#{content}"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/source/buffer"
4
+ require "prism"
5
+
6
+ module Henitai
7
+ # Parses Ruby source into parser-compatible AST nodes using Prism.
8
+ #
9
+ # The parser translation layer keeps the `Parser::AST::Node` shape that the
10
+ # mutation pipeline and Unparser already expect, while delegating syntax
11
+ # support to Prism.
12
+ class SourceParser
13
+ DEFAULT_PATH = "(string)"
14
+
15
+ def self.parse(source, path: DEFAULT_PATH)
16
+ new.parse(source, path:)
17
+ end
18
+
19
+ def self.parse_file(path)
20
+ new.parse_file(path)
21
+ end
22
+
23
+ def parse(source, path: DEFAULT_PATH)
24
+ Prism::Translation::ParserCurrent.new.parse(source_buffer(source, path))
25
+ end
26
+
27
+ def parse_file(path)
28
+ # Ruby's file encoding rules apply here. Projects that use explicit source
29
+ # encoding comments can be handled by a future encoding-aware option.
30
+ parse(File.read(path), path:)
31
+ end
32
+
33
+ private
34
+
35
+ def source_buffer(source, path)
36
+ Parser::Source::Buffer.new(path).tap do |buffer|
37
+ buffer.source = source
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Henitai
6
+ # Applies static, pre-execution filtering to generated mutants.
7
+ class StaticFilter
8
+ DEFAULT_COVERAGE_REPORT_PATH = "coverage/.resultset.json"
9
+ DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = "coverage/henitai_per_test.json"
10
+
11
+ # This method is the gate-level filter orchestrator.
12
+ def apply(mutants, config)
13
+ coverage_lines = coverage_lines_for(config)
14
+ coverage_report_present = coverage_report_present?(config)
15
+
16
+ Array(mutants).each do |mutant|
17
+ next if ignored_mutant?(mutant, config)
18
+
19
+ mark_equivalent_mutant(mutant)
20
+ mark_no_coverage_mutant(
21
+ mutant,
22
+ coverage_report_present:,
23
+ coverage_lines:
24
+ )
25
+ end
26
+
27
+ mutants
28
+ end
29
+
30
+ def coverage_lines_for(config)
31
+ coverage_report_path = coverage_report_path(config)
32
+ per_test_coverage_report_path = per_test_coverage_report_path(config)
33
+
34
+ coverage_lines = coverage_lines_by_file(coverage_report_path)
35
+ return coverage_lines unless coverage_lines.empty?
36
+
37
+ coverage_lines_from_test_lines(
38
+ test_lines_by_file(per_test_coverage_report_path)
39
+ )
40
+ end
41
+
42
+ def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
43
+ return {} unless File.exist?(path)
44
+
45
+ coverage = Hash.new { |hash, key| hash[key] = [] }
46
+ JSON.parse(File.read(path)).each_value do |result|
47
+ result.fetch("coverage", {}).each do |file, file_coverage|
48
+ coverage[normalize_path(file)].concat(covered_lines(file_coverage))
49
+ end
50
+ end
51
+
52
+ coverage.transform_values(&:uniq).transform_values(&:sort)
53
+ end
54
+
55
+ def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
56
+ return {} unless File.exist?(path)
57
+
58
+ parsed = JSON.parse(File.read(path))
59
+ return {} unless parsed.is_a?(Hash)
60
+
61
+ parsed.transform_values do |coverage|
62
+ normalize_test_coverage(coverage)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def ignored?(mutant, config)
69
+ source = source_for(mutant)
70
+ return false unless source
71
+
72
+ compiled_ignore_patterns(config).any? do |pattern|
73
+ pattern.match?(source)
74
+ end
75
+ end
76
+
77
+ def ignored_mutant?(mutant, config)
78
+ return false unless ignored?(mutant, config)
79
+
80
+ mutant.status = :ignored
81
+ true
82
+ end
83
+
84
+ def mark_equivalent_mutant(mutant)
85
+ return unless mutant.pending?
86
+
87
+ equivalence_detector.analyze(mutant)
88
+ end
89
+
90
+ def mark_no_coverage_mutant(mutant, coverage_report_present:, coverage_lines:)
91
+ return unless coverage_report_present
92
+ return unless mutant.pending?
93
+ return if covered?(mutant, coverage_lines)
94
+
95
+ mutant.status = :no_coverage
96
+ end
97
+
98
+ def covered?(mutant, coverage_lines)
99
+ file = normalize_path(mutant.location[:file])
100
+ start_line = mutant.location[:start_line]
101
+
102
+ Array(coverage_lines[file]).include?(start_line)
103
+ end
104
+
105
+ def source_for(mutant)
106
+ original_node = mutant.original_node
107
+ location = original_node&.location
108
+ expression = location&.expression
109
+ expression&.source
110
+ end
111
+
112
+ def compiled_ignore_patterns(config)
113
+ patterns = Array(config&.ignore_patterns).dup.freeze
114
+ @compiled_ignore_patterns ||= {}
115
+ @compiled_ignore_patterns[patterns] ||= patterns.map { |pattern| Regexp.new(pattern) }
116
+ end
117
+
118
+ def covered_lines(file_coverage)
119
+ Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
120
+ index + 1 if count.to_i.positive?
121
+ end
122
+ end
123
+
124
+ def normalize_test_coverage(coverage)
125
+ case coverage
126
+ when Hash
127
+ coverage.transform_values do |lines|
128
+ Array(lines).grep(Integer).uniq.sort
129
+ end
130
+ else
131
+ Array(coverage).grep(Integer).uniq.sort
132
+ end
133
+ end
134
+
135
+ def coverage_lines_from_test_lines(test_lines)
136
+ coverage = Hash.new { |hash, key| hash[key] = [] }
137
+
138
+ test_lines.each_value do |source_coverage|
139
+ next unless source_coverage.is_a?(Hash)
140
+
141
+ source_coverage.each do |source_file, lines|
142
+ coverage[normalize_path(source_file)].concat(Array(lines).grep(Integer))
143
+ end
144
+ end
145
+
146
+ coverage.transform_values(&:uniq).transform_values(&:sort)
147
+ end
148
+
149
+ def normalize_path(path)
150
+ File.expand_path(path)
151
+ end
152
+
153
+ def equivalence_detector
154
+ @equivalence_detector ||= EquivalenceDetector.new
155
+ end
156
+
157
+ def coverage_report_path(config)
158
+ File.join(coverage_dir_for(config), ".resultset.json")
159
+ end
160
+
161
+ def coverage_report_present?(config)
162
+ coverage_report_path = coverage_report_path(config)
163
+ per_test_coverage_report_path = per_test_coverage_report_path(config)
164
+
165
+ File.exist?(coverage_report_path) || File.exist?(per_test_coverage_report_path)
166
+ end
167
+
168
+ def per_test_coverage_report_path(config)
169
+ reports_dir = reports_dir_for(config)
170
+ File.join(reports_dir, File.basename(DEFAULT_PER_TEST_COVERAGE_REPORT_PATH))
171
+ end
172
+
173
+ def coverage_dir_for(config)
174
+ return "coverage" unless config.respond_to?(:reports_dir)
175
+ return "coverage" if config.reports_dir.nil? || config.reports_dir.empty?
176
+
177
+ File.join(config.reports_dir, "coverage")
178
+ end
179
+
180
+ def reports_dir_for(config)
181
+ return "coverage" unless config.respond_to?(:reports_dir)
182
+
183
+ config.reports_dir || "coverage"
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unparser"
4
+
5
+ module Henitai
6
+ # Suppresses mutants that do not produce syntactically valid Ruby.
7
+ class StillbornFilter
8
+ def suppressed?(mutant)
9
+ source = render(mutant)
10
+ return true unless source
11
+
12
+ RubyVM::InstructionSequence.compile(wrapped_source(source))
13
+ false
14
+ rescue SyntaxError
15
+ true
16
+ end
17
+
18
+ private
19
+
20
+ def render(mutant)
21
+ Unparser.unparse(mutant.mutated_node)
22
+ rescue StandardError
23
+ nil
24
+ end
25
+
26
+ def wrapped_source(source)
27
+ <<~RUBY
28
+ def __henitai_stillborn__
29
+ #{source}
30
+ end
31
+ RUBY
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Represents an addressable unit of source code to be mutated.
5
+ #
6
+ # Subjects are expressed using a compact syntax:
7
+ # Foo::Bar#instance_method — specific instance method
8
+ # Foo::Bar.class_method — specific class method
9
+ # Foo::Bar* — all methods on Foo::Bar
10
+ # Foo* — all methods in the Foo namespace
11
+ #
12
+ # The Subject is resolved from the AST before mutation begins.
13
+ # Test selection uses longest-prefix matching against example group
14
+ # descriptions in the test suite.
15
+ class Subject
16
+ attr_reader :namespace, :method_name, :method_type, :source_file,
17
+ :source_range, :ast_node
18
+
19
+ # @param expression [String] subject expression, e.g. "Foo::Bar#method"
20
+ def self.parse(expression)
21
+ new(expression:)
22
+ end
23
+
24
+ # @param namespace [String] fully-qualified module/class name
25
+ # @param method_name [String] method name (nil for wildcard subjects)
26
+ # @param method_type [Symbol] :instance or :class
27
+ # @param source_location [Hash] file/range metadata for the subject source
28
+ # The explicit keyword API matches the public call sites and keeps the
29
+ # metadata fields discoverable without a single options hash.
30
+ # rubocop:disable Metrics/ParameterLists
31
+ def initialize(expression: nil, namespace: nil, method_name: nil,
32
+ method_type: :instance, source_location: nil, ast_node: nil)
33
+ if expression
34
+ parse_expression(expression)
35
+ else
36
+ @namespace = namespace
37
+ @method_name = method_name
38
+ @method_type = method_type
39
+ end
40
+ @source_file = source_location&.fetch(:file, nil)
41
+ @source_range = source_location&.fetch(:range, nil)
42
+ @ast_node = ast_node
43
+ end
44
+ # rubocop:enable Metrics/ParameterLists
45
+
46
+ # Full addressable expression, e.g. "Foo::Bar#method"
47
+ def expression
48
+ sep = @method_type == :class ? "." : "#"
49
+ @method_name ? "#{@namespace}#{sep}#{@method_name}" : "#{@namespace}*"
50
+ end
51
+
52
+ def wildcard?
53
+ @method_name.nil?
54
+ end
55
+
56
+ private
57
+
58
+ def parse_expression(expr)
59
+ if (m = expr.match(/\A(.+?)([#.])(\w+)\z/))
60
+ @namespace = m[1]
61
+ @method_type = m[2] == "." ? :class : :instance
62
+ @method_name = m[3]
63
+ else
64
+ # Wildcard: "Foo*" or "Foo::Bar*"
65
+ @namespace = expr.delete_suffix("*")
66
+ @method_name = nil
67
+ @method_type = :instance
68
+ end
69
+ end
70
+ end
71
+ end