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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a993f2073367de6b25c8fe922a2089f57103d79790c1e9cd95d2ba6b573b3050
4
- data.tar.gz: d6956c7b23f1ec7cd97773cdd249a2c12b17f75e080cb98092d34049a763c607
3
+ metadata.gz: cda194514bf677d142e24c97c1589714041d2c88c4d2b33ffaf642e79c7ebe5d
4
+ data.tar.gz: 8505d5d6add5b850c5accb4904f43351e129b9261699c58051c35faa1a4b3758
5
5
  SHA512:
6
- metadata.gz: e7a1925ad77b77ac4ed802b3419b15988a436098488a2dfb0ee11f8f930da024bc05a686606ae4e86ba6fd9f36ef4aed19ef8b1e46bef31e143d25ee4dc54ded
7
- data.tar.gz: f095d945bd5f9313a202db7a6ce3936a69233b47d62b92ec353950857c27d58b75dc8084164383d66558ef2a076c62e2fd53e68621d8b9d95546c6b1114faa73
6
+ metadata.gz: b17e21c51f0d4aac03154d8b782d4e361ea8fa792c03a984b9d5b94deccd2411ffb3fe8a7e6fe096b76b33f2bc211a813618c059642b4dec3e54b8fab4d01d8a
7
+ data.tar.gz: 109527f73b390086f03b5249dd90e97bf57ad3e34551f94d1f80b8f1e5968699e8d6cefe394ebf87bc10465b2a759de737d5f54257807e654a92cd040fa73f59
data/CHANGELOG.md CHANGED
@@ -7,6 +7,97 @@ 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
+
33
+ ## [0.2.0] - 2026-04-30
34
+
35
+ ### Added
36
+ - `--survivors-from <path>` flag on `henitai run` for survivor-only reruns:
37
+ re-execute only the mutants that survived a prior full run without re-running
38
+ the whole suite
39
+ - `SurvivorLoader` reads a Stryker-compatible JSON report and extracts survivor
40
+ IDs, per-survivor coverage maps (`coveredBy`), and the anchoring git SHA
41
+ - `SurvivorSelector` filters the current mutant set to the survivor subset; emits
42
+ a drift warning when more than 50% of loaded survivors are unmatched (source
43
+ changed significantly since the prior run)
44
+ - `SurvivorTestFilter` skips survivors whose covering tests are unchanged since
45
+ the prior report's git SHA, marking them `:survived` immediately without
46
+ execution — same safety logic as StrykerJS incremental mode
47
+ - `MutantIdentity` module: stable SHA256 identity computed from expression,
48
+ operator, description, location, and mutation signature; shared by
49
+ `MutantHistoryStore` and `Mutant#stable_id`
50
+ - `Mutant#stable_id` exposes the stable identity; emitted as `stableId` in the
51
+ JSON report so survivor reports remain useful across commits
52
+ - `Result` carries `session_id` (UUID) and optional `git_sha` (HEAD SHA at
53
+ report time); both emitted in the Stryker-compatible JSON as `sessionId` /
54
+ `gitSha`
55
+ - `Reporter::Json` writes an immutable per-session snapshot alongside the
56
+ canonical report at `reports/sessions/<session_id>/mutation-report.json`,
57
+ giving `--survivors-from` a stable reference path across runs
58
+ - `GitDiffAnalyzer#head_sha` — returns the current HEAD SHA; `nil` when git is
59
+ unavailable (conservative fallback: all survivors are executed)
60
+ - `ProcessWorkerRunner` — flat process-slot scheduler for parallel mutant
61
+ execution: each slot owns one OS process, slots are refilled as children
62
+ finish, no thread per child
63
+ - Interrupt handling, spawn failure isolation, and in-slot retry added to
64
+ `ProcessWorkerRunner` (PR 6)
65
+ - Timeout precision and two-phase process-group cleanup in
66
+ `ProcessWorkerRunner` (PR 5)
67
+ - x86_64 platform added to gem platform list
68
+
69
+ ### Changed
70
+ - JSON mutation report vendor extension now always includes `sessionId`
71
+ (and `gitSha` when available) to support survivor-only reruns
72
+ - Terminal reporter labels partial reruns as "Partial survivor rerun" and
73
+ shows matched / unmatched / skipped-by-diff counts
74
+ - History store skips `runs` row insertion for partial reruns to avoid
75
+ distorting trend analytics; per-mutant `current_status` upsert still runs
76
+ - CLI exits 0 for partial reruns with a printed warning; threshold comparison
77
+ is skipped (applying a partial score to a CI gate is misleading)
78
+ - `StaticFilter` merges per-test coverage into standard coverage so
79
+ `coveredBy` data is available to both RSpec and Minitest survivor reports
80
+ - `.henitai.yml` default: operators set to `light`, timeout lowered to 10 s,
81
+ `max_flaky_retries: 3` added
82
+
83
+ ### Fixed
84
+ - Minitest autorun hook suppressed in mutation child processes to prevent
85
+ spurious re-runs of the full suite inside each fork
86
+ - Coverage bootstrap RSpec subprocess ARGV leakage resolved — child processes
87
+ no longer inherit the parent's `--format` / file arguments
88
+ - Survivor rerun state preserved across RSpec execution (was reset on each
89
+ subprocess boot)
90
+ - `--survivors-from` now respects dirty worktrees: coverage and source file
91
+ state are read from the working tree, not the index
92
+ - Recipe fast path skipped when source files changed since the cached run,
93
+ preventing stale cache hits after edits
94
+
95
+ ### Performance
96
+ - File discovery cached in the integration layer; repeated calls within one
97
+ run no longer re-scan the filesystem
98
+ - Polling sleep removed from the scheduler hot loop; slots are refilled
99
+ event-driven on child exit
100
+
10
101
  ## [0.1.10] - 2026-04-16
