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