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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Progress observer that flushes the canonical report to disk periodically
|
|
5
|
+
# during a run, so a crash on a long run keeps partial results and the report
|
|
6
|
+
# grows visibly mid-run instead of appearing only at the end (Gate 5).
|
|
7
|
+
#
|
|
8
|
+
# Each flush serialises only the mutants completed since the previous flush
|
|
9
|
+
# and folds them into the on-disk JSON report. When HTML reporting is active,
|
|
10
|
+
# the self-contained HTML report is regenerated from that merged JSON schema.
|
|
11
|
+
# Reuses {Result#to_stryker_schema} and {CanonicalReportWriter}.
|
|
12
|
+
#
|
|
13
|
+
# On a full (authoritative) run the first flush replaces the report — wiping
|
|
14
|
+
# entries left by earlier runs, including deleted files — and later flushes
|
|
15
|
+
# merge their batch in. On a scoped/partial run every flush merges, matching
|
|
16
|
+
# the end-of-run reporter's behaviour.
|
|
17
|
+
class CheckpointReporter
|
|
18
|
+
MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
19
|
+
|
|
20
|
+
def initialize(config:, source_provider:, authoritative:, started_at: Time.now, clock: MONOTONIC)
|
|
21
|
+
@config = config
|
|
22
|
+
@source_provider = source_provider
|
|
23
|
+
@authoritative = authoritative
|
|
24
|
+
@started_at = started_at
|
|
25
|
+
@clock = clock
|
|
26
|
+
@batch = []
|
|
27
|
+
@last_flush_at = clock.call
|
|
28
|
+
@flushed = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def progress(mutant, **)
|
|
32
|
+
@batch << mutant
|
|
33
|
+
flush! if due?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def due?
|
|
39
|
+
@batch.size >= @config.checkpoint_every ||
|
|
40
|
+
(@clock.call - @last_flush_at) >= @config.checkpoint_interval
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def flush!
|
|
44
|
+
first_full_flush = @authoritative && !@flushed
|
|
45
|
+
merged_schema = CanonicalReportWriter.write(
|
|
46
|
+
build_schema(@batch),
|
|
47
|
+
path: canonical_path,
|
|
48
|
+
authoritative: first_full_flush
|
|
49
|
+
)
|
|
50
|
+
write_html(merged_schema) if html_reporter?
|
|
51
|
+
@flushed = true
|
|
52
|
+
@batch = []
|
|
53
|
+
@last_flush_at = @clock.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_schema(mutants)
|
|
57
|
+
Result.new(
|
|
58
|
+
mutants: mutants,
|
|
59
|
+
started_at: @started_at,
|
|
60
|
+
finished_at: Time.now,
|
|
61
|
+
thresholds: @config.thresholds,
|
|
62
|
+
source_provider: @source_provider,
|
|
63
|
+
authoritative: @authoritative
|
|
64
|
+
).to_stryker_schema
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def canonical_path
|
|
68
|
+
File.join(@config.reports_dir, "mutation-report.json")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def html_reporter?
|
|
72
|
+
Array(@config.reporters).map(&:to_s).include?("html")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_html(schema)
|
|
76
|
+
Reporter::Html.new(config: @config).report_schema(schema)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
class CLI
|
|
7
|
+
# Implements `henitai clean`: removes generated report artifacts listed in
|
|
8
|
+
# {CLI::REPORT_CLEANUP_PATHS} and prints a deletion summary. Mixed into
|
|
9
|
+
# {CLI}.
|
|
10
|
+
module CleanCommand
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def clean_command
|
|
14
|
+
@command_halted = false
|
|
15
|
+
options = parse_clean_options
|
|
16
|
+
return if @command_halted
|
|
17
|
+
|
|
18
|
+
config = load_config(options)
|
|
19
|
+
removed_paths = ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
|
|
20
|
+
cleanup_report_artifacts(config)
|
|
21
|
+
end
|
|
22
|
+
puts clean_summary(removed_paths)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
handle_run_error(e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cleanup_report_artifacts(config)
|
|
28
|
+
removed_dirs = cleanup_dir_paths(config).select { |path| File.directory?(path) }
|
|
29
|
+
removed_dirs.each { |path| FileUtils.rm_rf(path) }
|
|
30
|
+
removed_files = cleanup_file_paths(config).select { |path| File.file?(path) }
|
|
31
|
+
removed_files.each { |path| FileUtils.rm_f(path) }
|
|
32
|
+
removed_dirs + removed_files
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cleanup_file_paths(config)
|
|
36
|
+
REPORT_CLEANUP_PATHS.map { |relative_path| File.join(config.reports_dir, *relative_path) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def cleanup_dir_paths(config)
|
|
40
|
+
REPORT_CLEANUP_DIRS.map { |relative_path| File.join(config.reports_dir, *relative_path) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clean_summary(removed_paths)
|
|
44
|
+
return "No generated report artifacts to clean" if removed_paths.empty?
|
|
45
|
+
|
|
46
|
+
format(
|
|
47
|
+
"Removed %<count>s generated report artifact%<plural>s",
|
|
48
|
+
count: removed_paths.length,
|
|
49
|
+
plural: removed_paths.length == 1 ? "" : "s"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class CLI
|
|
5
|
+
# Shared helpers for CLI command handlers: configuration loading (applying
|
|
6
|
+
# CLI overrides on top of the config file) and uniform error handling.
|
|
7
|
+
module CommandSupport
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def load_config(options)
|
|
11
|
+
Configuration.load(
|
|
12
|
+
path: options.fetch(:config, Configuration::CONFIG_FILE),
|
|
13
|
+
overrides: configuration_overrides(options)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configuration_overrides(options)
|
|
18
|
+
deep_compact(
|
|
19
|
+
{
|
|
20
|
+
integration: options[:integration],
|
|
21
|
+
all_logs: options[:all_logs],
|
|
22
|
+
mutation: {
|
|
23
|
+
operators: options[:operators],
|
|
24
|
+
timeout: options[:timeout],
|
|
25
|
+
timeout_multiplier: options[:timeout_multiplier]
|
|
26
|
+
},
|
|
27
|
+
jobs: options[:jobs]
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def deep_compact(value)
|
|
33
|
+
case value
|
|
34
|
+
when Hash
|
|
35
|
+
value.each_with_object({}) do |(key, nested_value), result|
|
|
36
|
+
compacted = deep_compact(nested_value)
|
|
37
|
+
result[key] = compacted unless compacted.nil?
|
|
38
|
+
end
|
|
39
|
+
when Array
|
|
40
|
+
value.map { |item| deep_compact(item) }.compact
|
|
41
|
+
else
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_run_error(error)
|
|
47
|
+
warn "#{error.class}: #{error.message}"
|
|
48
|
+
exit 2
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class CLI
|
|
5
|
+
# Implements `henitai init`: writes a starter `.henitai.yml`, optionally
|
|
6
|
+
# prompting for the default RSpec integration when stdin is a TTY. Mixed
|
|
7
|
+
# into {CLI}.
|
|
8
|
+
module InitCommand
|
|
9
|
+
INIT_TEMPLATE_LINES = [
|
|
10
|
+
"# yaml-language-server: $schema=./assets/schema/henitai.schema.json",
|
|
11
|
+
"includes:",
|
|
12
|
+
" - lib",
|
|
13
|
+
"mutation:",
|
|
14
|
+
" operators: light",
|
|
15
|
+
" timeout: 10.0",
|
|
16
|
+
" max_flaky_retries: 3",
|
|
17
|
+
" sampling:",
|
|
18
|
+
" ratio: 0.05",
|
|
19
|
+
" strategy: stratified",
|
|
20
|
+
"reports_dir: reports",
|
|
21
|
+
"thresholds:",
|
|
22
|
+
" high: 80",
|
|
23
|
+
" low: 60"
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def init_command
|
|
29
|
+
path = @argv.shift || Configuration::CONFIG_FILE
|
|
30
|
+
unexpected_arguments = @argv.dup
|
|
31
|
+
warn "Unexpected arguments: #{unexpected_arguments.join(' ')}" unless unexpected_arguments.empty?
|
|
32
|
+
exit 1 unless unexpected_arguments.empty?
|
|
33
|
+
|
|
34
|
+
File.write(path, init_template)
|
|
35
|
+
puts "Created #{path}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def init_template
|
|
39
|
+
template = init_template_lines
|
|
40
|
+
template << integration_block if include_default_integration?
|
|
41
|
+
"#{template.join("\n")}\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def init_template_lines
|
|
45
|
+
INIT_TEMPLATE_LINES.dup
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def include_default_integration?
|
|
49
|
+
return true unless $stdin.tty?
|
|
50
|
+
|
|
51
|
+
print "Use the default RSpec integration? [Y/n] "
|
|
52
|
+
response = $stdin.gets&.strip&.downcase
|
|
53
|
+
response.nil? || response.empty? || !%w[n no].include?(response)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def integration_block
|
|
57
|
+
<<~YAML.chomp
|
|
58
|
+
integration:
|
|
59
|
+
name: rspec
|
|
60
|
+
YAML
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class CLI
|
|
5
|
+
# Implements `henitai operator`: lists the built-in operators with their
|
|
6
|
+
# human-readable descriptions and examples. Mixed into {CLI}.
|
|
7
|
+
module OperatorCommand
|
|
8
|
+
OPERATOR_METADATA = {
|
|
9
|
+
"ArithmeticOperator" => ["Arithmetic operators", "a + b -> a - b"],
|
|
10
|
+
"EqualityOperator" => ["Comparison operators", "a == b -> a != b"],
|
|
11
|
+
"LogicalOperator" => ["Boolean operators", "a && b -> a || b"],
|
|
12
|
+
"BooleanLiteral" => ["Boolean literals", "true -> false"],
|
|
13
|
+
"ConditionalExpression" => ["Conditional branches", "if cond then ... end"],
|
|
14
|
+
"StringLiteral" => ["String literals", '"foo" -> ""'],
|
|
15
|
+
"ReturnValue" => ["Return expressions", "return x -> return nil"],
|
|
16
|
+
"ArrayDeclaration" => ["Array literals", "[1, 2] -> []"],
|
|
17
|
+
"HashLiteral" => ["Hash literals", "{ a: 1 } -> {}"],
|
|
18
|
+
"RangeLiteral" => ["Range literals", "1..5 -> 1...5"],
|
|
19
|
+
"SafeNavigation" => ["Safe navigation", "user&.name -> user.name"],
|
|
20
|
+
"PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
|
|
21
|
+
"BlockStatement" => ["Block statements", "{ do_work } -> {}"],
|
|
22
|
+
"MethodExpression" => ["Method calls", "call_service -> nil"],
|
|
23
|
+
"AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"],
|
|
24
|
+
"MethodChainUnwrap" => ["Method chain unwrap", "a.b.c -> a.b"],
|
|
25
|
+
"RegexMutator" => ["Regex literals", "/foo+/ -> /foo*/"],
|
|
26
|
+
"UnaryOperator" => ["Unary operators", "-x -> x"],
|
|
27
|
+
"UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"],
|
|
28
|
+
"EqualityIdentityOperator" => ["Identity-method comparisons", "a == b -> a.eql?(b)"]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def operator_command
|
|
34
|
+
subcommand = @argv.shift
|
|
35
|
+
case subcommand
|
|
36
|
+
when "list" then puts operator_list_text
|
|
37
|
+
when nil, "-h", "--help" then puts operator_help_text
|
|
38
|
+
else
|
|
39
|
+
warn "Unknown operator command: #{subcommand}"
|
|
40
|
+
warn operator_help_text
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
rescue ArgumentError => e
|
|
44
|
+
warn e.message
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def operator_help_text
|
|
49
|
+
<<~HELP
|
|
50
|
+
Hen'i-tai operator commands
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
henitai operator list
|
|
54
|
+
|
|
55
|
+
Run `henitai operator list` to see all built-in operators.
|
|
56
|
+
HELP
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def operator_list_text
|
|
60
|
+
validate_operator_metadata!
|
|
61
|
+
sections = [
|
|
62
|
+
operator_list_section("Light set", Operator::LIGHT_SET),
|
|
63
|
+
operator_list_section("Full set", Operator::FULL_SET)
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
["Available operators", *sections].join("\n")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def operator_list_section(title, names)
|
|
70
|
+
rows = names.map { |name| operator_description_row(name) }
|
|
71
|
+
([title] + rows).join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def operator_description_row(name)
|
|
75
|
+
description, example = operator_metadata[name] || fallback_operator_metadata
|
|
76
|
+
|
|
77
|
+
format("- %<name>s: %<description>s (%<example>s)", name:, description:, example:)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def operator_metadata
|
|
81
|
+
OPERATOR_METADATA
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fallback_operator_metadata
|
|
85
|
+
["No metadata available", "n/a"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_operator_metadata!
|
|
89
|
+
missing = Operator::FULL_SET - operator_metadata.keys
|
|
90
|
+
return if missing.empty?
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "Missing operator metadata for: #{missing.join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "run_options"
|
|
5
|
+
|
|
6
|
+
module Henitai
|
|
7
|
+
class CLI
|
|
8
|
+
# Builds the OptionParser instances for the `run` and `clean` commands and
|
|
9
|
+
# parses argv into an options Hash. Help/version options set
|
|
10
|
+
# +@command_halted+ so the caller can skip the rest of the command.
|
|
11
|
+
# Run-only option definitions live in {RunOptions}.
|
|
12
|
+
module Options
|
|
13
|
+
include RunOptions
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def parse_run_options
|
|
18
|
+
options = {}
|
|
19
|
+
build_run_option_parser(options).parse!(@argv)
|
|
20
|
+
options
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parse_clean_options
|
|
24
|
+
options = {}
|
|
25
|
+
build_clean_option_parser(options).parse!(@argv)
|
|
26
|
+
options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_run_option_parser(options)
|
|
30
|
+
OptionParser.new do |opts|
|
|
31
|
+
opts.banner = "Usage: henitai run [options] [SUBJECT_PATTERN...]"
|
|
32
|
+
add_run_flag_options(opts, options)
|
|
33
|
+
add_help_option(opts)
|
|
34
|
+
add_version_option(opts)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_run_flag_options(opts, options)
|
|
39
|
+
add_since_option(opts, options)
|
|
40
|
+
add_integration_option(opts, options)
|
|
41
|
+
add_config_option(opts, options)
|
|
42
|
+
add_operator_option(opts, options)
|
|
43
|
+
add_timeout_multiplier_option(opts, options)
|
|
44
|
+
add_jobs_option(opts, options)
|
|
45
|
+
add_output_option(opts, options)
|
|
46
|
+
add_survivors_from_option(opts, options)
|
|
47
|
+
add_incremental_option(opts, options)
|
|
48
|
+
add_force_option(opts, options)
|
|
49
|
+
add_dry_run_option(opts, options)
|
|
50
|
+
add_fail_on_survivors_option(opts, options)
|
|
51
|
+
add_strict_exit_codes_option(opts, options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_clean_option_parser(options)
|
|
55
|
+
OptionParser.new do |opts|
|
|
56
|
+
opts.banner = "Usage: henitai clean [options]"
|
|
57
|
+
add_config_option(opts, options)
|
|
58
|
+
add_help_option(opts)
|
|
59
|
+
add_version_option(opts)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def add_config_option(opts, options)
|
|
64
|
+
opts.on("--config PATH", "Path to .henitai.yml") do |path|
|
|
65
|
+
options[:config] = path
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_help_option(opts)
|
|
70
|
+
opts.on("-h", "--help", "Show this help") do
|
|
71
|
+
puts opts
|
|
72
|
+
@command_halted = true
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_version_option(opts)
|
|
77
|
+
opts.on("-v", "--version", "Show version") do
|
|
78
|
+
puts Henitai::VERSION
|
|
79
|
+
@command_halted = true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
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(run_exit_status(result, config, options))
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
handle_run_error(e)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run_exit_status(result, config, options)
|
|
26
|
+
# A dry run tests nothing, so there is no score to gate on.
|
|
27
|
+
return 0 if options[:dry_run]
|
|
28
|
+
|
|
29
|
+
exit_status_for(
|
|
30
|
+
result,
|
|
31
|
+
config,
|
|
32
|
+
fail_on_survivors: options[:fail_on_survivors],
|
|
33
|
+
strict_exit_codes: options[:strict_exit_codes]
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def run_pipeline(options, config)
|
|
38
|
+
resolved_survivors_from = resolve_survivors_from(options[:survivors_from])
|
|
39
|
+
runner = Runner.new(
|
|
40
|
+
config:,
|
|
41
|
+
subjects: subjects_from_argv,
|
|
42
|
+
since: options[:since],
|
|
43
|
+
survivors_from: resolved_survivors_from,
|
|
44
|
+
mode: {
|
|
45
|
+
dry_run: options.fetch(:dry_run, false),
|
|
46
|
+
incremental: options.fetch(:incremental, false) && !options.fetch(:force, false)
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
runner.run
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def subjects_from_argv
|
|
53
|
+
@argv.empty? ? nil : @argv.map { |expr| Subject.parse(expr) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_survivors_from(survivors_from)
|
|
57
|
+
return nil if survivors_from.nil?
|
|
58
|
+
|
|
59
|
+
# Fast path: if the path already points into reports/sessions/<session_id>/,
|
|
60
|
+
# keep it as-is so activation-recipes.json can be found by the runner.
|
|
61
|
+
report_dir = File.dirname(survivors_from)
|
|
62
|
+
parent_dir = File.dirname(report_dir)
|
|
63
|
+
# Heuristic: treat any path under a directory named "sessions" as already
|
|
64
|
+
# being a snapshot path; this keeps activation-recipes lookup correct.
|
|
65
|
+
return survivors_from if File.basename(parent_dir) == "sessions"
|
|
66
|
+
|
|
67
|
+
session_id = session_id_from_report(survivors_from)
|
|
68
|
+
return survivors_from if session_id.nil?
|
|
69
|
+
|
|
70
|
+
snapshot_path = survivors_snapshot_path(report_dir, session_id)
|
|
71
|
+
recipe_path = File.join(report_dir, "sessions", session_id, "activation-recipes.json")
|
|
72
|
+
return snapshot_path if File.exist?(recipe_path) && File.exist?(snapshot_path)
|
|
73
|
+
|
|
74
|
+
# If the recipes exist but the snapshot doesn't (e.g. partial cleanup),
|
|
75
|
+
# fall back to the path the user provided so the error message points
|
|
76
|
+
# at what they actually passed.
|
|
77
|
+
|
|
78
|
+
survivors_from
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
warn_survivors_from_resolution_error(survivors_from, e)
|
|
81
|
+
survivors_from
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def survivors_snapshot_path(report_dir, session_id)
|
|
85
|
+
File.join(report_dir, "sessions", session_id, "mutation-report.json")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def session_id_from_report(path)
|
|
89
|
+
parsed = JSON.parse(File.read(path))
|
|
90
|
+
parsed["sessionId"]
|
|
91
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def exit_status_for(result, config, fail_on_survivors: false, strict_exit_codes: false)
|
|
96
|
+
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
97
|
+
warn "henitai: partial rerun - mutation score threshold not evaluated"
|
|
98
|
+
return result.survived.positive? ? 1 : 0 if fail_on_survivors
|
|
99
|
+
|
|
100
|
+
return 0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
strict_status = strict_exit_codes ? strict_status_for(result) : nil
|
|
104
|
+
strict_status || threshold_status_for(result, config)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Expanded, opt-in exit codes (precedence: timeout > runtime/compile
|
|
108
|
+
# error > threshold miss). The timeout code is informational and
|
|
109
|
+
# independent of coverage_criteria.timeout: a run can pass its threshold
|
|
110
|
+
# and still exit 3.
|
|
111
|
+
def strict_status_for(result)
|
|
112
|
+
statuses = result.mutants.map(&:status)
|
|
113
|
+
return 3 if statuses.include?(:timeout)
|
|
114
|
+
return 4 if statuses.include?(:runtime_error) || statuses.include?(:compile_error)
|
|
115
|
+
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def threshold_status_for(result, config)
|
|
120
|
+
score = result.mutation_score
|
|
121
|
+
# No valid mutants to evaluate (e.g. an incremental run with no changed
|
|
122
|
+
# code) cannot fail a threshold — treat it as success.
|
|
123
|
+
return 0 if score.nil?
|
|
124
|
+
|
|
125
|
+
score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def warn_survivors_from_resolution_error(survivors_from, error)
|
|
129
|
+
warn(
|
|
130
|
+
"henitai: warning: could not resolve survivors-from " \
|
|
131
|
+
"#{survivors_from}: #{error.class}: #{error.message}"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class CLI
|
|
5
|
+
# Option definitions specific to `henitai run`, mixed into {Options} to
|
|
6
|
+
# keep each module focused: {Options} owns parser construction and the
|
|
7
|
+
# shared options, this module owns the run-only flags.
|
|
8
|
+
module RunOptions
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def add_since_option(opts, options)
|
|
12
|
+
opts.on("--since GIT_REF", "Only mutate subjects changed since GIT_REF") do |ref|
|
|
13
|
+
options[:since] = ref
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add_integration_option(opts, options)
|
|
18
|
+
opts.on("--use INTEGRATION", "Test framework integration (rspec)") do |name|
|
|
19
|
+
options[:integration] = name
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add_operator_option(opts, options)
|
|
24
|
+
opts.on("--operators SET", "Operator set: light | full") do |set|
|
|
25
|
+
options[:operators] = set
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_timeout_multiplier_option(opts, options)
|
|
30
|
+
opts.on(
|
|
31
|
+
"--timeout-multiplier N", Float,
|
|
32
|
+
"Multiplier applied to the measured per-mutant test baseline " \
|
|
33
|
+
"when mutation.timeout is unset (default: 3.0)"
|
|
34
|
+
) do |n|
|
|
35
|
+
options[:timeout_multiplier] = n
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def add_jobs_option(opts, options)
|
|
40
|
+
opts.on("--jobs N", Integer, "Number of parallel workers (default: 1)") do |n|
|
|
41
|
+
options[:jobs] = n
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_output_option(opts, options)
|
|
46
|
+
opts.on("--all-logs", "--verbose", "Print all captured child logs") do
|
|
47
|
+
options[:all_logs] = true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def add_survivors_from_option(opts, options)
|
|
52
|
+
opts.on(
|
|
53
|
+
"--survivors-from PATH",
|
|
54
|
+
"Re-run only survivors from a prior report " \
|
|
55
|
+
"(partial rerun; threshold checks are skipped; dirty worktrees are included)"
|
|
56
|
+
) do |path|
|
|
57
|
+
options[:survivors_from] = path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_dry_run_option(opts, options)
|
|
62
|
+
opts.on(
|
|
63
|
+
"--dry-run",
|
|
64
|
+
"List the post-filter mutant set without executing mutants (always exits 0)"
|
|
65
|
+
) do
|
|
66
|
+
options[:dry_run] = true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_incremental_option(opts, options)
|
|
71
|
+
opts.on(
|
|
72
|
+
"--incremental",
|
|
73
|
+
"Reuse still-valid Killed verdicts from the history store instead of re-executing them"
|
|
74
|
+
) do
|
|
75
|
+
options[:incremental] = true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_force_option(opts, options)
|
|
80
|
+
opts.on(
|
|
81
|
+
"--force",
|
|
82
|
+
"Bypass verdict reuse and execute every mutant (only meaningful with --incremental)"
|
|
83
|
+
) do
|
|
84
|
+
options[:force] = true
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_fail_on_survivors_option(opts, options)
|
|
89
|
+
opts.on(
|
|
90
|
+
"--fail-on-survivors",
|
|
91
|
+
"Exit 1 for partial reruns when any survivors remain (otherwise exits 0)"
|
|
92
|
+
) do
|
|
93
|
+
options[:fail_on_survivors] = true
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def add_strict_exit_codes_option(opts, options)
|
|
98
|
+
opts.on(
|
|
99
|
+
"--strict-exit-codes",
|
|
100
|
+
"Expanded exit codes: 0 threshold met, 1 threshold miss, " \
|
|
101
|
+
"2 framework error, 3 timeouts present, 4 runtime/compile errors present"
|
|
102
|
+
) do
|
|
103
|
+
options[:strict_exit_codes] = true
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|