11
102
 
12
103
  ### Fixed
@@ -211,7 +302,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
211
302
  - CLI critical path: `henitai run` now executes the full pipeline, supports `--since`, returns CI-friendly exit codes, and `henitai version` prints `Henitai::VERSION`
212
303
  - RSpec per-test coverage output: `henitai/coverage_formatter` now writes `coverage/henitai_per_test.json`
213
304
 
214
- [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
215
308
  [0.1.10]: https://github.com/martinotten/henitai/compare/v0.1.9...v0.1.10
216
309
  [0.1.9]: https://github.com/martinotten/henitai/compare/v0.1.8...v0.1.9
217
310
  [0.1.8]: https://github.com/martinotten/henitai/compare/v0.1.7...v0.1.8
data/README.md CHANGED
@@ -15,10 +15,9 @@ A Ruby mutation testing framework
15
15
  ## Maturaty
16
16
 
17
17
  - This is alpha software, there will be bugs
18
- - Henitai tests itself
19
- - Stryker Dashboard support is untested
20
- - Minitest support is untested
21
- - Lots of code has been carefully crafted by AI agents, not everything has been reviewed by humans (yet)
18
+ - Henitai tests itself and other projects
19
+ - It might break with a release
20
+ - If you need a mature solution for mutation testing in ruby, take a look at the [https://github.com/mbj/mutant](Mutant gem)
22
21
 
23
22
  ## What is mutation testing?
24
23
 
@@ -28,6 +27,8 @@ A mutation testing tool makes small, systematic changes — *mutants* — to you
28
27
 
29
28
  The ratio of killed mutants to total mutants is the **Mutation Score** (MS). A high mutation score is a strong quality signal.
30
29
 
30
+ As mutation testing modifies your code you make sure that your tests cannot have any unintended side-effects.
31
+
31
32
  ## Installation
32
33
 
33
34
  Add to your `Gemfile`:
@@ -42,7 +43,7 @@ Or install globally:
42
43
  gem install henitai
43
44
  ```
44
45
 
45
- **Requires Ruby 4.0.2+**
46
+ **Requires Ruby 4.0.0+**
46
47
 
47
48
  ## Quick start
48
49
 
@@ -56,6 +57,9 @@ bundle exec henitai run --since origin/main
56
57
  # Run on a specific subject pattern
57
58
  bundle exec henitai run 'MyClass#my_method'
58
59
  bundle exec henitai run 'MyNamespace*'
60
+
61
+ # Re-run only survivors from a prior mutation report
62
+ bundle exec henitai run --survivors-from reports/mutation-report.json
59
63
  ```
60
64
 
61
65
  Configuration lives in `.henitai.yml`:
@@ -68,6 +72,9 @@ integration:
68
72
  includes:
69
73
  - lib
70
74
 
75
+ excludes:
76
+ - lib/henitai/eager_load.rb # standalone entry points with no in-process coverage
77
+
71
78
  mutation:
72
79
  operators: light # light | full
73
80
  timeout: 10.0
@@ -112,6 +119,12 @@ and the terminal only shows progress plus a concise summary. Pass
112
119
  when the mutation score meets the low threshold, `1` when it does not, and `2`
113
120
  for framework errors.
114
121
 
122
+ `henitai run --survivors-from ...` performs a partial rerun: it reports only
123
+ the selected survivors, skips threshold-based exit checks, and does not update
124
+ the run trend history. Dirty worktree changes are included, so you can edit
125
+ tests locally without committing first; if source files under `includes` are
126
+ dirty, Henitai reruns the matched survivors conservatively.
127
+
115
128
  The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/workspaces/henitai/assets/schema/henitai.schema.json) for editor autocompletion.
116
129
 
117
130
  ## Operator sets
@@ -129,13 +142,17 @@ The repository ships a JSON Schema at [`assets/schema/henitai.schema.json`](/wor
129
142
  **Full** — adds lower-signal operators:
130
143
 
131
144
  - `ArrayDeclaration`, `HashLiteral`, `RangeLiteral`
145
+ - `MethodChainUnwrap` — remove a link from a method chain
146
+ - `RegexMutator` — mutate regexp quantifiers, anchors, char-class negation
132
147
  - `SafeNavigation` — `&.` → `.`
133
148
  - `PatternMatch` — case/in arm removal
134
149
  - `BlockStatement` — remove blocks
135
150
  - `MethodExpression` — remove calls
136
151
  - `AssignmentExpression` — mutate compound assignment
152
+ - `UnaryOperator` — remove unary `-` and `~`
153
+ - `UpdateOperator` — swap compound assignments (`+=`↔`-=`, `*=`↔`/=`, `||=`↔`&&=`)
137
154
 
138
- ## Stryker Dashboard integration (untested)
155
+ ## Stryker Dashboard integration
139
156
 
140
157
  ```yaml
141
158
  # .henitai.yml
@@ -150,7 +167,12 @@ dashboard:
150
167
  base_url: "https://dashboard.stryker-mutator.io"
151
168
  ```
152
169
 
153
- 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`).
154
176
 
155
177
  JSON reports are written to `reports/mutation-report.json` by default. Set
156
178
  `reports_dir` to change the output directory.
@@ -172,6 +194,10 @@ Framework integration smoke projects live under `spec/fixtures/integration_smoke
172
194
  and exercise `henitai` against small RSpec and Minitest apps via the local path
173
195
  dependency.
174
196
 
197
+ Git hook support is tracked in [`.githooks/pre-commit`](/Users/martinotten/projects/mo/henitai/.githooks/pre-commit).
198
+ Enable it with `git config core.hooksPath .githooks` so commits run RuboCop,
199
+ RSpec, and the integration smoke suite before they are created.
200
+
175
201
  A Dev Container configuration is included (`.devcontainer/`) for VS Code with the official `ruby:4`
176
202
  image, the Codex CLI, and RTK preinstalled. Codex support is bootstrapped with
177
203
  `rtk init -g --codex --auto-patch` during container creation.
@@ -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