henitai 0.1.0

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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +182 -0
  5. data/assets/schema/henitai.schema.json +123 -0
  6. data/exe/henitai +6 -0
  7. data/lib/henitai/arid_node_filter.rb +97 -0
  8. data/lib/henitai/cli.rb +341 -0
  9. data/lib/henitai/configuration.rb +132 -0
  10. data/lib/henitai/configuration_validator.rb +293 -0
  11. data/lib/henitai/coverage_bootstrapper.rb +75 -0
  12. data/lib/henitai/coverage_formatter.rb +112 -0
  13. data/lib/henitai/equivalence_detector.rb +85 -0
  14. data/lib/henitai/execution_engine.rb +174 -0
  15. data/lib/henitai/git_diff_analyzer.rb +82 -0
  16. data/lib/henitai/integration.rb +417 -0
  17. data/lib/henitai/mutant/activator.rb +234 -0
  18. data/lib/henitai/mutant.rb +68 -0
  19. data/lib/henitai/mutant_generator.rb +158 -0
  20. data/lib/henitai/mutant_history_store.rb +279 -0
  21. data/lib/henitai/operator.rb +96 -0
  22. data/lib/henitai/operators/arithmetic_operator.rb +46 -0
  23. data/lib/henitai/operators/array_declaration.rb +52 -0
  24. data/lib/henitai/operators/assignment_expression.rb +78 -0
  25. data/lib/henitai/operators/block_statement.rb +31 -0
  26. data/lib/henitai/operators/boolean_literal.rb +70 -0
  27. data/lib/henitai/operators/conditional_expression.rb +184 -0
  28. data/lib/henitai/operators/equality_operator.rb +41 -0
  29. data/lib/henitai/operators/hash_literal.rb +66 -0
  30. data/lib/henitai/operators/logical_operator.rb +84 -0
  31. data/lib/henitai/operators/method_expression.rb +56 -0
  32. data/lib/henitai/operators/pattern_match.rb +66 -0
  33. data/lib/henitai/operators/range_literal.rb +40 -0
  34. data/lib/henitai/operators/return_value.rb +105 -0
  35. data/lib/henitai/operators/safe_navigation.rb +34 -0
  36. data/lib/henitai/operators/string_literal.rb +64 -0
  37. data/lib/henitai/operators.rb +25 -0
  38. data/lib/henitai/parser_current.rb +7 -0
  39. data/lib/henitai/reporter.rb +432 -0
  40. data/lib/henitai/result.rb +170 -0
  41. data/lib/henitai/runner.rb +183 -0
  42. data/lib/henitai/sampling_strategy.rb +33 -0
  43. data/lib/henitai/scenario_execution_result.rb +71 -0
  44. data/lib/henitai/source_parser.rb +41 -0
  45. data/lib/henitai/static_filter.rb +186 -0
  46. data/lib/henitai/stillborn_filter.rb +34 -0
  47. data/lib/henitai/subject.rb +71 -0
  48. data/lib/henitai/subject_resolver.rb +232 -0
  49. data/lib/henitai/syntax_validator.rb +16 -0
  50. data/lib/henitai/test_prioritizer.rb +55 -0
  51. data/lib/henitai/unparse_helper.rb +24 -0
  52. data/lib/henitai/version.rb +5 -0
  53. data/lib/henitai/warning_silencer.rb +16 -0
  54. data/lib/henitai.rb +51 -0
  55. data/sig/configuration_validator.rbs +29 -0
  56. data/sig/henitai.rbs +594 -0
  57. data/sig/unparser.rbs +3 -0
  58. metadata +153 -0
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ module Henitai
5
+ # Command-line interface entry point.
6
+ #
7
+ # Usage:
8
+ # henitai run [options] [SUBJECT_PATTERN...]
9
+ #
10
+ # Options:
11
+ # --since GIT_REF Only mutate subjects changed since GIT_REF
12
+ # --use INTEGRATION Override integration from config (e.g. rspec)
13
+ # --config PATH Path to .henitai.yml (default: .henitai.yml)
14
+ # --operators SET Operator set: light (default) | full
15
+ # --jobs N Number of parallel workers (default: CPU count)
16
+ # --all-logs Print all captured child logs
17
+ # -h, --help Show this help message
18
+ # -v, --version Show version
19
+ # rubocop:disable Metrics/ClassLength
20
+ class CLI
21
+ INIT_TEMPLATE_LINES = [
22
+ "# yaml-language-server: $schema=./assets/schema/henitai.schema.json",
23
+ "includes:",
24
+ " - lib",
25
+ "mutation:",
26
+ " operators: light",
27
+ " timeout: 10.0",
28
+ " max_mutants_per_line: 1",
29
+ " max_flaky_retries: 3",
30
+ " sampling:",
31
+ " ratio: 0.05",
32
+ " strategy: stratified",
33
+ "reports_dir: reports",
34
+ "thresholds:",
35
+ " high: 80",
36
+ " low: 60"
37
+ ].freeze
38
+
39
+ OPERATOR_METADATA = {
40
+ "ArithmeticOperator" => ["Arithmetic operators", "a + b -> a - b"],
41
+ "EqualityOperator" => ["Comparison operators", "a == b -> a != b"],
42
+ "LogicalOperator" => ["Boolean operators", "a && b -> a || b"],
43
+ "BooleanLiteral" => ["Boolean literals", "true -> false"],
44
+ "ConditionalExpression" => ["Conditional branches", "if cond then ... end"],
45
+ "StringLiteral" => ["String literals", '"foo" -> ""'],
46
+ "ReturnValue" => ["Return expressions", "return x -> return nil"],
47
+ "ArrayDeclaration" => ["Array literals", "[1, 2] -> []"],
48
+ "HashLiteral" => ["Hash literals", "{ a: 1 } -> {}"],
49
+ "RangeLiteral" => ["Range literals", "1..5 -> 1...5"],
50
+ "SafeNavigation" => ["Safe navigation", "user&.name -> user.name"],
51
+ "PatternMatch" => ["Pattern matching", "in { x: Integer } -> in { x: String }"],
52
+ "BlockStatement" => ["Block statements", "{ do_work } -> {}"],
53
+ "MethodExpression" => ["Method calls", "call_service -> nil"],
54
+ "AssignmentExpression" => ["Assignment expressions", "x += 1 -> x -= 1"]
55
+ }.freeze
56
+
57
+ def self.start(argv)
58
+ new(argv).run
59
+ end
60
+
61
+ def initialize(argv)
62
+ @argv = argv.dup
63
+ end
64
+
65
+ def run
66
+ command = @argv.shift
67
+ case command
68
+ when "run" then run_command
69
+ when "version" then puts Henitai::VERSION
70
+ when "init" then init_command
71
+ when "operator" then operator_command
72
+ when nil, "-h", "--help" then puts help_text
73
+ else
74
+ warn "Unknown command: #{command}"
75
+ warn help_text
76
+ exit 1
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def run_command
83
+ @command_halted = false
84
+ options = parse_run_options
85
+ return if @command_halted
86
+
87
+ config = load_config(options)
88
+ result = run_pipeline(options, config)
89
+ exit(exit_status_for(result, config))
90
+ rescue StandardError => e
91
+ handle_run_error(e)
92
+ end
93
+
94
+ def parse_run_options
95
+ options = {}
96
+ build_run_option_parser(options).parse!(@argv)
97
+ options
98
+ end
99
+
100
+ def configuration_overrides(options)
101
+ deep_compact(
102
+ {
103
+ integration: options[:integration],
104
+ all_logs: options[:all_logs],
105
+ mutation: {
106
+ operators: options[:operators],
107
+ timeout: options[:timeout]
108
+ },
109
+ jobs: options[:jobs]
110
+ }
111
+ )
112
+ end
113
+
114
+ def deep_compact(value)
115
+ case value
116
+ when Hash
117
+ value.each_with_object({}) do |(key, nested_value), result|
118
+ compacted = deep_compact(nested_value)
119
+ result[key] = compacted unless compacted.nil?
120
+ end
121
+ when Array
122
+ value.map { |item| deep_compact(item) }.compact
123
+ else
124
+ value
125
+ end
126
+ end
127
+
128
+ def build_run_option_parser(options)
129
+ OptionParser.new do |opts|
130
+ opts.banner = "Usage: henitai run [options] [SUBJECT_PATTERN...]"
131
+ add_since_option(opts, options)
132
+ add_integration_option(opts, options)
133
+ add_config_option(opts, options)
134
+ add_operator_option(opts, options)
135
+ add_jobs_option(opts, options)
136
+ add_output_option(opts, options)
137
+ add_help_option(opts)
138
+ add_version_option(opts)
139
+ end
140
+ end
141
+
142
+ def add_since_option(opts, options)
143
+ opts.on("--since GIT_REF", "Only mutate subjects changed since GIT_REF") do |ref|
144
+ options[:since] = ref
145
+ end
146
+ end
147
+
148
+ def add_integration_option(opts, options)
149
+ opts.on("--use INTEGRATION", "Test framework integration (rspec)") do |name|
150
+ options[:integration] = name
151
+ end
152
+ end
153
+
154
+ def add_config_option(opts, options)
155
+ opts.on("--config PATH", "Path to .henitai.yml") do |path|
156
+ options[:config] = path
157
+ end
158
+ end
159
+
160
+ def add_operator_option(opts, options)
161
+ opts.on("--operators SET", "Operator set: light | full") do |set|
162
+ options[:operators] = set
163
+ end
164
+ end
165
+
166
+ def add_jobs_option(opts, options)
167
+ opts.on("--jobs N", Integer, "Number of parallel workers") do |n|
168
+ options[:jobs] = n
169
+ end
170
+ end
171
+
172
+ def add_output_option(opts, options)
173
+ opts.on("--all-logs", "--verbose", "Print all captured child logs") do
174
+ options[:all_logs] = true
175
+ end
176
+ end
177
+
178
+ def add_help_option(opts)
179
+ opts.on("-h", "--help", "Show this help") do
180
+ puts opts
181
+ @command_halted = true
182
+ end
183
+ end
184
+
185
+ def add_version_option(opts)
186
+ opts.on("-v", "--version", "Show version") do
187
+ puts Henitai::VERSION
188
+ @command_halted = true
189
+ end
190
+ end
191
+
192
+ def help_text
193
+ <<~HELP
194
+ Hen'i-tai 変異体 #{Henitai::VERSION} — Ruby 4 Mutation Testing
195
+
196
+ Usage:
197
+ henitai run [options] [SUBJECT_PATTERN...]
198
+ henitai version
199
+ henitai init [PATH]
200
+ henitai operator list
201
+
202
+ Examples:
203
+ bundle exec henitai run
204
+ bundle exec henitai run --since origin/main
205
+ bundle exec henitai run 'Foo::Bar#my_method'
206
+ bundle exec henitai run 'MyNamespace*' --operators full
207
+ bundle exec henitai init
208
+ bundle exec henitai operator list
209
+
210
+ Run `henitai run --help` for full option list.
211
+ HELP
212
+ end
213
+
214
+ def run_pipeline(options, config)
215
+ runner = Runner.new(
216
+ config:,
217
+ subjects: subjects_from_argv,
218
+ since: options[:since]
219
+ )
220
+ runner.run
221
+ end
222
+
223
+ def load_config(options)
224
+ Configuration.load(
225
+ path: options.fetch(:config, Configuration::CONFIG_FILE),
226
+ overrides: configuration_overrides(options)
227
+ )
228
+ end
229
+
230
+ def subjects_from_argv
231
+ @argv.empty? ? nil : @argv.map { |expr| Subject.parse(expr) }
232
+ end
233
+
234
+ def handle_run_error(error)
235
+ warn "#{error.class}: #{error.message}"
236
+ exit 2
237
+ end
238
+
239
+ def exit_status_for(result, config)
240
+ result.mutation_score.to_i >= config.thresholds.fetch(:low, 60) ? 0 : 1
241
+ end
242
+
243
+ def init_command
244
+ path = @argv.shift || Configuration::CONFIG_FILE
245
+ unexpected_arguments = @argv.dup
246
+ warn "Unexpected arguments: #{unexpected_arguments.join(' ')}" unless unexpected_arguments.empty?
247
+ exit 1 unless unexpected_arguments.empty?
248
+
249
+ File.write(path, init_template)
250
+ puts "Created #{path}"
251
+ end
252
+
253
+ def operator_command
254
+ subcommand = @argv.shift
255
+ case subcommand
256
+ when "list" then puts operator_list_text
257
+ when nil, "-h", "--help" then puts operator_help_text
258
+ else
259
+ warn "Unknown operator command: #{subcommand}"
260
+ warn operator_help_text
261
+ exit 1
262
+ end
263
+ rescue ArgumentError => e
264
+ warn e.message
265
+ exit 1
266
+ end
267
+
268
+ def init_template
269
+ template = init_template_lines
270
+ template << integration_block if include_default_integration?
271
+ "#{template.join("\n")}\n"
272
+ end
273
+
274
+ def init_template_lines
275
+ INIT_TEMPLATE_LINES.dup
276
+ end
277
+
278
+ def include_default_integration?
279
+ return true unless $stdin.tty?
280
+
281
+ print "Use the default RSpec integration? [Y/n] "
282
+ response = $stdin.gets&.strip&.downcase
283
+ response.nil? || response.empty? || !%w[n no].include?(response)
284
+ end
285
+
286
+ def integration_block
287
+ <<~YAML.chomp
288
+ integration:
289
+ name: rspec
290
+ YAML
291
+ end
292
+
293
+ def operator_help_text
294
+ <<~HELP
295
+ Hen'i-tai operator commands
296
+
297
+ Usage:
298
+ henitai operator list
299
+
300
+ Run `henitai operator list` to see all built-in operators.
301
+ HELP
302
+ end
303
+
304
+ def operator_list_text
305
+ validate_operator_metadata!
306
+ sections = [
307
+ operator_list_section("Light set", Operator::LIGHT_SET),
308
+ operator_list_section("Full set", Operator::FULL_SET)
309
+ ]
310
+
311
+ ["Available operators", *sections].join("\n")
312
+ end
313
+
314
+ def operator_list_section(title, names)
315
+ rows = names.map { |name| operator_description_row(name) }
316
+ ([title] + rows).join("\n")
317
+ end
318
+
319
+ def operator_description_row(name)
320
+ description, example = operator_metadata[name] || fallback_operator_metadata
321
+
322
+ format("- %<name>s: %<description>s (%<example>s)", name:, description:, example:)
323
+ end
324
+
325
+ def operator_metadata
326
+ OPERATOR_METADATA
327
+ end
328
+
329
+ def fallback_operator_metadata
330
+ ["No metadata available", "n/a"]
331
+ end
332
+
333
+ def validate_operator_metadata!
334
+ missing = Operator::FULL_SET - operator_metadata.keys
335
+ return if missing.empty?
336
+
337
+ raise ArgumentError, "Missing operator metadata for: #{missing.join(', ')}"
338
+ end
339
+ end
340
+ # rubocop:enable Metrics/ClassLength
341
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "psych"
4
+
5
+ require_relative "configuration_validator"
6
+
7
+ module Henitai
8
+ # Loads and validates .henitai.yml configuration.
9
+ #
10
+ # Configuration is resolved from built-in defaults and the project-root
11
+ # `.henitai.yml` file.
12
+ class Configuration
13
+ DEFAULT_TIMEOUT = 10.0
14
+ DEFAULT_OPERATORS = :light
15
+ DEFAULT_JOBS = nil # auto-detect
16
+ DEFAULT_MAX_MUTANTS_PER_LINE = 1
17
+ DEFAULT_MAX_FLAKY_RETRIES = 3
18
+ DEFAULT_REPORTS_DIR = "reports"
19
+ DEFAULT_COVERAGE_CRITERIA = {
20
+ test_result: true,
21
+ timeout: false,
22
+ process_abort: false
23
+ }.freeze
24
+ DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
25
+ CONFIG_FILE = ".henitai.yml"
26
+
27
+ attr_reader :integration, :includes, :operators, :timeout,
28
+ :ignore_patterns, :max_mutants_per_line, :sampling, :jobs,
29
+ :max_flaky_retries, :coverage_criteria, :thresholds,
30
+ :reporters, :reports_dir,
31
+ :dashboard, :all_logs
32
+
33
+ # @param path [String] path to .henitai.yml (default: project root)
34
+ def self.load(path: CONFIG_FILE, overrides: {})
35
+ new(path:, overrides:)
36
+ end
37
+
38
+ def initialize(path: CONFIG_FILE, overrides: {})
39
+ raw = load_raw_configuration(path)
40
+ unless raw.is_a?(Hash)
41
+ raise Henitai::ConfigurationError,
42
+ "Invalid configuration value for configuration: expected Hash, got #{raw.class}"
43
+ end
44
+ merged = merge_defaults(raw, symbolize_keys(overrides))
45
+ ConfigurationValidator.validate!(merged)
46
+ apply_defaults(merged)
47
+ end
48
+
49
+ private
50
+
51
+ def load_raw_configuration(path)
52
+ return {} unless File.exist?(path)
53
+
54
+ raw = Psych.safe_load(File.read(path), symbolize_names: true)
55
+ raw || {}
56
+ end
57
+
58
+ def apply_defaults(raw)
59
+ apply_general_defaults(raw)
60
+ apply_mutation_defaults(raw)
61
+ apply_analysis_defaults(raw)
62
+ end
63
+
64
+ def apply_general_defaults(raw)
65
+ integration = raw[:integration]
66
+ @integration = if integration.is_a?(Hash)
67
+ integration[:name] || "rspec"
68
+ elsif integration.nil?
69
+ "rspec"
70
+ else
71
+ integration
72
+ end
73
+ @includes = raw[:includes] || ["lib"]
74
+ @jobs = raw[:jobs]
75
+ @reporters = raw[:reporters] || ["terminal"]
76
+ @reports_dir = raw[:reports_dir] || DEFAULT_REPORTS_DIR
77
+ @all_logs = raw[:all_logs] == true
78
+ # @type var empty_dashboard: Hash[Symbol, untyped]
79
+ empty_dashboard = {}
80
+ @dashboard = merge_defaults(empty_dashboard, raw[:dashboard])
81
+ end
82
+
83
+ def apply_mutation_defaults(raw)
84
+ mutation = raw[:mutation] || {}
85
+
86
+ @operators = (mutation[:operators] || DEFAULT_OPERATORS).to_sym
87
+ @timeout = mutation[:timeout] || DEFAULT_TIMEOUT
88
+ @ignore_patterns = mutation[:ignore_patterns] || []
89
+ @max_mutants_per_line = mutation[:max_mutants_per_line] || DEFAULT_MAX_MUTANTS_PER_LINE
90
+ @max_flaky_retries = if mutation.key?(:max_flaky_retries)
91
+ mutation[:max_flaky_retries]
92
+ else
93
+ DEFAULT_MAX_FLAKY_RETRIES
94
+ end
95
+ @sampling = mutation[:sampling]
96
+ end
97
+
98
+ def apply_analysis_defaults(raw)
99
+ @coverage_criteria = merge_defaults(DEFAULT_COVERAGE_CRITERIA,
100
+ raw[:coverage_criteria])
101
+ @thresholds = merge_defaults(DEFAULT_THRESHOLDS, raw[:thresholds])
102
+ end
103
+
104
+ def merge_defaults(defaults, overrides)
105
+ return defaults.dup if overrides.nil?
106
+
107
+ defaults.merge(overrides) do |_key, default_value, override_value|
108
+ if default_value.is_a?(Hash) && override_value.is_a?(Hash)
109
+ merge_defaults(default_value, override_value)
110
+ else
111
+ override_value
112
+ end
113
+ end
114
+ end
115
+
116
+ def symbolize_keys(value)
117
+ case value
118
+ when Hash
119
+ # @type var result: Hash[Symbol, untyped]
120
+ result = {}
121
+ value.each do |key, val|
122
+ result[key.to_sym] = symbolize_keys(val)
123
+ end
124
+ result
125
+ when Array
126
+ value.map { |item| symbolize_keys(item) }
127
+ else
128
+ value
129
+ end
130
+ end
131
+ end
132
+ end