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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/assets/schema/henitai.schema.json +123 -0
- data/exe/henitai +6 -0
- data/lib/henitai/arid_node_filter.rb +97 -0
- data/lib/henitai/cli.rb +341 -0
- data/lib/henitai/configuration.rb +132 -0
- data/lib/henitai/configuration_validator.rb +293 -0
- data/lib/henitai/coverage_bootstrapper.rb +75 -0
- data/lib/henitai/coverage_formatter.rb +112 -0
- data/lib/henitai/equivalence_detector.rb +85 -0
- data/lib/henitai/execution_engine.rb +174 -0
- data/lib/henitai/git_diff_analyzer.rb +82 -0
- data/lib/henitai/integration.rb +417 -0
- data/lib/henitai/mutant/activator.rb +234 -0
- data/lib/henitai/mutant.rb +68 -0
- data/lib/henitai/mutant_generator.rb +158 -0
- data/lib/henitai/mutant_history_store.rb +279 -0
- data/lib/henitai/operator.rb +96 -0
- data/lib/henitai/operators/arithmetic_operator.rb +46 -0
- data/lib/henitai/operators/array_declaration.rb +52 -0
- data/lib/henitai/operators/assignment_expression.rb +78 -0
- data/lib/henitai/operators/block_statement.rb +31 -0
- data/lib/henitai/operators/boolean_literal.rb +70 -0
- data/lib/henitai/operators/conditional_expression.rb +184 -0
- data/lib/henitai/operators/equality_operator.rb +41 -0
- data/lib/henitai/operators/hash_literal.rb +66 -0
- data/lib/henitai/operators/logical_operator.rb +84 -0
- data/lib/henitai/operators/method_expression.rb +56 -0
- data/lib/henitai/operators/pattern_match.rb +66 -0
- data/lib/henitai/operators/range_literal.rb +40 -0
- data/lib/henitai/operators/return_value.rb +105 -0
- data/lib/henitai/operators/safe_navigation.rb +34 -0
- data/lib/henitai/operators/string_literal.rb +64 -0
- data/lib/henitai/operators.rb +25 -0
- data/lib/henitai/parser_current.rb +7 -0
- data/lib/henitai/reporter.rb +432 -0
- data/lib/henitai/result.rb +170 -0
- data/lib/henitai/runner.rb +183 -0
- data/lib/henitai/sampling_strategy.rb +33 -0
- data/lib/henitai/scenario_execution_result.rb +71 -0
- data/lib/henitai/source_parser.rb +41 -0
- data/lib/henitai/static_filter.rb +186 -0
- data/lib/henitai/stillborn_filter.rb +34 -0
- data/lib/henitai/subject.rb +71 -0
- data/lib/henitai/subject_resolver.rb +232 -0
- data/lib/henitai/syntax_validator.rb +16 -0
- data/lib/henitai/test_prioritizer.rb +55 -0
- data/lib/henitai/unparse_helper.rb +24 -0
- data/lib/henitai/version.rb +5 -0
- data/lib/henitai/warning_silencer.rb +16 -0
- data/lib/henitai.rb +51 -0
- data/sig/configuration_validator.rbs +29 -0
- data/sig/henitai.rbs +594 -0
- data/sig/unparser.rbs +3 -0
- 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,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
|