henitai 0.2.1 → 0.3.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -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 +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -16,22 +16,28 @@ module Henitai
16
16
  return if @command_halted
17
17
 
18
18
  config = load_config(options)
19
- removed_paths = cleanup_report_artifacts(config)
19
+ removed_paths = ReportsDirectoryLock.new(reports_dir: config.reports_dir).synchronize do
20
+ cleanup_report_artifacts(config)
21
+ end
20
22
  puts clean_summary(removed_paths)
21
23
  rescue StandardError => e
22
24
  handle_run_error(e)
23
25
  end
24
26
 
25
27
  def cleanup_report_artifacts(config)
26
- removed_paths = report_cleanup_paths(config).select { |path| File.exist?(path) }
27
- removed_paths.each { |path| FileUtils.rm_f(path) }
28
- removed_paths
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
29
33
  end
30
34
 
31
- def report_cleanup_paths(config)
32
- REPORT_CLEANUP_PATHS.map do |relative_path|
33
- File.join(config.reports_dir, *relative_path)
34
- end
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) }
35
41
  end
36
42
 
37
43
  def clean_summary(removed_paths)
@@ -21,7 +21,8 @@ module Henitai
21
21
  all_logs: options[:all_logs],
22
22
  mutation: {
23
23
  operators: options[:operators],
24
- timeout: options[:timeout]
24
+ timeout: options[:timeout],
25
+ timeout_multiplier: options[:timeout_multiplier]
25
26
  },
26
27
  jobs: options[:jobs]
27
28
  }
@@ -24,7 +24,8 @@ module Henitai
24
24
  "MethodChainUnwrap" => ["Method chain unwrap", "a.b.c -> a.b"],
25
25
  "RegexMutator" => ["Regex literals", "/foo+/ -> /foo*/"],
26
26
  "UnaryOperator" => ["Unary operators", "-x -> x"],
27
- "UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"]
27
+ "UpdateOperator" => ["Compound assignment", "x += 1 -> x -= 1"],
28
+ "EqualityIdentityOperator" => ["Identity-method comparisons", "a == b -> a.eql?(b)"]
28
29
  }.freeze
29
30
 
30
31
  private
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require_relative "run_options"
4
5
 
5
6
  module Henitai
6
7
  class CLI
7
8
  # Builds the OptionParser instances for the `run` and `clean` commands and
8
9
  # parses argv into an options Hash. Help/version options set
9
10
  # +@command_halted+ so the caller can skip the rest of the command.
11
+ # Run-only option definitions live in {RunOptions}.
10
12
  module Options
13
+ include RunOptions
14
+
11
15
  private
12
16
 
13
17
  def parse_run_options
@@ -25,19 +29,28 @@ module Henitai
25
29
  def build_run_option_parser(options)
26
30
  OptionParser.new do |opts|
27
31
  opts.banner = "Usage: henitai run [options] [SUBJECT_PATTERN...]"
28
- add_since_option(opts, options)
29
- add_integration_option(opts, options)
30
- add_config_option(opts, options)
31
- add_operator_option(opts, options)
32
- add_jobs_option(opts, options)
33
- add_output_option(opts, options)
34
- add_survivors_from_option(opts, options)
35
- add_fail_on_survivors_option(opts, options)
32
+ add_run_flag_options(opts, options)
36
33
  add_help_option(opts)
37
34
  add_version_option(opts)
38
35
  end
39
36
  end
40
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
+
41
54
  def build_clean_option_parser(options)
42
55
  OptionParser.new do |opts|
43
56
  opts.banner = "Usage: henitai clean [options]"
@@ -47,61 +60,12 @@ module Henitai
47
60
  end
48
61
  end
49
62
 
50
- def add_since_option(opts, options)
51
- opts.on("--since GIT_REF", "Only mutate subjects changed since GIT_REF") do |ref|
52
- options[:since] = ref
53
- end
54
- end
55
-
56
- def add_integration_option(opts, options)
57
- opts.on("--use INTEGRATION", "Test framework integration (rspec)") do |name|
58
- options[:integration] = name
59
- end
60
- end
61
-
62
63
  def add_config_option(opts, options)
63
64
  opts.on("--config PATH", "Path to .henitai.yml") do |path|
64
65
  options[:config] = path
65
66
  end
66
67
  end
67
68
 
68
- def add_operator_option(opts, options)
69
- opts.on("--operators SET", "Operator set: light | full") do |set|
70
- options[:operators] = set
71
- end
72
- end
73
-
74
- def add_jobs_option(opts, options)
75
- opts.on("--jobs N", Integer, "Number of parallel workers (default: 1)") do |n|
76
- options[:jobs] = n
77
- end
78
- end
79
-
80
- def add_output_option(opts, options)
81
- opts.on("--all-logs", "--verbose", "Print all captured child logs") do
82
- options[:all_logs] = true
83
- end
84
- end
85
-
86
- def add_survivors_from_option(opts, options)
87
- opts.on(
88
- "--survivors-from PATH",
89
- "Re-run only survivors from a prior report " \
90
- "(partial rerun; threshold checks are skipped; dirty worktrees are included)"
91
- ) do |path|
92
- options[:survivors_from] = path
93
- end
94
- end
95
-
96
- def add_fail_on_survivors_option(opts, options)
97
- opts.on(
98
- "--fail-on-survivors",
99
- "Exit 1 for partial reruns when any survivors remain (otherwise exits 0)"
100
- ) do
101
- options[:fail_on_survivors] = true
102
- end
103
- end
104
-
105
69
  def add_help_option(opts)
