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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b2062372801be4391691c6040c2221147a5b14ec346726466d1abe8e916a7670
4
- data.tar.gz: b51bd86b3f98751b4cc28bf0e443c56158fe6051f754e2ed954dc74940824f29
3
+ metadata.gz: cda194514bf677d142e24c97c1589714041d2c88c4d2b33ffaf642e79c7ebe5d
4
+ data.tar.gz: 8505d5d6add5b850c5accb4904f43351e129b9261699c58051c35faa1a4b3758
5
5
  SHA512:
6
- metadata.gz: d128d68a96d10a3aeccdd88654cce9ef2ad53d527f196c1a29c1f02416f7d1b045989766872eef92b1832f8a283fdc726fc3b994098b464b22d91897a2993450
7
- data.tar.gz: 9b13d2a228c4fd266b49db1d102ca796cd71c534458a341565fd2c1788088bd33f22c1c32bd5c9eeaee4f72d6c3658ea431a78eb5631960299736aea9575b627
6
+ metadata.gz: b17e21c51f0d4aac03154d8b782d4e361ea8fa792c03a984b9d5b94deccd2411ffb3fe8a7e6fe096b76b33f2bc211a813618c059642b4dec3e54b8fab4d01d8a
7
+ data.tar.gz: 109527f73b390086f03b5249dd90e97bf57ad3e34551f94d1f80b8f1e5968699e8d6cefe394ebf87bc10465b2a759de737d5f54257807e654a92cd040fa73f59
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-06-25
11
+
12
+ ### Fixed
13
+ - `henitai run` now exits 0 when a run evaluates no valid mutants, instead of
14
+ failing a CI gate on a vacuous result
15
+ - Flaky-retry counts are now recorded on the parallel execution path (they were
16
+ always reported as 0 regardless of actual retries)
17
+
18
+ ### Changed
19
+ - `Result` and `Reporter::Json` take their IO as an injected dependency,
20
+ restoring the domain/infrastructure boundary (no public API change)
21
+ - Decomposed the monolithic `integration.rb` into single-responsibility files
22
+ and reparented `Integration::Minitest` off `Rspec` onto a shared
23
+ `MutantRunSupport` mixin; restored class-size discipline across the codebase
24
+ - Narrowed broad rescues in `safe_unparse` and per-test coverage snapshotting
25
+
26
+ ### Internal
27
+ - De-mocked runner specs, added `process_wakeup` and helper coverage, and
28
+ removed sleep- and chdir-based flakiness from the suite
29
+ - Enabled full-operator dogfooding configuration and isolated the
30
+ `minitest_simplecov` spec
31
+ - Reconciled README and consolidated plan-tree documentation
32
+
10
33
  ## [0.2.0] - 2026-04-30
11
34
 
12
35
  ### Added
@@ -279,7 +302,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
279
302
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
280
303
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
281
304
 
282
- [Unreleased]: https://github.com/martinotten/henitai/compare/v0.1.10...HEAD
305
+ [Unreleased]: https://github.com/martinotten/henitai/compare/v0.2.1...HEAD
306
+ [0.2.1]: https://github.com/martinotten/henitai/compare/v0.2.0...v0.2.1
307
+ [0.2.0]: https://github.com/martinotten/henitai/compare/v0.1.10...v0.2.0
283
308
  [0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
284
309
  [0.1.9]: https://github.com/martinotten/henitai/compare/v0.1.8...v0.1.9
285
310
  [0.1.8]: https://github.com/martinotten/henitai/compare/v0.1.7...v0.1.8
data/README.md CHANGED
@@ -43,7 +43,7 @@ Or install globally:
43
43
  gem install henitai
44
44
  ```
45
45
 
46
- **Requires Ruby 4.0.2+**
46
+ **Requires Ruby 4.0.0+**
47
47
 
48
48
  ## Quick start
49
49
 
@@ -72,6 +72,9 @@ integration:
72
72
  includes:
73
73
  - lib
74
74
 
75
+ excludes:
76
+ - lib/henitai/eager_load.rb # standalone entry points with no in-process coverage
77
+
75
78
  mutation:
76
79
  operators: light # light | full
77
80
  timeout: 10.0
@@ -139,13 +142,17 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
139
142
  **Full** — adds lower-signal operators:
140
143
 
141
144
  - `ArrayDeclaration`, `HashLiteral`, `RangeLiteral`
145
+ - `MethodChainUnwrap` — remove a link from a method chain
146
+ - `RegexMutator` — mutate regexp quantifiers, anchors, char-class negation
142
147
  - `SafeNavigation` — `&.` → `.`
143
148
  - `PatternMatch` — case/in arm removal
144
149
  - `BlockStatement` — remove blocks
145
150
  - `MethodExpression` — remove calls
146
151
  - `AssignmentExpression` — mutate compound assignment
152
+ - `UnaryOperator` — remove unary `-` and `~`
153
+ - `UpdateOperator` — swap compound assignments (`+=`↔`-=`, `*=`↔`/=`, `||=`↔`&&=`)
147
154
 
148
- ## Stryker Dashboard integration (untested)
155
+ ## Stryker Dashboard integration
149
156
 
150
157
  ```yaml
151
158
  # .henitai.yml
@@ -160,7 +167,12 @@ dashboard:
160
167
  base_url: "https://dashboard.stryker-mutator.io"
161
168
  ```
162
169
 
163
- Set `STRYKER_DASHBOARD_API_KEY` in your CI environment to publish reports.
170
+ Set `STRYKER_DASHBOARD_API_KEY` in your CI environment to publish reports. When
171
+ the key, project, and version are present, the `dashboard` reporter uploads the
172
+ Stryker-schema report to the dashboard REST API (default
173
+ `https://dashboard.stryker-mutator.io`); otherwise it is skipped silently. The
174
+ project defaults to the git remote and the version to the current branch or CI
175
+ ref (`GITHUB_REF_NAME`/`GITHUB_REF`/`GITHUB_SHA`).
164
176
 
165
177
  JSON reports are written to `reports/mutation-report.json` by default. Set
166
178
  `reports_dir` to change the output directory.
@@ -27,6 +27,12 @@
27
27
  "type": "string"
28
28
  }
