henitai 0.1.10 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +33 -7
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +17 -327
- data/lib/henitai/configuration.rb +26 -12
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +6 -11
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +43 -519
- data/lib/henitai/mutant/activator.rb +13 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +12 -91
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +148 -0
- data/lib/henitai/reporter.rb +96 -11
- data/lib/henitai/result.rb +49 -16
- data/lib/henitai/runner.rb +96 -30
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +10 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +329 -53
- metadata +46 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
class CLI
|
|
7
|
+
# Implements `henitai run`: option parsing, pipeline execution, survivors
|
|
8
|
+
# resolution, and exit-status derivation. Mixed into {CLI} so it shares the
|
|
9
|
+
# instance (and observable +exit+/+warn+ calls).
|
|
10
|
+
module RunCommand
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def run_command
|
|
14
|
+
@command_halted = false
|
|
15
|
+
options = parse_run_options
|
|
16
|
+
return if @command_halted
|
|
17
|
+
|
|
18
|
+
config = load_config(options)
|
|
19
|
+
result = run_pipeline(options, config)
|
|
20
|
+
exit(exit_status_for(result, config, fail_on_survivors: options[:fail_on_survivors]))
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
handle_run_error(e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_pipeline(options, config)
|
|
26
|
+
resolved_survivors_from = resolve_survivors_from(options[:survivors_from])
|
|
27
|
+
runner = Runner.new(
|
|
28
|
+
config:,
|
|
29
|
+
subjects: subjects_from_argv,
|
|
30
|
+
since: options[:since],
|
|
31
|
+
survivors_from: resolved_survivors_from
|
|
32
|
+
)
|
|
33
|
+
runner.run
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def subjects_from_argv
|
|
37
|
+
@argv.empty? ? nil : @argv.map { |expr| Subject.parse(expr) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_survivors_from(survivors_from)
|
|
41
|
+
return nil if survivors_from.nil?
|
|
42
|
+
|
|
43
|
+
# Fast path: if the path already points into reports/sessions/<session_id>/,
|
|
44
|
+
# keep it as-is so activation-recipes.json can be found by the runner.
|
|
45
|
+
report_dir = File.dirname(survivors_from)
|
|
46
|
+
parent_dir = File.dirname(report_dir)
|
|
47
|
+
# Heuristic: treat any path under a directory named "sessions" as already
|
|
48
|
+
# being a snapshot path; this keeps activation-recipes lookup correct.
|
|
49
|
+
return survivors_from if File.basename(parent_dir) == "sessions"
|
|
50
|
+
|
|
51
|
+
session_id = session_id_from_report(survivors_from)
|
|
52
|
+
return survivors_from if session_id.nil?
|
|
53
|
+
|
|
54
|
+
snapshot_path = survivors_snapshot_path(report_dir, session_id)
|
|
55
|
+
recipe_path = File.join(report_dir, "sessions", session_id, "activation-recipes.json")
|
|
56
|
+
return snapshot_path if File.exist?(recipe_path) && File.exist?(snapshot_path)
|
|
57
|
+
|
|
58
|
+
# If the recipes exist but the snapshot doesn't (e.g. partial cleanup),
|
|
59
|
+
# fall back to the path the user provided so the error message points
|
|
60
|
+
# at what they actually passed.
|
|
61
|
+
|
|
62
|
+
survivors_from
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
warn_survivors_from_resolution_error(survivors_from, e)
|
|
65
|
+
survivors_from
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def survivors_snapshot_path(report_dir, session_id)
|
|
69
|
+
File.join(report_dir, "sessions", session_id, "mutation-report.json")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def session_id_from_report(path)
|
|
73
|
+
parsed = JSON.parse(File.read(path))
|
|
74
|
+
parsed["sessionId"]
|
|
75
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def exit_status_for(result, config, fail_on_survivors: false)
|
|
80
|
+
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
81
|
+
warn "henitai: partial rerun - mutation score threshold not evaluated"
|
|
82
|
+
return result.survived.positive? ? 1 : 0 if fail_on_survivors
|
|
83
|
+
|
|
84
|
+
return 0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
score = result.mutation_score
|
|
88
|
+
# No valid mutants to evaluate (e.g. an incremental run with no changed
|
|
89
|
+
# code) cannot fail a threshold — treat it as success.
|
|
90
|
+
return 0 if score.nil?
|
|
91
|
+
|
|
92
|
+
score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def warn_survivors_from_resolution_error(survivors_from, error)
|
|
96
|
+
warn(
|
|
97
|
+
"henitai: warning: could not resolve survivors-from " \
|
|
98
|
+
"#{survivors_from}: #{error.class}: #{error.message}"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/henitai/cli.rb
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
require_relative "cli/command_support"
|
|
4
|
+
require_relative "cli/options"
|
|
5
|
+
require_relative "cli/run_command"
|
|
6
|
+
require_relative "cli/clean_command"
|
|
7
|
+
require_relative "cli/init_command"
|
|
8
|
+
require_relative "cli/operator_command"
|
|
9
|
+
|
|
5
10
|
module Henitai
|
|
6
11
|
# Command-line interface entry point.
|
|
7
12
|
#
|
|
@@ -17,24 +22,16 @@ module Henitai
|
|
|
17
22
|
# --all-logs Print all captured child logs
|
|
18
23
|
# -h, --help Show this help message
|
|
19
24
|
# -v, --version Show version
|
|
20
|
-
#
|
|
25
|
+
#
|
|
26
|
+
# Argument parsing and command dispatch live here; the per-command behaviour
|
|
27
|
+
# lives in the mixed-in command modules under +lib/henitai/cli/+.
|
|
21
28
|
class CLI
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
" timeout: 10.0",
|
|
29
|
-
" max_flaky_retries: 3",
|
|
30
|
-
" sampling:",
|
|
31
|
-
" ratio: 0.05",
|
|
32
|
-
" strategy: stratified",
|
|
33
|
-
"reports_dir: reports",
|
|
34
|
-
"thresholds:",
|
|
35
|
-
" high: 80",
|
|
36
|
-
" low: 60"
|
|
37
|
-
].freeze
|
|
29
|
+
include CommandSupport
|
|
30
|
+
include Options
|
|
31
|
+
include RunCommand
|
|
32
|
+
include CleanCommand
|
|
33
|
+
include InitCommand
|
|
34
|
+
include OperatorCommand
|
|
38
35
|
|
|
39
36
|
REPORT_CLEANUP_PATHS = [
|
|
40
37
|
%w[mutation-logs baseline.log],
|
|
@@ -45,28 +42,6 @@ module Henitai
|
|
|
45
42
|
["henitai_per_test.json"]
|
|
46
43
|
].freeze
|
|
47
44
|
|
|
48
|
-
OPERATOR_METADATA = {
|
|
49
|
-
"ArithmeticOperator" => ["Arithmetic operators", "a + b -> a - b"],
|
|
50
|
-
"EqualityOperator" => ["Comparison operators", "a == b -> a != b"],
|
|
51
|
-
"LogicalOperator" => ["Boolean operators", "a && b -> a || b"],
|
|
52
|
-
"BooleanLiteral" => ["Boolean literals", "true -> false"],
|
|
53
|
-
"ConditionalExpression" => ["Conditional branches", "if cond then ... end"],
|
|
54
|
-
"StringLiteral" => ["String literals", '"foo" -> ""'],
|
|
55
|
-
"ReturnValue" => ["Return expressions", "return x -> return nil"],
|
|
56
|
-
"ArrayDeclaration" => ["Array literals", "[1, 2] -> []"],
|
|
57
|
-
"HashLiteral" => ["Hash literals", "{ a: 1 } -> {}"],
|
|
58
|
-
"RangeLiteral" => ["Range literals", "1..5 -> 1...5"],
|
|
59
|
-
"SafeNavigation" => ["Safe navigation", "user&.name -> user.name"],
|
|
60
|
-
"PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
|
|
61
|
-
"BlockStatement" => ["Block statements", "{ do_work } -> {}"],
|
|
62
|
-
"MethodExpression" => ["Method calls", "call_service -> nil"],
|
|
63
|
-
"AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"],
|
|
64
|
-
"MethodChainUnwrap" => ["Method chain unwrap", "a.b.c -> a.b"],
|
|
65
|
-
"RegexMutator" => ["Regex literals", "/foo+/ -> /foo*/"],
|
|
66
|
-
"UnaryOperator" => ["Unary operators", "-x -> x"],
|
|
67
|
-
"UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"]
|
|
68
|
-
}.freeze
|
|
69
|
-
|
|
70
45
|
def self.start(argv)
|
|
71
46
|
new(argv).run
|
|
72
47
|
end
|
|
@@ -93,143 +68,6 @@ module Henitai
|
|
|
93
68
|
|
|
94
69
|
private
|
|
95
70
|
|
|
96
|
-
def run_command
|
|
97
|
-
@command_halted = false
|
|
98
|
-
options = parse_run_options
|
|
99
|
-
return if @command_halted
|
|
100
|
-
|
|
101
|
-
config = load_config(options)
|
|
102
|
-
result = run_pipeline(options, config)
|
|
103
|
-
exit(exit_status_for(result, config))
|
|
104
|
-
rescue StandardError => e
|
|
105
|
-
handle_run_error(e)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def clean_command
|
|
109
|
-
@command_halted = false
|
|
110
|
-
options = parse_clean_options
|
|
111
|
-
return if @command_halted
|
|
112
|
-
|
|
113
|
-
config = load_config(options)
|
|
114
|
-
removed_paths = cleanup_report_artifacts(config)
|
|
115
|
-
puts clean_summary(removed_paths)
|
|
116
|
-
rescue StandardError => e
|
|
117
|
-
handle_run_error(e)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def parse_run_options
|
|
121
|
-
options = {}
|
|
122
|
-
build_run_option_parser(options).parse!(@argv)
|
|
123
|
-
options
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def configuration_overrides(options)
|
|
127
|
-
deep_compact(
|
|
128
|
-
{
|
|
129
|
-
integration: options[:integration],
|
|
130
|
-
all_logs: options[:all_logs],
|
|
131
|
-
mutation: {
|
|
132
|
-
operators: options[:operators],
|
|
133
|
-
timeout: options[:timeout]
|
|
134
|
-
},
|
|
135
|
-
jobs: options[:jobs]
|
|
136
|
-
}
|
|
137
|
-
)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def deep_compact(value)
|
|
141
|
-
case value
|
|
142
|
-
when Hash
|
|
143
|
-
value.each_with_object({}) do |(key, nested_value), result|
|
|
144
|
-
compacted = deep_compact(nested_value)
|
|
145
|
-
result[key] = compacted unless compacted.nil?
|
|
146
|
-
end
|
|
147
|
-
when Array
|
|
148
|
-
value.map { |item| deep_compact(item) }.compact
|
|
149
|
-
else
|
|
150
|
-
value
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def build_run_option_parser(options)
|
|
155
|
-
OptionParser.new do |opts|
|
|
156
|
-
opts.banner = "Usage: henitai run [options] [SUBJECT_PATTERN...]"
|
|
157
|
-
add_since_option(opts, options)
|
|
158
|
-
add_integration_option(opts, options)
|
|
159
|
-
add_config_option(opts, options)
|
|
160
|
-
add_operator_option(opts, options)
|
|
161
|
-
add_jobs_option(opts, options)
|
|
162
|
-
add_output_option(opts, options)
|
|
163
|
-
add_help_option(opts)
|
|
164
|
-
add_version_option(opts)
|
|
165
|
-
end
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def parse_clean_options
|
|
169
|
-
options = {}
|
|
170
|
-
build_clean_option_parser(options).parse!(@argv)
|
|
171
|
-
options
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def build_clean_option_parser(options)
|
|
175
|
-
OptionParser.new do |opts|
|
|
176
|
-
opts.banner = "Usage: henitai clean [options]"
|
|
177
|
-
add_config_option(opts, options)
|
|
178
|
-
add_help_option(opts)
|
|
179
|
-
add_version_option(opts)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def add_since_option(opts, options)
|
|
184
|
-
opts.on("--since GIT_REF", "Only mutate subjects changed since GIT_REF") do |ref|
|
|
185
|
-
options[:since] = ref
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
def add_integration_option(opts, options)
|
|
190
|
-
opts.on("--use INTEGRATION", "Test framework integration (rspec)") do |name|
|
|
191
|
-
options[:integration] = name
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def add_config_option(opts, options)
|
|
196
|
-
opts.on("--config PATH", "Path to .henitai.yml") do |path|
|
|
197
|
-
options[:config] = path
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def add_operator_option(opts, options)
|
|
202
|
-
opts.on("--operators SET", "Operator set: light | full") do |set|
|
|
203
|
-
options[:operators] = set
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def add_jobs_option(opts, options)
|
|
208
|
-
opts.on("--jobs N", Integer, "Number of parallel workers (default: 1)") do |n|
|
|
209
|
-
options[:jobs] = n
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def add_output_option(opts, options)
|
|
214
|
-
opts.on("--all-logs", "--verbose", "Print all captured child logs") do
|
|
215
|
-
options[:all_logs] = true
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def add_help_option(opts)
|
|
220
|
-
opts.on("-h", "--help", "Show this help") do
|
|
221
|
-
puts opts
|
|
222
|
-
@command_halted = true
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def add_version_option(opts)
|
|
227
|
-
opts.on("-v", "--version", "Show version") do
|
|
228
|
-
puts Henitai::VERSION
|
|
229
|
-
@command_halted = true
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
|
|
233
71
|
def help_text
|
|
234
72
|
<<~HELP
|
|
235
73
|
Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby 4 Mutation Testing
|
|
@@ -246,6 +84,7 @@ module Henitai
|
|
|
246
84
|
bundle exec henitai run --since origin/main
|
|
247
85
|
bundle exec henitai run 'Foo::Bar#my_method'
|
|
248
86
|
bundle exec henitai run 'MyNamespace*' --operators full
|
|
87
|
+
bundle exec henitai run --survivors-from reports/mutation-report.json
|
|
249
88
|
bundle exec henitai clean
|
|
250
89
|
bundle exec henitai init
|
|
251
90
|
bundle exec henitai operator list
|
|
@@ -253,154 +92,5 @@ module Henitai
|
|
|
253
92
|
Run `henitai run --help` for full option list.
|
|
254
93
|
HELP
|
|
255
94
|
end
|
|
256
|
-
|
|
257
|
-
def run_pipeline(options, config)
|
|
258
|
-
runner = Runner.new(
|
|
259
|
-
config:,
|
|
260
|
-
subjects: subjects_from_argv,
|
|
261
|
-
since: options[:since]
|
|
262
|
-
)
|
|
263
|
-
runner.run
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def load_config(options)
|
|
267
|
-
Configuration.load(
|
|
268
|
-
path: options.fetch(:config, Configuration::CONFIG_FILE),
|
|
269
|
-
overrides: configuration_overrides(options)
|
|
270
|
-
)
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def subjects_from_argv
|
|
274
|
-
@argv.empty? ? nil : @argv.map { |expr| Subject.parse(expr) }
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def handle_run_error(error)
|
|
278
|
-
warn "#{error.class}: #{error.message}"
|
|
279
|
-
exit 2
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def clean_summary(removed_paths)
|
|
283
|
-
return "No generated report artifacts to clean" if removed_paths.empty?
|
|
284
|
-
|
|
285
|
-
format(
|
|
286
|
-
"Removed %<count>s generated report artifact%<plural>s",
|
|
287
|
-
count: removed_paths.length,
|
|
288
|
-
plural: removed_paths.length == 1 ? "" : "s"
|
|
289
|
-
)
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def cleanup_report_artifacts(config)
|
|
293
|
-
removed_paths = report_cleanup_paths(config).select { |path| File.exist?(path) }
|
|
294
|
-
removed_paths.each { |path| FileUtils.rm_f(path) }
|
|
295
|
-
removed_paths
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def report_cleanup_paths(config)
|
|
299
|
-
REPORT_CLEANUP_PATHS.map do |relative_path|
|
|
300
|
-
File.join(config.reports_dir, *relative_path)
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def exit_status_for(result, config)
|
|
305
|
-
result.mutation_score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def init_command
|
|
309
|
-
path = @argv.shift || Configuration::CONFIG_FILE
|
|
310
|
-
unexpected_arguments = @argv.dup
|
|
311
|
-
warn "Unexpected arguments: #{unexpected_arguments.join(' ')}" unless unexpected_arguments.empty?
|
|
312
|
-
exit 1 unless unexpected_arguments.empty?
|
|
313
|
-
|
|
314
|
-
File.write(path, init_template)
|
|
315
|
-
puts "Created #{path}"
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def operator_command
|
|
319
|
-
subcommand = @argv.shift
|
|
320
|
-
case subcommand
|
|
321
|
-
when "list" then puts operator_list_text
|
|
322
|
-
when nil, "-h", "--help" then puts operator_help_text
|
|
323
|
-
else
|
|
324
|
-
warn "Unknown operator command: #{subcommand}"
|
|
325
|
-
warn operator_help_text
|
|
326
|
-
exit 1
|
|
327
|
-
end
|
|
328
|
-
rescue ArgumentError => e
|
|
329
|
-
warn e.message
|
|
330
|
-
exit 1
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def init_template
|
|
334
|
-
template = init_template_lines
|
|
335
|
-
template << integration_block if include_default_integration?
|
|
336
|
-
"#{template.join("\n")}\n"
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def init_template_lines
|
|
340
|
-
INIT_TEMPLATE_LINES.dup
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def include_default_integration?
|
|
344
|
-
return true unless $stdin.tty?
|
|
345
|
-
|
|
346
|
-
print "Use the default RSpec integration? [Y/n] "
|
|
347
|
-
response = $stdin.gets&.strip&.downcase
|
|
348
|
-
response.nil? || response.empty? || !%w[n no].include?(response)
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
def integration_block
|
|
352
|
-
<<~YAML.chomp
|
|
353
|
-
integration:
|
|
354
|
-
name: rspec
|
|
355
|
-
YAML
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
def operator_help_text
|
|
359
|
-
<<~HELP
|
|
360
|
-
Hen'i-tai operator commands
|
|
361
|
-
|
|
362
|
-
Usage:
|
|
363
|
-
henitai operator list
|
|
364
|
-
|
|
365
|
-
Run `henitai operator list` to see all built-in operators.
|
|
366
|
-
HELP
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
def operator_list_text
|
|
370
|
-
validate_operator_metadata!
|
|
371
|
-
sections = [
|
|
372
|
-
operator_list_section("Light set", Operator::LIGHT_SET),
|
|
373
|
-
operator_list_section("Full set", Operator::FULL_SET)
|
|
374
|
-
]
|
|
375
|
-
|
|
376
|
-
["Available operators", *sections].join("\n")
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
def operator_list_section(title, names)
|
|
380
|
-
rows = names.map { |name| operator_description_row(name) }
|
|
381
|
-
([title] + rows).join("\n")
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
def operator_description_row(name)
|
|
385
|
-
description, example = operator_metadata[name] || fallback_operator_metadata
|
|
386
|
-
|
|
387
|
-
format("- %<name>s: %<description>s (%<example>s)", name:, description:, example:)
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
def operator_metadata
|
|
391
|
-
OPERATOR_METADATA
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
def fallback_operator_metadata
|
|
395
|
-
["No metadata available", "n/a"]
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
def validate_operator_metadata!
|
|
399
|
-
missing = Operator::FULL_SET - operator_metadata.keys
|
|
400
|
-
return if missing.empty?
|
|
401
|
-
|
|
402
|
-
raise ArgumentError, "Missing operator metadata for: #{missing.join(', ')}"
|
|
403
|
-
end
|
|
404
95
|
end
|
|
405
|
-
# rubocop:enable Metrics/ClassLength
|
|
406
96
|
end
|
|
@@ -23,7 +23,7 @@ module Henitai
|
|
|
23
23
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
24
24
|
CONFIG_FILE = ".henitai.yml"
|
|
25
25
|
|
|
26
|
-
attr_reader :integration, :includes, :operators, :timeout,
|
|
26
|
+
attr_reader :integration, :includes, :excludes, :operators, :timeout,
|
|
27
27
|
:ignore_patterns, :sampling, :jobs,
|
|
28
28
|
:max_flaky_retries, :coverage_criteria, :thresholds,
|
|
29
29
|
:reporters, :reports_dir,
|
|
@@ -35,6 +35,7 @@ module Henitai
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def initialize(path: CONFIG_FILE, overrides: {})
|
|
38
|
+
@config_dir = File.dirname(File.expand_path(path))
|
|
38
39
|
raw = load_raw_configuration(path)
|
|
39
40
|
unless raw.is_a?(Hash)
|
|
40
41
|
raise Henitai::ConfigurationError,
|
|
@@ -54,6 +55,14 @@ module Henitai
|
|
|
54
55
|
raw || {}
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
def detect_integration
|
|
59
|
+
return "rspec" if File.exist?(File.join(@config_dir, ".rspec"))
|
|
60
|
+
return "minitest" if File.directory?(File.join(@config_dir, "test"))
|
|
61
|
+
return "rspec" if File.directory?(File.join(@config_dir, "spec"))
|
|
62
|
+
|
|
63
|
+
"rspec"
|
|
64
|
+
end
|
|
65
|
+
|
|
57
66
|
def apply_defaults(raw)
|
|
58
67
|
apply_general_defaults(raw)
|
|
59
68
|
apply_mutation_defaults(raw)
|
|
@@ -61,22 +70,14 @@ module Henitai
|
|
|
61
70
|
end
|
|
62
71
|
|
|
63
72
|
def apply_general_defaults(raw)
|
|
64
|
-
integration = raw[:integration]
|
|
65
|
-
@integration = if integration.is_a?(Hash)
|
|
66
|
-
integration[:name] || "rspec"
|
|
67
|
-
elsif integration.nil?
|
|
68
|
-
"rspec"
|
|
69
|
-
else
|
|
70
|
-
integration
|
|
71
|
-
end
|
|
73
|
+
@integration = resolve_integration_default(raw[:integration])
|
|
72
74
|
@includes = raw[:includes] || ["lib"]
|
|
75
|
+
@excludes = raw[:excludes] || []
|
|
73
76
|
@jobs = raw.fetch(:jobs, DEFAULT_JOBS)
|
|
74
77
|
@reporters = raw[:reporters] || ["terminal"]
|
|
75
78
|
@reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
|
|
76
79
|
@all_logs = raw[:all_logs] == true
|
|
77
|
-
|
|
78
|
-
empty_dashboard = {}
|
|
79
|
-
@dashboard = merge_defaults(empty_dashboard, raw[:dashboard])
|
|
80
|
+
@dashboard = default_dashboard(raw[:dashboard])
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
def apply_mutation_defaults(raw)
|
|
@@ -111,6 +112,19 @@ module Henitai
|
|
|
111
112
|
end
|
|
112
113
|
end
|
|
113
114
|
|
|
115
|
+
def resolve_integration_default(integration)
|
|
116
|
+
return integration[:name] || detect_integration if integration.is_a?(Hash)
|
|
117
|
+
return detect_integration if integration.nil?
|
|
118
|
+
|
|
119
|
+
integration
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def default_dashboard(overrides)
|
|
123
|
+
# @type var empty_dashboard: Hash[Symbol, untyped]
|
|
124
|
+
empty_dashboard = {}
|
|
125
|
+
merge_defaults(empty_dashboard, overrides)
|
|
126
|
+
end
|
|
127
|
+
|
|
114
128
|
def symbolize_keys(value)
|
|
115
129
|
case value
|
|
116
130
|
when Hash
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scalars"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module ConfigurationValidator
|
|
7
|
+
# Section-level validation rules.
|
|
8
|
+
#
|
|
9
|
+
# Each +validate_*+ method inspects one configuration section, warns about
|
|
10
|
+
# unknown keys via {ConfigurationValidator.warn}, and delegates leaf value
|
|
11
|
+
# checks to {Scalars}. Failures raise +Henitai::ConfigurationError+.
|
|
12
|
+
module Rules
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate_top_level_keys(raw)
|
|
16
|
+
warn_unknown_keys(raw, VALID_TOP_LEVEL_KEYS)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate_integration(raw)
|
|
20
|
+
value = raw[:integration]
|
|
21
|
+
return if value.nil?
|
|
22
|
+
return if value.is_a?(String)
|
|
23
|
+
|
|
24
|
+
ensure_hash!(value, "integration")
|
|
25
|
+
warn_unknown_keys(value, VALID_INTEGRATION_KEYS, "integration")
|
|
26
|
+
Scalars.validate_optional_string(value[:name], "integration.name")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_includes(raw)
|
|
30
|
+
Scalars.validate_string_array(raw[:includes], "includes")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_excludes(raw)
|
|
34
|
+
Scalars.validate_string_array(raw[:excludes], "excludes")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_jobs(raw)
|
|
38
|
+
value = raw[:jobs]
|
|
39
|
+
return if value.nil?
|
|
40
|
+
return if value.is_a?(Integer)
|
|
41
|
+
|
|
42
|
+
configuration_error("Invalid configuration value for jobs: expected Integer, got #{value.class}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_reporters(raw)
|
|
46
|
+
Scalars.validate_string_array(raw[:reporters], "reporters")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_reports_dir(raw)
|
|
50
|
+
Scalars.validate_optional_string(raw[:reports_dir], "reports_dir")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_all_logs(raw)
|
|
54
|
+
value = raw[:all_logs]
|
|
55
|
+
return if value.nil?
|
|
56
|
+
|
|
57
|
+
Scalars.validate_boolean(value, "all_logs")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_dashboard(raw)
|
|
61
|
+
value = raw[:dashboard]
|
|
62
|
+
return if value.nil?
|
|
63
|
+
|
|
64
|
+
ensure_hash!(value, "dashboard")
|
|
65
|
+
warn_unknown_keys(value, VALID_DASHBOARD_KEYS, "dashboard")
|
|
66
|
+
Scalars.validate_optional_string(value[:project], "dashboard.project")
|
|
67
|
+
Scalars.validate_optional_string(value[:base_url], "dashboard.base_url")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_mutation(raw)
|
|
71
|
+
value = raw[:mutation]
|
|
72
|
+
return if value.nil?
|
|
73
|
+
|
|
74
|
+
ensure_hash!(value, "mutation")
|
|
75
|
+
warn_unknown_keys(value, VALID_MUTATION_KEYS, "mutation")
|
|
76
|
+
Scalars.validate_operator(value[:operators])
|
|
77
|
+
validate_mutation_limits(value)
|
|
78
|
+
validate_mutation_filters(value)
|
|
79
|
+
validate_sampling(value[:sampling])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_mutation_limits(value)
|
|
83
|
+
Scalars.validate_timeout(value[:timeout])
|
|
84
|
+
Scalars.validate_max_flaky_retries(value[:max_flaky_retries])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def validate_mutation_filters(value)
|
|
88
|
+
Scalars.validate_string_array(value[:ignore_patterns], "mutation.ignore_patterns")
|
|
89
|
+
Scalars.validate_ignore_patterns(value[:ignore_patterns])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_coverage_criteria(raw)
|
|
93
|
+
value = raw[:coverage_criteria]
|
|
94
|
+
return if value.nil?
|
|
95
|
+
|
|
96
|
+
ensure_hash!(value, "coverage_criteria")
|
|
97
|
+
warn_unknown_keys(value, VALID_COVERAGE_CRITERIA_KEYS, "coverage_criteria")
|
|
98
|
+
value.each { |key, flag| Scalars.validate_boolean(flag, "coverage_criteria.#{key}") }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_thresholds(raw)
|
|
102
|
+
value = raw[:thresholds]
|
|
103
|
+
return if value.nil?
|
|
104
|
+
|
|
105
|
+
ensure_hash!(value, "thresholds")
|
|
106
|
+
warn_unknown_keys(value, VALID_THRESHOLDS_KEYS, "thresholds")
|
|
107
|
+
value.each { |key, threshold| Scalars.validate_threshold(threshold, "thresholds.#{key}") }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_sampling(value)
|
|
111
|
+
return if value.nil?
|
|
112
|
+
|
|
113
|
+
ensure_hash!(value, "mutation.sampling")
|
|
114
|
+
warn_unknown_keys(value, VALID_SAMPLING_KEYS, "mutation.sampling")
|
|
115
|
+
Scalars.validate_sampling_completeness(value)
|
|
116
|
+
Scalars.validate_sampling_ratio(value[:ratio])
|
|
117
|
+
Scalars.validate_sampling_strategy(value[:strategy])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def warn_unknown_keys(raw, allowed_keys, path = nil)
|
|
121
|
+
raw.each_key do |key|
|
|
122
|
+
next if allowed_keys.include?(key)
|
|
123
|
+
|
|
124
|
+
ConfigurationValidator.warn "Unknown configuration key: #{key_path(path, key)}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def key_path(path, key)
|
|
129
|
+
path ? "#{path}.#{key}" : key.to_s
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ensure_hash!(value, path)
|
|
133
|
+
return if value.is_a?(Hash)
|
|
134
|
+
|
|
135
|
+
configuration_error("Invalid configuration value for #{path}: expected Hash, got #{value.class}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def configuration_error(message)
|
|
139
|
+
raise Henitai::ConfigurationError, message
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|