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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "source_parser"
4
+ require_relative "subject"
5
+
6
+ module Henitai
7
+ # Resolves AST subjects from Ruby source files.
8
+ #
9
+ # The resolver walks Prism-translated ASTs and extracts method definitions
10
+ # with the namespace context established by surrounding module/class nodes.
11
+ class SubjectResolver
12
+ def resolve_from_files(paths)
13
+ Array(paths).flat_map do |path|
14
+ resolve_from_file(path)
15
+ end
16
+ end
17
+
18
+ def apply_pattern(subjects, pattern)
19
+ pattern_subject = Subject.parse(pattern)
20
+
21
+ Array(subjects).select do |subject|
22
+ match_subject?(subject, pattern_subject)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def resolve_from_file(path)
29
+ subjects = []
30
+ walk(
31
+ SourceParser.parse_file(path),
32
+ namespace: nil,
33
+ singleton_context: false,
34
+ subjects:
35
+ )
36
+ subjects
37
+ end
38
+
39
+ def walk(node, namespace:, singleton_context:, subjects:)
40
+ return unless node.respond_to?(:type)
41
+ return if anonymous_class_block?(node)
42
+
43
+ namespace, singleton_context = update_context(
44
+ node,
45
+ namespace,
46
+ singleton_context
47
+ )
48
+
49
+ collect_subject(node, namespace, singleton_context, subjects)
50
+ traverse_children(node, namespace, singleton_context, subjects)
51
+ end
52
+
53
+ def collect_subject(node, namespace, singleton_context, subjects)
54
+ subject = subject_for(node, namespace, singleton_context)
55
+ subjects << subject if subject
56
+ end
57
+
58
+ def traverse_children(node, namespace, singleton_context, subjects)
59
+ node.children.each do |child|
60
+ walk(child, namespace:, singleton_context:, subjects:)
61
+ end
62
+ end
63
+
64
+ def anonymous_class_block?(node)
65
+ return false unless node.type == :block
66
+
67
+ call_node = node.children.first
68
+ return false unless call_node.respond_to?(:type)
69
+ return false unless call_node.type == :send
70
+
71
+ receiver = call_node.children.first
72
+ method_name = call_node.children[1]
73
+ receiver_name = constant_name(receiver)
74
+
75
+ anonymous_constructor_call?(receiver_name, method_name)
76
+ end
77
+
78
+ def anonymous_constructor_call?(receiver_name, method_name)
79
+ anonymous_receivers = %w[Class Module Struct Data]
80
+ anonymous_methods = %i[new define]
81
+
82
+ anonymous_receivers.include?(receiver_name) &&
83
+ anonymous_methods.include?(method_name)
84
+ end
85
+
86
+ def update_context(node, namespace, singleton_context)
87
+ case node.type
88
+ when :class, :module
89
+ [qualify_namespace(namespace, constant_name(node.children.first)),
90
+ singleton_context]
91
+ when :sclass
92
+ [namespace, true]
93
+ else
94
+ [namespace, singleton_context]
95
+ end
96
+ end
97
+
98
+ def subject_for(node, namespace, singleton_context)
99
+ case node.type
100
+ when :block
101
+ define_method_subject(node, namespace, singleton_context)
102
+ when :def
103
+ instance_subject(node, namespace, singleton_context)
104
+ when :defs
105
+ class_subject(node, namespace)
106
+ end
107
+ end
108
+
109
+ def instance_subject(node, namespace, singleton_context)
110
+ return unless namespace
111
+
112
+ Subject.new(
113
+ namespace:,
114
+ method_name: method_name(node.children.first),
115
+ method_type: singleton_context ? :class : :instance,
116
+ source_location: source_location_for(node),
117
+ ast_node: node
118
+ )
119
+ end
120
+
121
+ def class_subject(node, namespace)
122
+ return unless namespace
123
+
124
+ Subject.new(
125
+ namespace:,
126
+ method_name: method_name(node.children[1]),
127
+ method_type: :class,
128
+ source_location: source_location_for(node),
129
+ ast_node: node
130
+ )
131
+ end
132
+
133
+ def define_method_subject(node, namespace, singleton_context)
134
+ call_node = node.children.first
135
+ return unless define_method_call?(call_node)
136
+ return unless namespace
137
+
138
+ method_name = define_method_name(call_node)
139
+ return unless method_name
140
+
141
+ Subject.new(
142
+ namespace:,
143
+ method_name:,
144
+ method_type: singleton_context ? :class : :instance,
145
+ source_location: source_location_for(node),
146
+ ast_node: node
147
+ )
148
+ end
149
+
150
+ def qualify_namespace(namespace, name)
151
+ return name if namespace.nil? || namespace.empty?
152
+ return namespace if name.nil? || name.empty?
153
+
154
+ "#{namespace}::#{name}"
155
+ end
156
+
157
+ def constant_name(node)
158
+ return unless node.respond_to?(:type)
159
+
160
+ case node.type
161
+ when :const
162
+ parent_name = constant_name(node.children.first)
163
+ current_name = symbol_name(node.children.last)
164
+
165
+ return current_name if parent_name.nil? || parent_name.empty?
166
+
167
+ "#{parent_name}::#{current_name}"
168
+ when :cbase
169
+ ""
170
+ end
171
+ end
172
+
173
+ def method_name(value)
174
+ symbol_name(value)
175
+ end
176
+
177
+ def define_method_call?(call_node)
178
+ return false unless call_node.respond_to?(:type)
179
+ return false unless call_node.type == :send
180
+ return false unless call_node.children[1] == :define_method
181
+
182
+ receiver = call_node.children.first
183
+ receiver.nil? || receiver.type == :self
184
+ end
185
+
186
+ def define_method_name(call_node)
187
+ literal_method_name(call_node.children[2])
188
+ end
189
+
190
+ def literal_method_name(node)
191
+ return unless node.respond_to?(:type)
192
+
193
+ case node.type
194
+ when :sym, :str
195
+ symbol_name(node.children.first)
196
+ end
197
+ end
198
+
199
+ def symbol_name(value)
200
+ # Prism exposes identifiers as symbols (for example, :foo), so normalize
201
+ # them to the string form used by Subject expressions.
202
+ value.to_s.delete_prefix(":")
203
+ end
204
+
205
+ def source_location_for(node)
206
+ location = node.location.expression
207
+
208
+ {
209
+ file: location.source_buffer.name,
210
+ range: location.line..location.last_line
211
+ }
212
+ end
213
+
214
+ def match_subject?(subject, pattern_subject)
215
+ if pattern_subject.wildcard?
216
+ wildcard_match?(subject, pattern_subject)
217
+ else
218
+ subject.expression == pattern_subject.expression
219
+ end
220
+ end
221
+
222
+ def wildcard_match?(subject, pattern_subject)
223
+ subject_namespace = subject.namespace
224
+ pattern_namespace = pattern_subject.namespace
225
+
226
+ return false unless subject_namespace
227
+
228
+ subject_namespace == pattern_namespace ||
229
+ subject_namespace.start_with?("#{pattern_namespace}::")
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stillborn_filter"
4
+
5
+ module Henitai
6
+ # Validates that a mutant can be rendered and compiled as Ruby source.
7
+ class SyntaxValidator
8
+ def initialize
9
+ @stillborn_filter = StillbornFilter.new
10
+ end
11
+
12
+ def valid?(mutant)
13
+ !@stillborn_filter.suppressed?(mutant)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Orders test files so previously effective tests run first.
5
+ class TestPrioritizer
6
+ def sort(tests, _mutant, history)
7
+ Array(tests).each_with_index.sort_by do |test, index|
8
+ [-history_count(history, test), index]
9
+ end.map(&:first)
10
+ end
11
+
12
+ private
13
+
14
+ def history_count(history, test)
15
+ return 0 unless history.respond_to?(:fetch)
16
+
17
+ history_value = history_value_for(history, test)
18
+
19
+ case history_value
20
+ when Integer
21
+ history_value
22
+ when Hash
23
+ history_value.fetch(:kills, history_value.fetch("kills", 0)).to_i
24
+ else
25
+ history_value.to_i
26
+ end
27
+ end
28
+
29
+ def history_value_for(history, test)
30
+ history_key_candidates(test).each do |key|
31
+ value = history.fetch(key, nil)
32
+ return value unless value.nil?
33
+ end
34
+
35
+ 0
36
+ end
37
+
38
+ def history_key_candidates(test)
39
+ key = test.to_s
40
+ candidates = [key, File.expand_path(key), relative_history_key(key)]
41
+ candidates.compact.uniq
42
+ rescue StandardError
43
+ [key]
44
+ end
45
+
46
+ def relative_history_key(path)
47
+ pathname = Pathname.new(path)
48
+ return unless pathname.absolute?
49
+
50
+ pathname.relative_path_from(Pathname.pwd).to_s
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unparser"
4
+
5
+ module Henitai
6
+ # Shared safe unparse helpers for user-facing output and report serialization.
7
+ module UnparseHelper
8
+ private
9
+
10
+ def safe_unparse(node)
11
+ Unparser.unparse(node)
12
+ rescue StandardError
13
+ # Unparser does not support all AST node types, so fall back gracefully.
14
+ fallback_source(node)
15
+ end
16
+
17
+ def fallback_source(node)
18
+ return "" if node.nil?
19
+ return node.type.to_s if node.respond_to?(:type)
20
+
21
+ node.class.name
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Temporarily suppresses noisy warnings emitted by third-party libraries.
5
+ module WarningSilencer
6
+ def self.silence
7
+ original_stderr = $stderr
8
+ File.open(File::NULL, "w") do |sink|
9
+ $stderr = sink
10
+ yield
11
+ end
12
+ ensure
13
+ $stderr = original_stderr
14
+ end
15
+ end
16
+ end
data/lib/henitai.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "henitai/version"
4
+
5
+ # Hen'i-tai (変異体) — Mutation testing for Ruby
6
+ #
7
+ # Usage:
8
+ # henitai run --use rspec 'MyNamespace*'
9
+ # henitai run --since HEAD~1 'MyClass#my_method'
10
+ #
11
+ module Henitai
12
+ HISTORY_STORE_FILENAME = "mutation-history.sqlite3"
13
+
14
+ # Raised when the framework encounters a configuration error
15
+ class ConfigurationError < StandardError; end
16
+
17
+ # Raised when a subject expression cannot be resolved
18
+ class SubjectNotFound < StandardError; end
19
+
20
+ # Raised when coverage data cannot be bootstrapped or validated.
21
+ class CoverageError < StandardError; end
22
+
23
+ autoload :Configuration, "henitai/configuration"
24
+ autoload :CoverageBootstrapper, "henitai/coverage_bootstrapper"
25
+ autoload :Subject, "henitai/subject"
26
+ autoload :Mutant, "henitai/mutant"
27
+ autoload :Operator, "henitai/operator"
28
+ autoload :Operators, "henitai/operators"
29
+ autoload :SourceParser, "henitai/source_parser"
30
+ autoload :SubjectResolver, "henitai/subject_resolver"
31
+ autoload :GitDiffAnalyzer, "henitai/git_diff_analyzer"
32
+ autoload :GitDiffError, "henitai/git_diff_analyzer"
33
+ autoload :MutantGenerator, "henitai/mutant_generator"
34
+ autoload :MutantHistoryStore, "henitai/mutant_history_store"
35
+ autoload :AridNodeFilter, "henitai/arid_node_filter"
36
+ autoload :EquivalenceDetector, "henitai/equivalence_detector"
37
+ autoload :StaticFilter, "henitai/static_filter"
38
+ autoload :StillbornFilter, "henitai/stillborn_filter"
39
+ autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
40
+ autoload :CoverageFormatter, "henitai/coverage_formatter"
41
+ autoload :SyntaxValidator, "henitai/syntax_validator"
42
+ autoload :SamplingStrategy, "henitai/sampling_strategy"
43
+ autoload :TestPrioritizer, "henitai/test_prioritizer"
44
+ autoload :ExecutionEngine, "henitai/execution_engine"
45
+ autoload :Runner, "henitai/runner"
46
+ autoload :Reporter, "henitai/reporter"
47
+ autoload :Integration, "henitai/integration"
48
+ autoload :Result, "henitai/result"
49
+ autoload :WarningSilencer, "henitai/warning_silencer"
50
+ autoload :CLI, "henitai/cli"
51
+ end
@@ -0,0 +1,29 @@
1
+ module Henitai
2
+ module ConfigurationValidator
3
+ def self.validate!: (Hash[Symbol, untyped]) -> void
4
+
5
+ private
6
+
7
+ def self.validate_top_level_keys: (Hash[Symbol, untyped]) -> void
8
+ def self.validate_integration: (Hash[Symbol, untyped]) -> void
9
+ def self.validate_includes: (Hash[Symbol, untyped]) -> void
10
+ def self.validate_jobs: (Hash[Symbol, untyped]) -> void
11
+ def self.validate_reporters: (Hash[Symbol, untyped]) -> void
12
+ def self.validate_all_logs: (Hash[Symbol, untyped]) -> void
13
+ def self.validate_dashboard: (Hash[Symbol, untyped]) -> void
14
+ def self.validate_mutation: (Hash[Symbol, untyped]) -> void
15
+ def self.validate_coverage_criteria: (Hash[Symbol, untyped]) -> void
16
+ def self.validate_thresholds: (Hash[Symbol, untyped]) -> void
17
+ def self.validate_operator: (untyped) -> void
18
+ def self.validate_timeout: (untyped) -> void
19
+ def self.validate_threshold: (untyped, String) -> void
20
+ def self.validate_boolean: (untyped, String) -> void
21
+ def self.validate_optional_string: (untyped, String) -> void
22
+ def self.validate_string_array: (untyped, String) -> void
23
+ def self.warn_unknown_keys: (Hash[Symbol, untyped], Array[Symbol], ?String) -> void
24
+ def self.key_path: (?String, Symbol) -> String
25
+ def self.ensure_hash!: (untyped, String) -> void
26
+ def self.describe_array_type: (untyped) -> String
27
+ def self.configuration_error: (String) -> void
28
+ end
29
+ end