106
70
  opts.on("-h", "--help", "Show this help") do
107
71
  puts opts
@@ -17,18 +17,34 @@ module Henitai
17
17
 
18
18
  config = load_config(options)
19
19
  result = run_pipeline(options, config)
20
- exit(exit_status_for(result, config, fail_on_survivors: options[:fail_on_survivors]))
20
+ exit(run_exit_status(result, config, options))
21
21
  rescue StandardError => e
22
22
  handle_run_error(e)
23
23
  end
24
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
+
25
37
  def run_pipeline(options, config)
26
38
  resolved_survivors_from = resolve_survivors_from(options[:survivors_from])
27
39
  runner = Runner.new(
28
40
  config:,
29
41
  subjects: subjects_from_argv,
30
42
  since: options[:since],
31
- survivors_from: resolved_survivors_from
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
+ }
32
48
  )
33
49
  runner.run
34
50
  end
@@ -76,7 +92,7 @@ module Henitai
76
92
  nil
77
93
  end
78
94
 
79
- def exit_status_for(result, config, fail_on_survivors: false)
95
+ def exit_status_for(result, config, fail_on_survivors: false, strict_exit_codes: false)
80
96
  if result.respond_to?(:partial_rerun?) && result.partial_rerun?
81
97
  warn "henitai: partial rerun - mutation score threshold not evaluated"
82
98
  return result.survived.positive? ? 1 : 0 if fail_on_survivors
@@ -84,6 +100,23 @@ module Henitai
84
100
  return 0
85
101
  end
86
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)
87
120
  score = result.mutation_score
88
121
  # No valid mutants to evaluate (e.g. an incremental run with no changed
89
122
  # code) cannot fail a threshold — treat it as success.
@@ -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
data/lib/henitai/cli.rb CHANGED
@@ -34,12 +34,20 @@ module Henitai
34
34
  include OperatorCommand
35
35
 
36
36
  REPORT_CLEANUP_PATHS = [
37
- %w[mutation-logs baseline.log],
38
- %w[mutation-logs baseline.stdout.log],
39
- %w[mutation-logs baseline.stderr.log],
40
37
  %w[coverage .resultset.json],
41
38
  %w[coverage .last_run.json],
42
- ["henitai_per_test.json"]
39
+ ["henitai_per_test.json"],
40
+ [CoverageBootstrapper::DEPENDENCY_MANIFEST_FILE]
41
+ ].freeze
42
+
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]
43
51
  ].freeze
44
52
 
45
53
  def self.start(argv)
@@ -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 = 10.0
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, :excludes, :operators, :timeout,
27
- :ignore_patterns, :sampling, :jobs,
28
- :max_flaky_retries, :coverage_criteria, :thresholds,
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,6 +85,7 @@ 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
 
@@ -73,6 +93,7 @@ module Henitai
73
93
  @integration = resolve_integration_default(raw[:integration])
74
94
  @includes = raw[:includes] || ["lib"]
75
95
  @excludes = raw[:excludes] || []
96
+ @test_excludes = raw[:test_excludes] || []
76
97
  @jobs = raw.fetch(:jobs, DEFAULT_JOBS)
77
98
  @reporters = raw[:reporters] || ["terminal"]
78
99
  @reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
@@ -84,14 +105,25 @@ module Henitai
84
105
  mutation = raw[:mutation] || {}
85
106
 
86
107
  @operators = (mutation[:operators] || DEFAULT_OPERATORS).to_sym
108
+ @timeout_configured = !mutation[:timeout].nil?
87
109
  @timeout = mutation[:timeout] || DEFAULT_TIMEOUT
110
+ @timeout_multiplier = mutation[:timeout_multiplier] || DEFAULT_TIMEOUT_MULTIPLIER
88
111
  @ignore_patterns = mutation[:ignore_patterns] || []
89
- @max_flaky_retries = if mutation.key?(:max_flaky_retries)
90
- mutation[:max_flaky_retries]
91
- else
92
- DEFAULT_MAX_FLAKY_RETRIES
93
- end
94
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
95
127
  end
96
128
 
97
129
  def apply_analysis_defaults(raw)
@@ -34,6 +34,10 @@ module Henitai
34
34
  Scalars.validate_string_array(raw[:excludes], "excludes")
35
35
  end
36
36
 
37
+ def validate_test_excludes(raw)
38
+ Scalars.validate_string_array(raw[:test_excludes], "test_excludes")
39
+ end
40
+
37
41
  def validate_jobs(raw)
38
42
  value = raw[:jobs]