29
29
  },
30
+ "excludes": {
31
+ "type": "array",
32
+ "items": {
33
+ "type": "string"
34
+ }
35
+ },
30
36
  "mutation": {
31
37
  "type": "object",
32
38
  "additionalProperties": false,
@@ -0,0 +1,48 @@
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 = cleanup_report_artifacts(config)
20
+ puts clean_summary(removed_paths)
21
+ rescue StandardError => e
22
+ handle_run_error(e)
23
+ end
24
+
25
+ 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
29
+ end
30
+
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
+ end
36
+
37
+ def clean_summary(removed_paths)
38
+ return "No generated report artifacts to clean" if removed_paths.empty?
39
+
40
+ format(
41
+ "Removed %<count>s generated report artifact%<plural>s",
42
+ count: removed_paths.length,
43
+ plural: removed_paths.length == 1 ? "" : "s"
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
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
+ },
26
+ jobs: options[:jobs]
27
+ }
28
+ )
29
+ end
30
+
31
+ def deep_compact(value)
32
+ case value
33
+ when Hash
34
+ value.each_with_object({}) do |(key, nested_value), result|
35
+ compacted = deep_compact(nested_value)
36
+ result[key] = compacted unless compacted.nil?
37
+ end
38
+ when Array
39
+ value.map { |item| deep_compact(item) }.compact
40
+ else
41
+ value
42
+ end
43
+ end
44
+
45
+ def handle_run_error(error)
46
+ warn "#{error.class}: #{error.message}"
47
+ exit 2
48
+ end
49
+ end
50
+ end
51
+ 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,95 @@
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
+ }.freeze
29
+
30
+ private
31
+
32
+ def operator_command
33
+ subcommand = @argv.shift
34
+ case subcommand
35
+ when "list" then puts operator_list_text
36
+ when nil, "-h", "--help" then puts operator_help_text
37
+ else
38
+ warn "Unknown operator command: #{subcommand}"
39
+ warn operator_help_text
40
+ exit 1
41
+ end
42
+ rescue ArgumentError => e
43
+ warn e.message
44
+ exit 1
45
+ end
46
+
47
+ def operator_help_text
48
+ <<~HELP
49
+ Hen'i-tai operator commands
50
+
51
+ Usage:
52
+ henitai operator list
53
+
54
+ Run `henitai operator list` to see all built-in operators.
55
+ HELP
56
+ end
57
+
58
+ def operator_list_text
59
+ validate_operator_metadata!
60
+ sections = [
61
+ operator_list_section("Light set", Operator::LIGHT_SET),
62
+ operator_list_section("Full set", Operator::FULL_SET)
63
+ ]
64
+
65
+ ["Available operators", *sections].join("\n")
66
+ end
67
+
68
+ def operator_list_section(title, names)
69
+ rows = names.map { |name| operator_description_row(name) }
70
+ ([title] + rows).join("\n")
71
+ end
72
+
73
+ def operator_description_row(name)
74
+ description, example = operator_metadata[name] || fallback_operator_metadata
75
+
76
+ format("- %<name>s: %<description>s (%<example>s)", name:, description:, example:)
77
+ end
78
+
79
+ def operator_metadata
80
+ OPERATOR_METADATA
81
+ end
82
+
83
+ def fallback_operator_metadata
84
+ ["No metadata available", "n/a"]
85
+ end
86
+
87
+ def validate_operator_metadata!
88
+ missing = Operator::FULL_SET - operator_metadata.keys
89
+ return if missing.empty?
90
+
91
+ raise ArgumentError, "Missing operator metadata for: #{missing.join(', ')}"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Henitai
6
+ class CLI
7
+ # Builds the OptionParser instances for the `run` and `clean` commands and
8
+ # parses argv into an options Hash. Help/version options set
9
+ # +@command_halted+ so the caller can skip the rest of the command.
10
+ module Options
11
+ private
12
+
13
+ def parse_run_options
14
+ options = {}
15
+ build_run_option_parser(options).parse!(@argv)
16
+ options
17
+ end
18
+
19
+ def parse_clean_options
20
+ options = {}
21
+ build_clean_option_parser(options).parse!(@argv)
22
+ options
23
+ end
24
+
25
+ def build_run_option_parser(options)
26
+ OptionParser.new do |opts|
27
+ 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)
36
+ add_help_option(opts)
37
+ add_version_option(opts)
38
+ end
39
+ end
40
+
41
+ def build_clean_option_parser(options)
42
+ OptionParser.new do |opts|
43
+ opts.banner = "Usage: henitai clean [options]"
44
+ add_config_option(opts, options)
45
+ add_help_option(opts)
46
+ add_version_option(opts)
47
+ end
48
+ end
49
+
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
+ def add_config_option(opts, options)
63
+ opts.on("--config PATH", "Path to .henitai.yml") do |path|
64
+ options[:config] = path
65
+ end
66
+ end
67
+
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
+ def add_help_option(opts)
106
+ opts.on("-h", "--help", "Show this help") do
107
+ puts opts
108
+ @command_halted = true
109
+ end
110
+ end
111
+
112
+ def add_version_option(opts)
113
+ opts.on("-v", "--version", "Show version") do
114
+ puts Henitai::VERSION
115
+ @command_halted = true
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -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