henitai 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
data/lib/henitai/cli.rb
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
|
|
6
10
|
module Henitai
|
|
7
11
|
# Command-line interface entry point.
|
|
8
12
|
#
|
|
@@ -18,55 +22,33 @@ module Henitai
|
|
|
18
22
|
# --all-logs Print all captured child logs
|
|
19
23
|
# -h, --help Show this help message
|
|
20
24
|
# -v, --version Show version
|
|
21
|
-
#
|
|
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/+.
|
|
22
28
|
class CLI
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
" timeout: 10.0",
|
|
30
|
-
" max_flaky_retries: 3",
|
|
31
|
-
" sampling:",
|
|
32
|
-
" ratio: 0.05",
|
|
33
|
-
" strategy: stratified",
|
|
34
|
-
"reports_dir: reports",
|
|
35
|
-
"thresholds:",
|
|
36
|
-
" high: 80",
|
|
37
|
-
" low: 60"
|
|
38
|
-
].freeze
|
|
29
|
+
include CommandSupport
|
|
30
|
+
include Options
|
|
31
|
+
include RunCommand
|
|
32
|
+
include CleanCommand
|
|
33
|
+
include InitCommand
|
|
34
|
+
include OperatorCommand
|
|
39
35
|
|
|
40
36
|
REPORT_CLEANUP_PATHS = [
|
|
41
|
-
%w[mutation-logs baseline.log],
|
|
42
|
-
%w[mutation-logs baseline.stdout.log],
|
|
43
|
-
%w[mutation-logs baseline.stderr.log],
|
|
44
37
|
%w[coverage .resultset.json],
|
|
45
38
|
%w[coverage .last_run.json],
|
|
46
|
-
["henitai_per_test.json"]
|
|
39
|
+
["henitai_per_test.json"],
|
|
40
|
+
[CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE]
|
|
47
41
|
].freeze
|
|
48
42
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"HashLiteral" => ["Hash literals", "{ a: 1 } -> {}"],
|
|
59
|
-
"RangeLiteral" => ["Range literals", "1..5 -> 1...5"],
|
|
60
|
-
"SafeNavigation" => ["Safe navigation", "user&.name -> user.name"],
|
|
61
|
-
"PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
|
|
62
|
-
"BlockStatement" => ["Block statements", "{ do_work } -> {}"],
|
|
63
|
-
"MethodExpression" => ["Method calls", "call_service -> nil"],
|
|
64
|
-
"AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"],
|
|
65
|
-
"MethodChainUnwrap" => ["Method chain unwrap", "a.b.c -> a.b"],
|
|
66
|
-
"RegexMutator" => ["Regex literals", "/foo+/ -> /foo*/"],
|
|
67
|
-
"UnaryOperator" => ["Unary operators", "-x -> x"],
|
|
68
|
-
"UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"]
|
|
69
|
-
}.freeze
|
|
43
|
+
# Whole directories of per-mutant scratch artifacts, removed recursively.
|
|
44
|
+
# `mutation-logs` accumulates one stdout/stderr/combined log trio per
|
|
45
|
+
# mutant (and baseline) and `mutation-coverage` one dir per mutant, so both
|
|
46
|
+
# grow to GBs on a large run; deleting the tree is the only way to reclaim
|
|
47
|
+
# it (rm_f cannot remove a directory).
|
|
48
|
+
REPORT_CLEANUP_DIRS = [
|
|
49
|
+
%w[mutation-logs],
|
|
50
|
+
%w[mutation-coverage]
|
|
51
|
+
].freeze
|
|
70
52
|
|
|
71
53
|
def self.start(argv)
|
|
72
54
|
new(argv).run
|
|
@@ -94,164 +76,6 @@ module Henitai
|
|
|
94
76
|
|
|
95
77
|
private
|
|
96
78
|
|
|
97
|
-
def run_command
|
|
98
|
-
@command_halted = false
|
|
99
|
-
options = parse_run_options
|
|
100
|
-
return if @command_halted
|
|
101
|
-
|
|
102
|
-
config = load_config(options)
|
|
103
|
-
result = run_pipeline(options, config)
|
|
104
|
-
exit(exit_status_for(result, config, fail_on_survivors: options[:fail_on_survivors]))
|
|
105
|
-
rescue StandardError => e
|
|
106
|
-
handle_run_error(e)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def clean_command
|
|
110
|
-
@command_halted = false
|
|
111
|
-
options = parse_clean_options
|
|
112
|
-
return if @command_halted
|
|
113
|
-
|
|
114
|
-
config = load_config(options)
|
|
115
|
-
removed_paths = cleanup_report_artifacts(config)
|
|
116
|
-
puts clean_summary(removed_paths)
|
|
117
|
-
rescue StandardError => e
|
|
118
|
-
handle_run_error(e)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def parse_run_options
|
|
122
|
-
options = {}
|
|
123
|
-
build_run_option_parser(options).parse!(@argv)
|
|
124
|
-
options
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def configuration_overrides(options)
|
|
128
|
-
deep_compact(
|
|
129
|
-
{
|
|
130
|
-
integration: options[:integration],
|
|
131
|
-
all_logs: options[:all_logs],
|
|
132
|
-
mutation: {
|
|
133
|
-
operators: options[:operators],
|
|
134
|
-
timeout: options[:timeout]
|
|
135
|
-
},
|
|
136
|
-
jobs: options[:jobs]
|
|
137
|
-
}
|
|
138
|
-
)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def deep_compact(value)
|
|
142
|
-
case value
|
|
143
|
-
when Hash
|
|
144
|
-
value.each_with_object({}) do |(key, nested_value), result|
|
|
145
|
-
compacted = deep_compact(nested_value)
|
|
146
|
-
result[key] = compacted unless compacted.nil?
|
|
147
|
-
end
|
|
148
|
-
when Array
|
|
149
|
-
value.map { |item| deep_compact(item) }.compact
|
|
150
|
-
else
|
|
151
|
-
value
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def build_run_option_parser(options)
|
|
156
|
-
OptionParser.new do |opts|
|
|
157
|
-
opts.banner = "Usage: henitai run [options] [SUBJECT_PATTERN...]"
|
|
158
|
-
add_since_option(opts, options)
|
|
159
|
-
add_integration_option(opts, options)
|
|
160
|
-
add_config_option(opts, options)
|
|
161
|
-
add_operator_option(opts, options)
|
|
162
|
-
add_jobs_option(opts, options)
|
|
163
|
-
add_output_option(opts, options)
|
|
164
|
-
add_survivors_from_option(opts, options)
|
|
165
|
-
add_fail_on_survivors_option(opts, options)
|
|
166
|
-
add_help_option(opts)
|
|
167
|
-
add_version_option(opts)
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def parse_clean_options
|
|
172
|
-
options = {}
|
|
173
|
-
build_clean_option_parser(options).parse!(@argv)
|
|
174
|
-
options
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def build_clean_option_parser(options)
|
|
178
|
-
OptionParser.new do |opts|
|
|
179
|
-
opts.banner = "Usage: henitai clean [options]"
|
|
180
|
-
add_config_option(opts, options)
|
|
181
|
-
add_help_option(opts)
|
|
182
|
-
add_version_option(opts)
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def add_since_option(opts, options)
|
|
187
|
-
opts.on("--since GIT_REF", "Only mutate subjects changed since GIT_REF") do |ref|
|
|
188
|
-
options[:since] = ref
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def add_integration_option(opts, options)
|
|
193
|
-
opts.on("--use INTEGRATION", "Test framework integration (rspec)") do |name|
|
|
194
|
-
options[:integration] = name
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def add_config_option(opts, options)
|
|
199
|
-
opts.on("--config PATH", "Path to .henitai.yml") do |path|
|
|
200
|
-
options[:config] = path
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def add_operator_option(opts, options)
|
|
205
|
-
opts.on("--operators SET", "Operator set: light | full") do |set|
|
|
206
|
-
options[:operators] = set
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def add_jobs_option(opts, options)
|
|
211
|
-
opts.on("--jobs N", Integer, "Number of parallel workers (default: 1)") do |n|
|
|
212
|
-
options[:jobs] = n
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def add_output_option(opts, options)
|
|
217
|
-
opts.on("--all-logs", "--verbose", "Print all captured child logs") do
|
|
218
|
-
options[:all_logs] = true
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def add_survivors_from_option(opts, options)
|
|
223
|
-
opts.on(
|
|
224
|
-
"--survivors-from PATH",
|
|
225
|
-
"Re-run only survivors from a prior report " \
|
|
226
|
-
"(partial rerun; threshold checks are skipped; dirty worktrees are included)"
|
|
227
|
-
) do |path|
|
|
228
|
-
options[:survivors_from] = path
|
|
229
|
-
end
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def add_fail_on_survivors_option(opts, options)
|
|
233
|
-
opts.on(
|
|
234
|
-
"--fail-on-survivors",
|
|
235
|
-
"Exit 1 for partial reruns when any survivors remain (otherwise exits 0)"
|
|
236
|
-
) do
|
|
237
|
-
options[:fail_on_survivors] = true
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def add_help_option(opts)
|
|
242
|
-
opts.on("-h", "--help", "Show this help") do
|
|
243
|
-
puts opts
|
|
244
|
-
@command_halted = true
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def add_version_option(opts)
|
|
249
|
-
opts.on("-v", "--version", "Show version") do
|
|
250
|
-
puts Henitai::VERSION
|
|
251
|
-
@command_halted = true
|
|
252
|
-
end
|
|
253
|
-
end
|
|
254
|
-
|
|
255
79
|
def help_text
|
|
256
80
|
<<~HELP
|
|
257
81
|
Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby 4 Mutation Testing
|
|
@@ -276,209 +100,5 @@ module Henitai
|
|
|
276
100
|
Run `henitai run --help` for full option list.
|
|
277
101
|
HELP
|
|
278
102
|
end
|
|
279
|
-
|
|
280
|
-
def run_pipeline(options, config)
|
|
281
|
-
resolved_survivors_from = resolve_survivors_from(options[:survivors_from])
|
|
282
|
-
runner = Runner.new(
|
|
283
|
-
config:,
|
|
284
|
-
subjects: subjects_from_argv,
|
|
285
|
-
since: options[:since],
|
|
286
|
-
survivors_from: resolved_survivors_from
|
|
287
|
-
)
|
|
288
|
-
runner.run
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def resolve_survivors_from(survivors_from)
|
|
292
|
-
return nil if survivors_from.nil?
|
|
293
|
-
|
|
294
|
-
# Fast path: if the path already points into reports/sessions/<session_id>/,
|
|
295
|
-
# keep it as-is so activation-recipes.json can be found by the runner.
|
|
296
|
-
report_dir = File.dirname(survivors_from)
|
|
297
|
-
parent_dir = File.dirname(report_dir)
|
|
298
|
-
# Heuristic: treat any path under a directory named "sessions" as already
|
|
299
|
-
# being a snapshot path; this keeps activation-recipes lookup correct.
|
|
300
|
-
return survivors_from if File.basename(parent_dir) == "sessions"
|
|
301
|
-
|
|
302
|
-
session_id = session_id_from_report(survivors_from)
|
|
303
|
-
return survivors_from if session_id.nil?
|
|
304
|
-
|
|
305
|
-
snapshot_path = survivors_snapshot_path(report_dir, session_id)
|
|
306
|
-
recipe_path = File.join(report_dir, "sessions", session_id, "activation-recipes.json")
|
|
307
|
-
return snapshot_path if File.exist?(recipe_path) && File.exist?(snapshot_path)
|
|
308
|
-
|
|
309
|
-
# If the recipes exist but the snapshot doesn't (e.g. partial cleanup),
|
|
310
|
-
# fall back to the path the user provided so the error message points
|
|
311
|
-
# at what they actually passed.
|
|
312
|
-
|
|
313
|
-
survivors_from
|
|
314
|
-
rescue StandardError => e
|
|
315
|
-
warn_survivors_from_resolution_error(survivors_from, e)
|
|
316
|
-
survivors_from
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def survivors_snapshot_path(report_dir, session_id)
|
|
320
|
-
File.join(report_dir, "sessions", session_id, "mutation-report.json")
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def session_id_from_report(path)
|
|
324
|
-
parsed = JSON.parse(File.read(path))
|
|
325
|
-
parsed["sessionId"]
|
|
326
|
-
rescue JSON::ParserError, Errno::ENOENT
|
|
327
|
-
nil
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
def load_config(options)
|
|
331
|
-
Configuration.load(
|
|
332
|
-
path: options.fetch(:config, Configuration::CONFIG_FILE),
|
|
333
|
-
overrides: configuration_overrides(options)
|
|
334
|
-
)
|
|
335
|
-
end
|
|
336
|
-
|
|
337
|
-
def subjects_from_argv
|
|
338
|
-
@argv.empty? ? nil : @argv.map { |expr| Subject.parse(expr) }
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
def handle_run_error(error)
|
|
342
|
-
warn "#{error.class}: #{error.message}"
|
|
343
|
-
exit 2
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def warn_survivors_from_resolution_error(survivors_from, error)
|
|
347
|
-
warn(
|
|
348
|
-
"henitai: warning: could not resolve survivors-from " \
|
|
349
|
-
"#{survivors_from}: #{error.class}: #{error.message}"
|
|
350
|
-
)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def clean_summary(removed_paths)
|
|
354
|
-
return "No generated report artifacts to clean" if removed_paths.empty?
|
|
355
|
-
|
|
356
|
-
format(
|
|
357
|
-
"Removed %<count>s generated report artifact%<plural>s",
|
|
358
|
-
count: removed_paths.length,
|
|
359
|
-
plural: removed_paths.length == 1 ? "" : "s"
|
|
360
|
-
)
|
|
361
|
-
end
|
|
362
|
-
|
|
363
|
-
def cleanup_report_artifacts(config)
|
|
364
|
-
removed_paths = report_cleanup_paths(config).select { |path| File.exist?(path) }
|
|
365
|
-
removed_paths.each { |path| FileUtils.rm_f(path) }
|
|
366
|
-
removed_paths
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
def report_cleanup_paths(config)
|
|
370
|
-
REPORT_CLEANUP_PATHS.map do |relative_path|
|
|
371
|
-
File.join(config.reports_dir, *relative_path)
|
|
372
|
-
end
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
def exit_status_for(result, config, fail_on_survivors: false)
|
|
376
|
-
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
377
|
-
warn "henitai: partial rerun - mutation score threshold not evaluated"
|
|
378
|
-
return result.survived.positive? ? 1 : 0 if fail_on_survivors
|
|
379
|
-
|
|
380
|
-
return 0
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
result.mutation_score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
def init_command
|
|
387
|
-
path = @argv.shift || Configuration::CONFIG_FILE
|
|
388
|
-
unexpected_arguments = @argv.dup
|
|
389
|
-
warn "Unexpected arguments: #{unexpected_arguments.join(' ')}" unless unexpected_arguments.empty?
|
|
390
|
-
exit 1 unless unexpected_arguments.empty?
|
|
391
|
-
|
|
392
|
-
File.write(path, init_template)
|
|
393
|
-
puts "Created #{path}"
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def operator_command
|
|
397
|
-
subcommand = @argv.shift
|
|
398
|
-
case subcommand
|
|
399
|
-
when "list" then puts operator_list_text
|
|
400
|
-
when nil, "-h", "--help" then puts operator_help_text
|
|
401
|
-
else
|
|
402
|
-
warn "Unknown operator command: #{subcommand}"
|
|
403
|
-
warn operator_help_text
|
|
404
|
-
exit 1
|
|
405
|
-
end
|
|
406
|
-
rescue ArgumentError => e
|
|
407
|
-
warn e.message
|
|
408
|
-
exit 1
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def init_template
|
|
412
|
-
template = init_template_lines
|
|
413
|
-
template << integration_block if include_default_integration?
|
|
414
|
-
"#{template.join("\n")}\n"
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
def init_template_lines
|
|
418
|
-
INIT_TEMPLATE_LINES.dup
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def include_default_integration?
|
|
422
|
-
return true unless $stdin.tty?
|
|
423
|
-
|
|
424
|
-
print "Use the default RSpec integration? [Y/n] "
|
|
425
|
-
response = $stdin.gets&.strip&.downcase
|
|
426
|
-
response.nil? || response.empty? || !%w[n no].include?(response)
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def integration_block
|
|
430
|
-
<<~YAML.chomp
|
|
431
|
-
integration:
|
|
432
|
-
name: rspec
|
|
433
|
-
YAML
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
def operator_help_text
|
|
437
|
-
<<~HELP
|
|
438
|
-
Hen'i-tai operator commands
|
|
439
|
-
|
|
440
|
-
Usage:
|
|
441
|
-
henitai operator list
|
|
442
|
-
|
|
443
|
-
Run `henitai operator list` to see all built-in operators.
|
|
444
|
-
HELP
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
def operator_list_text
|
|
448
|
-
validate_operator_metadata!
|
|
449
|
-
sections = [
|
|
450
|
-
operator_list_section("Light set", Operator::LIGHT_SET),
|
|
451
|
-
operator_list_section("Full set", Operator::FULL_SET)
|
|
452
|
-
]
|
|
453
|
-
|
|
454
|
-
["Available operators", *sections].join("\n")
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
def operator_list_section(title, names)
|
|
458
|
-
rows = names.map { |name| operator_description_row(name) }
|
|
459
|
-
([title] + rows).join("\n")
|
|
460
|
-
end
|
|
461
|
-
|
|
462
|
-
def operator_description_row(name)
|
|
463
|
-
description, example = operator_metadata[name] || fallback_operator_metadata
|
|
464
|
-
|
|
465
|
-
format("- %<name>s: %<description>s (%<example>s)", name:, description:, example:)
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def operator_metadata
|
|
469
|
-
OPERATOR_METADATA
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def fallback_operator_metadata
|
|
473
|
-
["No metadata available", "n/a"]
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
def validate_operator_metadata!
|
|
477
|
-
missing = Operator::FULL_SET - operator_metadata.keys
|
|
478
|
-
return if missing.empty?
|
|
479
|
-
|
|
480
|
-
raise ArgumentError, "Missing operator metadata for: #{missing.join(', ')}"
|
|
481
|
-
end
|
|
482
103
|
end
|
|
483
|
-
# rubocop:enable Metrics/ClassLength
|
|
484
104
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Fans a single #progress callback out to several observers so the execution
|
|
5
|
+
# engines keep exactly one progress hook. Used to run the terminal reporter
|
|
6
|
+
# and the checkpoint writer side by side.
|
|
7
|
+
class CompositeProgressReporter
|
|
8
|
+
# Assembles the active progress reporters for a run: the terminal reporter
|
|
9
|
+
# when terminal output is on, and the checkpoint writer when enabled and a
|
|
10
|
+
# file report (json/html) is configured. Returns a single reporter when only
|
|
11
|
+
# one is active, the composite when several are, or nil when none are.
|
|
12
|
+
def self.for(config:, source_provider:, full_run:)
|
|
13
|
+
active = [terminal_for(config), checkpoint_for(config, source_provider, full_run)].compact
|
|
14
|
+
return active.first if active.one?
|
|
15
|
+
|
|
16
|
+
active.empty? ? nil : new(active)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.terminal_for(config)
|
|
20
|
+
Reporter::Terminal.new(config:) if reporter?(config, "terminal")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.checkpoint_for(config, source_provider, full_run)
|
|
24
|
+
return unless config.respond_to?(:checkpoint_enabled) && config.checkpoint_enabled
|
|
25
|
+
return unless reporter?(config, "json") || reporter?(config, "html")
|
|
26
|
+
|
|
27
|
+
CheckpointReporter.new(config:, source_provider:, authoritative: full_run)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.reporter?(config, name)
|
|
31
|
+
Array(config.reporters).map(&:to_s).include?(name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(reporters)
|
|
35
|
+
@reporters = reporters.compact
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def progress(mutant, scenario_result: nil)
|
|
39
|
+
@reporters.each { |reporter| reporter.progress(mutant, scenario_result:) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -10,10 +10,21 @@ module Henitai
|
|
|
10
10
|
# Configuration is resolved from built-in defaults and the project-root
|
|
11
11
|
# `.henitai.yml` file.
|
|
12
12
|
class Configuration
|
|
13
|
-
DEFAULT_TIMEOUT
|
|
13
|
+
DEFAULT_TIMEOUT = 10.0
|
|
14
|
+
DEFAULT_TIMEOUT_MULTIPLIER = 3.0
|
|
14
15
|
DEFAULT_OPERATORS = :light
|
|
15
16
|
DEFAULT_JOBS = 1
|
|
16
17
|
DEFAULT_MAX_FLAKY_RETRIES = 3
|
|
18
|
+
# Cap on captured child stdout/stderr per stream (bytes). A runaway mutant
|
|
19
|
+
# (e.g. one that puts henitai's own suite into a spewing recursion) can
|
|
20
|
+
# otherwise write hundreds of MB per child; overflow is discarded.
|
|
21
|
+
DEFAULT_MAX_LOG_BYTES = 5_000_000
|
|
22
|
+
# Ceiling for the per-mutant auto-calibrated timeout (seconds). Keeps a
|
|
23
|
+
# runaway from running for minutes when the calibrated value is large.
|
|
24
|
+
DEFAULT_MAX_TIMEOUT = 30.0
|
|
25
|
+
# Incremental report checkpoint cadence for long full runs.
|
|
26
|
+
DEFAULT_CHECKPOINT_EVERY = 200
|
|
27
|
+
DEFAULT_CHECKPOINT_INTERVAL = 30.0
|
|
17
28
|
DEFAULT_REPORTS_DIR = "reports"
|
|
18
29
|
DEFAULT_COVERAGE_CRITERIA = {
|
|
19
30
|
test_result: true,
|
|
@@ -23,12 +34,20 @@ module Henitai
|
|
|
23
34
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
24
35
|
CONFIG_FILE = ".henitai.yml"
|
|
25
36
|
|
|
26
|
-
attr_reader :integration, :includes, :operators, :timeout,
|
|
27
|
-
:ignore_patterns, :sampling, :jobs,
|
|
28
|
-
:max_flaky_retries, :
|
|
37
|
+
attr_reader :integration, :includes, :excludes, :test_excludes, :operators, :timeout,
|
|
38
|
+
:timeout_multiplier, :ignore_patterns, :sampling, :jobs,
|
|
39
|
+
:max_flaky_retries, :max_log_bytes, :max_timeout,
|
|
40
|
+
:coverage_criteria, :thresholds,
|
|
29
41
|
:reporters, :reports_dir,
|
|
42
|
+
:checkpoint_enabled, :checkpoint_every, :checkpoint_interval,
|
|
30
43
|
:dashboard, :all_logs
|
|
31
44
|
|
|
45
|
+
# True when mutation.timeout was set explicitly (file or CLI override);
|
|
46
|
+
# a fixed timeout disables per-mutant auto-calibration.
|
|
47
|
+
def timeout_configured?
|
|
48
|
+
@timeout_configured
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
# @param path [String] path to .henitai.yml (default: project root)
|
|
33
52
|
def self.load(path: CONFIG_FILE, overrides: {})
|
|
34
53
|
new(path:, overrides:)
|
|
@@ -66,12 +85,15 @@ module Henitai
|
|
|
66
85
|
def apply_defaults(raw)
|
|
67
86
|
apply_general_defaults(raw)
|
|
68
87
|
apply_mutation_defaults(raw)
|
|
88
|
+
apply_reports_defaults(raw)
|
|
69
89
|
apply_analysis_defaults(raw)
|
|
70
90
|
end
|
|
71
91
|
|
|
72
92
|
def apply_general_defaults(raw)
|
|
73
93
|
@integration = resolve_integration_default(raw[:integration])
|
|
74
94
|
@includes = raw[:includes] || ["lib"]
|
|
95
|
+
@excludes = raw[:excludes] || []
|
|
96
|
+
@test_excludes = raw[:test_excludes] || []
|
|
75
97
|
@jobs = raw.fetch(:jobs, DEFAULT_JOBS)
|
|
76
98
|
@reporters = raw[:reporters] || ["terminal"]
|
|
77
99
|
@reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
|
|
@@ -83,14 +105,25 @@ module Henitai
|
|
|
83
105
|
mutation = raw[:mutation] || {}
|
|
84
106
|
|
|
85
107
|
@operators = (mutation[:operators] || DEFAULT_OPERATORS).to_sym
|
|
108
|
+
@timeout_configured = !mutation[:timeout].nil?
|
|
86
109
|
@timeout = mutation[:timeout] || DEFAULT_TIMEOUT
|
|
110
|
+
@timeout_multiplier = mutation[:timeout_multiplier] || DEFAULT_TIMEOUT_MULTIPLIER
|
|
87
111
|
@ignore_patterns = mutation[:ignore_patterns] || []
|
|
88
|
-
@max_flaky_retries = if mutation.key?(:max_flaky_retries)
|
|
89
|
-
mutation[:max_flaky_retries]
|
|
90
|
-
else
|
|
91
|
-
DEFAULT_MAX_FLAKY_RETRIES
|
|
92
|
-
end
|
|
93
112
|
@sampling = mutation[:sampling]
|
|
113
|
+
apply_mutation_limit_defaults(mutation)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply_mutation_limit_defaults(mutation)
|
|
117
|
+
@max_flaky_retries = mutation.fetch(:max_flaky_retries, DEFAULT_MAX_FLAKY_RETRIES)
|
|
118
|
+
@max_log_bytes = mutation[:max_log_bytes] || DEFAULT_MAX_LOG_BYTES
|
|
119
|
+
@max_timeout = mutation[:max_timeout] || DEFAULT_MAX_TIMEOUT
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def apply_reports_defaults(raw)
|
|
123
|
+
reports = raw[:reports] || {}
|
|
124
|
+
@checkpoint_enabled = reports.fetch(:checkpoint, true)
|
|
125
|
+
@checkpoint_every = reports[:checkpoint_every] || DEFAULT_CHECKPOINT_EVERY
|
|
126
|
+
@checkpoint_interval = reports[:checkpoint_interval] || DEFAULT_CHECKPOINT_INTERVAL
|
|
94
127
|
end
|
|
95
128
|
|
|
96
129
|
def apply_analysis_defaults(raw)
|