39
43
  return if value.nil?
@@ -50,6 +54,17 @@ module Henitai
50
54
  Scalars.validate_optional_string(raw[:reports_dir], "reports_dir")
51
55
  end
52
56
 
57
+ def validate_reports(raw)
58
+ value = raw[:reports]
59
+ return if value.nil?
60
+
61
+ ensure_hash!(value, "reports")
62
+ warn_unknown_keys(value, VALID_REPORTS_KEYS, "reports")
63
+ Scalars.validate_boolean(value[:checkpoint], "reports.checkpoint") unless value[:checkpoint].nil?
64
+ Scalars.validate_checkpoint_every(value[:checkpoint_every])
65
+ Scalars.validate_checkpoint_interval(value[:checkpoint_interval])
66
+ end
67
+
53
68
  def validate_all_logs(raw)
54
69
  value = raw[:all_logs]
55
70
  return if value.nil?
@@ -81,7 +96,10 @@ module Henitai
81
96
 
82
97
  def validate_mutation_limits(value)
83
98
  Scalars.validate_timeout(value[:timeout])
99
+ Scalars.validate_timeout_multiplier(value[:timeout_multiplier])
84
100
  Scalars.validate_max_flaky_retries(value[:max_flaky_retries])
101
+ Scalars.validate_max_log_bytes(value[:max_log_bytes])
102
+ Scalars.validate_max_timeout(value[:max_timeout])
85
103
  end
86
104
 
87
105
  def validate_mutation_filters(value)
@@ -30,6 +30,16 @@ module Henitai
30
30
  )
31
31
  end
32
32
 
33
+ def validate_timeout_multiplier(value)
34
+ return if value.nil?
35
+ return if value.is_a?(Numeric) && value.positive?
36
+
37
+ Rules.configuration_error(
38
+ "Invalid configuration value for mutation.timeout_multiplier: " \
39
+ "expected positive Numeric, got #{value.inspect}"
40
+ )
41
+ end
42
+
33
43
  def validate_threshold(value, path)
34
44
  return if value.is_a?(Integer) && value.between?(0, 100)
35
45
 
@@ -83,6 +93,42 @@ module Henitai
83
93
  )
84
94
  end
85
95
 
96
+ def validate_max_log_bytes(value)
97
+ return if value.nil?
98
+ return if value.is_a?(Integer) && value.positive?
99
+
100
+ Rules.configuration_error(
101
+ "Invalid configuration value for mutation.max_log_bytes: expected Integer > 0, got #{value.inspect}"
102
+ )
103
+ end
104
+
105
+ def validate_max_timeout(value)
106
+ return if value.nil?
107
+ return if value.is_a?(Numeric) && value.positive?
108
+
109
+ Rules.configuration_error(
110
+ "Invalid configuration value for mutation.max_timeout: expected positive Numeric, got #{value.inspect}"
111
+ )
112
+ end
113
+
114
+ def validate_checkpoint_every(value)
115
+ return if value.nil?
116
+ return if value.is_a?(Integer) && value.positive?
117
+
118
+ Rules.configuration_error(
119
+ "Invalid configuration value for reports.checkpoint_every: expected Integer > 0, got #{value.inspect}"
120
+ )
121
+ end
122
+
123
+ def validate_checkpoint_interval(value)
124
+ return if value.nil?
125
+ return if value.is_a?(Numeric) && value.positive?
126
+
127
+ Rules.configuration_error(
128
+ "Invalid configuration value for reports.checkpoint_interval: expected positive Numeric, got #{value.inspect}"
129
+ )
130
+ end
131
+
86
132
  def validate_sampling_ratio(value)
87
133
  return if value.nil?
88
134
  return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
@@ -13,16 +13,21 @@ module Henitai
13
13
  integration
14
14
  includes
15
15
  excludes
16
+ test_excludes
16
17
  mutation
17
18
  coverage_criteria
18
19
  thresholds
19
20
  reporters
20
21
  reports_dir
22
+ reports
21
23
  all_logs
22
24
  dashboard
23
25
  jobs
24
26
  ].freeze
25
- VALID_MUTATION_KEYS = %i[operators timeout ignore_patterns max_flaky_retries sampling].freeze
27
+ VALID_MUTATION_KEYS = %i[
28
+ operators timeout timeout_multiplier ignore_patterns max_flaky_retries max_log_bytes max_timeout sampling
29
+ ].freeze
30
+ VALID_REPORTS_KEYS = %i[checkpoint checkpoint_every checkpoint_interval].freeze
26
31
  VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
27
32
  VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
28
33
  VALID_THRESHOLDS_KEYS = %i[high low].freeze
@@ -34,9 +39,11 @@ module Henitai
34
39
  validate_integration
35
40
  validate_includes
36
41
  validate_excludes
42
+ validate_test_excludes
37
43
  validate_jobs
38
44
  validate_reporters
39
45
  validate_reports_dir
46
+ validate_reports
40
47
  validate_all_logs
41
48
  validate_dashboard
42
49
  validate_mutation