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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -1
- data/README.md +15 -3
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +16 -404
- data/lib/henitai/configuration.rb +2 -1
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +4 -3
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/mutant/activator.rb +1 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +1 -0
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +5 -69
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter.rb +20 -8
- data/lib/henitai/result.rb +17 -15
- data/lib/henitai/runner.rb +59 -182
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +2 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +158 -73
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cda194514bf677d142e24c97c1589714041d2c88c4d2b33ffaf642e79c7ebe5d
|
|
4
|
+
data.tar.gz: 8505d5d6add5b850c5accb4904f43351e129b9261699c58051c35faa1a4b3758
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
@@ -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
|