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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +138 -5
  4. data/assets/schema/henitai.schema.json +41 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +54 -0
  10. data/lib/henitai/cli/command_support.rb +52 -0
  11. data/lib/henitai/cli/init_command.rb +64 -0
  12. data/lib/henitai/cli/operator_command.rb +96 -0
  13. data/lib/henitai/cli/options.rb +84 -0
  14. data/lib/henitai/cli/run_command.rb +136 -0
  15. data/lib/henitai/cli/run_options.rb +108 -0
  16. data/lib/henitai/cli.rb +27 -407
  17. data/lib/henitai/composite_progress_reporter.rb +42 -0
  18. data/lib/henitai/configuration.rb +42 -9
  19. data/lib/henitai/configuration_validator/rules.rb +161 -0
  20. data/lib/henitai/configuration_validator/scalars.rb +169 -0
  21. data/lib/henitai/configuration_validator.rb +20 -240
  22. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  23. data/lib/henitai/coverage_formatter.rb +5 -1
  24. data/lib/henitai/coverage_report_reader.rb +28 -5
  25. data/lib/henitai/eager_load.rb +36 -5
  26. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  27. data/lib/henitai/execution_engine.rb +115 -53
  28. data/lib/henitai/generated_artifacts.rb +58 -0
  29. data/lib/henitai/incremental_filter.rb +100 -0
  30. data/lib/henitai/integration/base.rb +171 -0
  31. data/lib/henitai/integration/child_debug_support.rb +119 -0
  32. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  33. data/lib/henitai/integration/coverage_suppression.rb +52 -0
  34. data/lib/henitai/integration/minitest.rb +101 -0
  35. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  36. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  37. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  38. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  39. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  40. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  41. data/lib/henitai/integration/rspec_test_selection.rb +138 -0
  42. data/lib/henitai/integration/scenario_log_support.rb +160 -0
  43. data/lib/henitai/integration.rb +22 -846
  44. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  45. data/lib/henitai/mutant/activator.rb +15 -79
  46. data/lib/henitai/mutant/parameter_source.rb +98 -0
  47. data/lib/henitai/mutant.rb +14 -6
  48. data/lib/henitai/mutant_history_store/sql.rb +90 -0
  49. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  50. data/lib/henitai/mutant_history_store.rb +67 -81
  51. data/lib/henitai/mutant_identity.rb +33 -2
  52. data/lib/henitai/mutation_skip_directives.rb +227 -0
  53. data/lib/henitai/operator.rb +3 -1
  54. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  55. data/lib/henitai/operators/equality_operator.rb +8 -3
  56. data/lib/henitai/operators.rb +1 -0
  57. data/lib/henitai/per_test_coverage.rb +66 -0
  58. data/lib/henitai/per_test_coverage_collector.rb +23 -6
  59. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  60. data/lib/henitai/process_worker_runner.rb +48 -334
  61. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  62. data/lib/henitai/reporter.rb +258 -125
  63. data/lib/henitai/reports_directory_lock.rb +76 -0
  64. data/lib/henitai/result.rb +88 -23
  65. data/lib/henitai/runner.rb +137 -218
  66. data/lib/henitai/scenario_execution_result.rb +12 -0
  67. data/lib/henitai/slot_scheduler/draining.rb +146 -0
  68. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  69. data/lib/henitai/slot_scheduler.rb +272 -0
  70. data/lib/henitai/static_filter.rb +16 -6
  71. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  72. data/lib/henitai/survivor_test_filter.rb +1 -1
  73. data/lib/henitai/test_prioritizer.rb +28 -2
  74. data/lib/henitai/timeout_calibrator.rb +44 -0
  75. data/lib/henitai/unparse_helper.rb +5 -2
  76. data/lib/henitai/verdict_fingerprint.rb +155 -0
  77. data/lib/henitai/version.rb +1 -1
  78. data/lib/henitai.rb +16 -1
  79. data/sig/configuration_validator.rbs +46 -22
  80. data/sig/henitai.rbs +386 -96
  81. metadata +45 -3
  82. 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