henitai 0.1.10 → 0.2.1

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