mutation_tester 1.2.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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/Gemfile +9 -0
  4. data/Gemfile.lock +83 -0
  5. data/LICENSE.txt +19 -0
  6. data/Rakefile +57 -0
  7. data/docs/ci.md +158 -0
  8. data/docs/execution-runners.md +163 -0
  9. data/docs/json-schema.md +171 -0
  10. data/docs/mutation-types.md +207 -0
  11. data/examples/calculator.rb +35 -0
  12. data/examples/calculator_100_perc_spec.rb +121 -0
  13. data/examples/calculator_minitest.rb +53 -0
  14. data/examples/calculator_spec.rb +105 -0
  15. data/examples/github_actions/ai_mutation_gate.yml +75 -0
  16. data/examples/github_actions/mutation_test.yml +87 -0
  17. data/examples/hooks/pre-push +41 -0
  18. data/examples/run_example.rb +31 -0
  19. data/examples/run_example_minitest.rb +38 -0
  20. data/examples/run_example_parallel_minitest.rb +48 -0
  21. data/examples/run_example_parallel_rspec.rb +48 -0
  22. data/examples/run_example_rspec.rb +38 -0
  23. data/examples/spec_helper.rb +21 -0
  24. data/exe/mutation_test +345 -0
  25. data/lib/mutation_tester/batch_runner.rb +286 -0
  26. data/lib/mutation_tester/configuration.rb +105 -0
  27. data/lib/mutation_tester/core.rb +193 -0
  28. data/lib/mutation_tester/fork_runner/worker.rb +202 -0
  29. data/lib/mutation_tester/fork_runner.rb +382 -0
  30. data/lib/mutation_tester/framework_detector.rb +25 -0
  31. data/lib/mutation_tester/in_memory_loader.rb +25 -0
  32. data/lib/mutation_tester/mutation_runner.rb +644 -0
  33. data/lib/mutation_tester/mutator.rb +874 -0
  34. data/lib/mutation_tester/progress_display.rb +130 -0
  35. data/lib/mutation_tester/railtie.rb +7 -0
  36. data/lib/mutation_tester/rake_task.rb +18 -0
  37. data/lib/mutation_tester/reporters/base_reporter.rb +154 -0
  38. data/lib/mutation_tester/reporters/batch_json_reporter.rb +72 -0
  39. data/lib/mutation_tester/reporters/console_reporter.rb +130 -0
  40. data/lib/mutation_tester/reporters/html_reporter.rb +693 -0
  41. data/lib/mutation_tester/reporters/json_reporter.rb +67 -0
  42. data/lib/mutation_tester/test_command.rb +165 -0
  43. data/lib/mutation_tester/version.rb +3 -0
  44. data/lib/mutation_tester.rb +52 -0
  45. data/lib/tasks/mutation_tester.rake +80 -0
  46. data/readme.md +925 -0
  47. metadata +213 -0
@@ -0,0 +1,874 @@
1
+ module MutationTester
2
+ class Mutator
3
+ MUTATIONS = {
4
+ arithmetic: {
5
+ :+ => %i[- * /],
6
+ :- => %i[+ * /],
7
+ :* => %i[+ - /],
8
+ :/ => %i[+ - *],
9
+ :% => %i[+ - *],
10
+ :** => %i[* +]
11
+ },
12
+ comparison: {
13
+ :> => %i[< >= <= ==],
14
+ :< => %i[> >= <= ==],
15
+ :>= => %i[< > <= ==],
16
+ :<= => %i[> >= < ==],
17
+ :== => %i[!= > <],
18
+ :!= => %i[== > <],
19
+ :<=> => [:==]
20
+ },
21
+ bitwise: {
22
+ :| => %i[&],
23
+ :& => %i[|],
24
+ :^ => %i[| &]
25
+ },
26
+ strict_equality: {
27
+ :== => %i[eql? equal?]
28
+ }
29
+ }.freeze
30
+
31
+ REQUIRE_METHODS = %i[require require_relative load autoload].freeze
32
+
33
+ PURE_TRANSFORMATIONS = %i[
34
+ uniq compact sort flatten strip chomp downcase upcase capitalize
35
+ reverse round floor ceil abs to_a
36
+ ].freeze
37
+
38
+ BLOCK_NODE_TYPES = %i[block numblock itblock].freeze
39
+
40
+ PLAIN_METHOD_NAME = /\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/.freeze
41
+
42
+ NON_MUTABLE_ARGUMENT_TYPES = %i[block_pass splat kwsplat].freeze
43
+
44
+ DISABLE_ANNOTATION = /mutation_tester:disable\b/.freeze
45
+
46
+ def self.disabled_lines(content)
47
+ _ast, comments = Parser::CurrentRuby.parse_with_comments(content)
48
+ comments
49
+ .select { |comment| comment.text.match?(DISABLE_ANNOTATION) }
50
+ .map { |comment| comment.location.line }
51
+ .uniq
52
+ rescue Parser::SyntaxError
53
+ []
54
+ end
55
+
56
+ attr_reader :skipped_count, :excluded_count
57
+
58
+ def initialize(source_file, config = MutationTester.configuration)
59
+ @source_file = source_file
60
+ @config = config
61
+ @original_content = File.read(source_file)
62
+ @mutation_id = 0
63
+ @skipped_count = 0
64
+ @excluded_count = 0
65
+ @disabled_lines = []
66
+ end
67
+
68
+ def generate_mutations(ast)
69
+ @skipped_count = 0
70
+ @excluded_count = 0
71
+ @disabled_lines = self.class.disabled_lines(@original_content)
72
+ @in_memory_safe_context = false
73
+ @class_body_block_depth = 0
74
+ mutations = collect_mutations(ast)
75
+ mutations = filter_mutations(mutations)
76
+ report_skipped(mutations.size) if @skipped_count.positive?
77
+ mutations
78
+ end
79
+
80
+ private
81
+
82
+ def filter_mutations(mutations)
83
+ seen = {}
84
+ filtered = []
85
+ mutations.each do |mutation|
86
+ if @disabled_lines.include?(mutation[:line])
87
+ @excluded_count += 1
88
+ next
89
+ end
90
+
91
+ code = mutation[:code]
92
+ next if code == @original_content
93
+ next if seen[code]
94
+
95
+ seen[code] = true
96
+ filtered << mutation
97
+ end
98
+ renumber_mutations(filtered)
99
+ end
100
+
101
+ def renumber_mutations(mutations)
102
+ mutations.each_with_index { |mutation, index| mutation[:id] = index + 1 }
103
+ mutations
104
+ end
105
+
106
+ def collect_mutations(ast, skip_string_mutation: false, block_call: false)
107
+ mutations = []
108
+ previous_safe = @in_memory_safe_context
109
+ previous_block_depth = @class_body_block_depth
110
+ return mutations unless ast
111
+
112
+ scope_name = method_scope_name(ast)
113
+ previous_scope = @enclosing_method
114
+ @enclosing_method = scope_name if scope_name
115
+ update_load_time_context(ast.type)
116
+
117
+ case ast.type
118
+ when :send
119
+ mutations += mutate_send_node(ast) if enabled?(:arithmetic) || enabled?(:comparison) || enabled?(:logical) || enabled?(:strict_equality)
120
+ mutations += mutate_call_removal_node(ast) if enabled?(:call_removal) && !block_call
121
+ mutations += mutate_argument_node(ast) if enabled?(:argument)
122
+ when :def, :defs
123
+ mutations += mutate_method_return_node(ast) if enabled?(:nil_injection)
124
+ mutations += mutate_default_value_node(ast) if enabled?(:argument)
125
+ when :ivasgn
126
+ mutations += mutate_ivasgn_node(ast) if enabled?(:nil_injection)
127
+ when :true, :false
128
+ mutations += mutate_boolean_node(ast) if enabled?(:boolean)
129
+ when :if, :while, :until, :while_post, :until_post
130
+ mutations += mutate_conditional_node(ast) if enabled?(:conditional)
131
+ when :case
132
+ mutations += mutate_case_node(ast) if enabled?(:conditional)
133
+ when :op_asgn
134
+ mutations += mutate_op_asgn_node(ast) if enabled?(:arithmetic)
135
+ when :or_asgn
136
+ mutations += mutate_or_asgn_node(ast) if enabled?(:logical)
137
+ when :and_asgn
138
+ mutations += mutate_and_asgn_node(ast) if enabled?(:logical)
139
+ when :int, :float
140
+ mutations += mutate_number_node(ast) if enabled?(:number)
141
+ when :str, :dstr
142
+ mutations += mutate_string_node(ast) if enabled?(:string) && !skip_string_mutation && !heredoc_node?(ast)
143
+ when :and
144
+ mutations += mutate_logical_and(ast) if enabled?(:logical)
145
+ when :or
146
+ mutations += mutate_logical_or(ast) if enabled?(:logical)
147
+ end
148
+
149
+ mutations.each do |mutation|
150
+ mutation[:method_name] = @enclosing_method
151
+ mutation[:in_memory_safe] = @in_memory_safe_context
152
+ end
153
+
154
+ loader_call = require_like_send?(ast)
155
+ heredoc = heredoc_node?(ast)
156
+ ast.children.each do |child|
157
+ next unless child.is_a?(Parser::AST::Node)
158
+
159
+ child_skips_string =
160
+ skip_string_mutation || heredoc || (loader_call && string_literal?(child))
161
+ child_is_block_call = BLOCK_NODE_TYPES.include?(ast.type) && child.equal?(ast.children[0])
162
+ mutations += collect_mutations(child, skip_string_mutation: child_skips_string, block_call: child_is_block_call)
163
+ end
164
+
165
+ mutations
166
+ ensure
167
+ @enclosing_method = previous_scope if scope_name
168
+ @in_memory_safe_context = previous_safe
169
+ @class_body_block_depth = previous_block_depth
170
+ end
171
+
172
+ def update_load_time_context(type)
173
+ case type
174
+ when :class, :module, :sclass
175
+ @in_memory_safe_context = false
176
+ when :def, :defs
177
+ @in_memory_safe_context = true if @class_body_block_depth.zero?
178
+ else
179
+ @class_body_block_depth += 1 if BLOCK_NODE_TYPES.include?(type) && !@in_memory_safe_context
180
+ end
181
+ end
182
+
183
+ def method_scope_name(node)
184
+ case node.type
185
+ when :def then node.children[0].to_s
186
+ when :defs then node.children[1].to_s
187
+ end
188
+ end
189
+
190
+ def require_like_send?(node)
191
+ node.type == :send && node.children[0].nil? && REQUIRE_METHODS.include?(node.children[1])
192
+ end
193
+
194
+ def string_literal?(node)
195
+ node.type == :str || node.type == :dstr
196
+ end
197
+
198
+ def heredoc_node?(node)
199
+ loc = node.loc
200
+ loc.respond_to?(:heredoc_body) && !loc.heredoc_body.nil?
201
+ end
202
+
203
+ def warn_skipped(type, node, error)
204
+ @skipped_count += 1
205
+ return unless @config.verbose
206
+
207
+ line = node.loc&.line
208
+ location = "#{@source_file}:#{line}"
209
+ puts Rainbow("Warning: skipped #{type} mutation at #{location} - #{error.message}").yellow
210
+ end
211
+
212
+ def report_skipped(generated)
213
+ puts Rainbow("Generated #{generated} mutations, skipped #{@skipped_count}").yellow
214
+ end
215
+
216
+ def enabled?(type)
217
+ @config.mutation_types[type]
218
+ end
219
+
220
+ def mutate_send_node(node)
221
+ mutations = []
222
+ method_name = node.children[1]
223
+
224
+ if enabled?(:arithmetic) && MUTATIONS[:arithmetic][method_name]
225
+ MUTATIONS[:arithmetic][method_name].each do |replacement|
226
+ mutations << create_mutation(node, replacement, :arithmetic)
227
+ end
228
+ end
229
+
230
+ if enabled?(:comparison) && MUTATIONS[:comparison][method_name]
231
+ MUTATIONS[:comparison][method_name].each do |replacement|
232
+ mutations << create_mutation(node, replacement, :comparison)
233
+ end
234
+ end
235
+
236
+ if enabled?(:strict_equality) && MUTATIONS[:strict_equality][method_name] && strict_equality_candidate?(node)
237
+ MUTATIONS[:strict_equality][method_name].each do |replacement|
238
+ mutations << create_mutation(node, replacement, :strict_equality)
239
+ end
240
+ end
241
+
242
+ mutations
243
+ rescue => e
244
+ warn_skipped(:send, node, e)
245
+ []
246
+ end
247
+
248
+ def strict_equality_candidate?(node)
249
+ node.children[0].is_a?(Parser::AST::Node) && node.children.size == 3
250
+ end
251
+
252
+ def mutate_call_removal_node(node)
253
+ receiver, method_name, *arguments = node.children
254
+ return [] unless receiver.is_a?(Parser::AST::Node)
255
+ return [] unless arguments.empty?
256
+ return [] unless PURE_TRANSFORMATIONS.include?(method_name)
257
+
258
+ mutated_code = replace_node(node, receiver)
259
+ return [] if mutated_code == @original_content
260
+ return [] unless parses_cleanly?(mutated_code)
261
+
262
+ [{
263
+ id: next_mutation_id,
264
+ type: :call_removal,
265
+ line: node.loc.line,
266
+ original: safe_unparse(node),
267
+ mutated: safe_unparse(receiver),
268
+ code: mutated_code,
269
+ source_line: extract_source_line(node.loc.line),
270
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
271
+ description: "Remove #{method_name} call"
272
+ }]
273
+ rescue => e
274
+ warn_skipped(:call_removal, node, e)
275
+ []
276
+ end
277
+
278
+ def mutate_argument_node(node)
279
+ method_name = node.children[1]
280
+ arguments = node.children[2..]
281
+ return [] if arguments.empty?
282
+ return [] unless PLAIN_METHOD_NAME.match?(method_name.to_s)
283
+ return [] if REQUIRE_METHODS.include?(method_name)
284
+
285
+ build_last_argument_removal(node, arguments) + build_argument_nil_mutations(node, arguments)
286
+ rescue => e
287
+ warn_skipped(:argument, node, e)
288
+ []
289
+ end
290
+
291
+ def mutable_argument?(argument)
292
+ return false if NON_MUTABLE_ARGUMENT_TYPES.include?(argument.type)
293
+ return false if %i[kwargs hash].include?(argument.type) &&
294
+ argument.children.any? { |child| child.is_a?(Parser::AST::Node) && child.type == :kwsplat }
295
+
296
+ true
297
+ end
298
+
299
+ def build_last_argument_removal(node, arguments)
300
+ last = arguments.last
301
+ return [] unless mutable_argument?(last)
302
+
303
+ range_begin, range_end =
304
+ if arguments.size > 1
305
+ [arguments[-2].loc.expression.end_pos, last.loc.expression.end_pos]
306
+ elsif node.loc.begin
307
+ [node.loc.begin.end_pos, node.loc.end.begin_pos]
308
+ else
309
+ [node.loc.selector.end_pos, last.loc.expression.end_pos]
310
+ end
311
+
312
+ mutation = build_argument_mutation(
313
+ node, range_begin, range_end, '',
314
+ "Remove last argument from #{node.children[1]}"
315
+ )
316
+ mutation ? [mutation] : []
317
+ end
318
+
319
+ def build_argument_nil_mutations(node, arguments)
320
+ arguments.filter_map do |argument|
321
+ next if !mutable_argument?(argument) || argument.type == :nil
322
+
323
+ expression = argument.loc.expression
324
+ build_argument_mutation(
325
+ node, expression.begin_pos, expression.end_pos, 'nil',
326
+ "Replace argument #{source_slice(expression.begin_pos, expression.end_pos)} with nil"
327
+ )
328
+ end
329
+ end
330
+
331
+ def build_argument_mutation(node, range_begin, range_end, replacement, description)
332
+ mutated_code = splice_source(range_begin, range_end, replacement)
333
+ return nil if mutated_code == @original_content
334
+ return nil unless parses_cleanly?(mutated_code)
335
+
336
+ call = node.loc.expression
337
+ offset = replacement.length - (range_end - range_begin)
338
+ {
339
+ id: next_mutation_id,
340
+ type: :argument,
341
+ line: node.loc.line,
342
+ original: source_slice(call.begin_pos, call.end_pos),
343
+ mutated: mutated_code[call.begin_pos...(call.end_pos + offset)],
344
+ code: mutated_code,
345
+ source_line: extract_source_line(node.loc.line),
346
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
347
+ description: description
348
+ }
349
+ end
350
+
351
+ def mutate_default_value_node(node)
352
+ args = node.type == :def ? node.children[1] : node.children[2]
353
+ return [] unless args.is_a?(Parser::AST::Node)
354
+
355
+ params = args.children.grep(Parser::AST::Node)
356
+ optional_before_required = optional_before_required?(params)
357
+
358
+ params.flat_map do |param|
359
+ next [] unless %i[optarg kwoptarg].include?(param.type)
360
+
361
+ skip_removal = param.type == :optarg && optional_before_required
362
+ build_default_removal(param, skip: skip_removal) + build_default_nil(param)
363
+ end
364
+ rescue => e
365
+ warn_skipped(:argument, node, e)
366
+ []
367
+ end
368
+
369
+ def optional_before_required?(params)
370
+ optarg_seen = false
371
+ params.any? do |param|
372
+ optarg_seen = true if param.type == :optarg
373
+ optarg_seen && param.type == :arg
374
+ end
375
+ end
376
+
377
+ def build_default_removal(param, skip:)
378
+ return [] if skip
379
+
380
+ name = param.children[0]
381
+ range_begin = param.loc.name.end_pos
382
+ range_begin += 1 if param.type == :kwoptarg
383
+ mutation = build_default_value_mutation(
384
+ param, range_begin, param.loc.expression.end_pos, '',
385
+ "Remove default value of #{name}"
386
+ )
387
+ mutation ? [mutation] : []
388
+ end
389
+
390
+ def build_default_nil(param)
391
+ value = param.children[1]
392
+ return [] if %i[nil false].include?(value.type)
393
+
394
+ expression = value.loc.expression
395
+ mutation = build_default_value_mutation(
396
+ param, expression.begin_pos, expression.end_pos, 'nil',
397
+ "Replace default value of #{param.children[0]} with nil"
398
+ )
399
+ mutation ? [mutation] : []
400
+ end
401
+
402
+ def build_default_value_mutation(param, range_begin, range_end, replacement, description)
403
+ mutated_code = splice_source(range_begin, range_end, replacement)
404
+ return nil if mutated_code == @original_content
405
+ return nil unless parses_cleanly?(mutated_code)
406
+
407
+ expression = param.loc.expression
408
+ offset = replacement.length - (range_end - range_begin)
409
+ {
410
+ id: next_mutation_id,
411
+ type: :argument,
412
+ line: param.loc.line,
413
+ original: source_slice(expression.begin_pos, expression.end_pos),
414
+ mutated: mutated_code[expression.begin_pos...(expression.end_pos + offset)],
415
+ code: mutated_code,
416
+ source_line: extract_source_line(param.loc.line),
417
+ mutated_line: extract_mutated_line(mutated_code, param.loc.line),
418
+ description: description
419
+ }
420
+ end
421
+
422
+ def splice_source(range_begin, range_end, replacement)
423
+ code = @original_content.dup
424
+ code[range_begin...range_end] = replacement
425
+ code
426
+ end
427
+
428
+ def source_slice(range_begin, range_end)
429
+ @original_content[range_begin...range_end]
430
+ end
431
+
432
+ def mutate_method_return_node(node)
433
+ body = node.type == :def ? node.children[2] : node.children[3]
434
+ return [] unless body.is_a?(Parser::AST::Node)
435
+ return [] if %i[rescue ensure].include?(body.type)
436
+
437
+ last_expression = body.type == :begin ? body.children.last : body
438
+ return [] unless last_expression.is_a?(Parser::AST::Node)
439
+ return [] if last_expression.type == :nil
440
+
441
+ build_nil_injection_mutation(last_expression, 'Replace method return value with nil')
442
+ rescue => e
443
+ warn_skipped(:nil_injection, node, e)
444
+ []
445
+ end
446
+
447
+ def mutate_ivasgn_node(node)
448
+ name, value = node.children
449
+ return [] unless value.is_a?(Parser::AST::Node)
450
+ return [] if value.type == :nil
451
+
452
+ build_nil_injection_mutation(value, "Assign nil to #{name}")
453
+ rescue => e
454
+ warn_skipped(:nil_injection, node, e)
455
+ []
456
+ end
457
+
458
+ def build_nil_injection_mutation(target, description)
459
+ mutated_code = replace_node(target, Parser::AST::Node.new(:nil))
460
+ return [] if mutated_code == @original_content
461
+ return [] unless parses_cleanly?(mutated_code)
462
+
463
+ [{
464
+ id: next_mutation_id,
465
+ type: :nil_injection,
466
+ line: target.loc.line,
467
+ original: safe_unparse(target),
468
+ mutated: 'nil',
469
+ code: mutated_code,
470
+ source_line: extract_source_line(target.loc.line),
471
+ mutated_line: extract_mutated_line(mutated_code, target.loc.line),
472
+ description: description
473
+ }]
474
+ end
475
+
476
+ def mutate_op_asgn_node(node)
477
+ operator = node.children[1]
478
+ replacements = MUTATIONS[:arithmetic][operator] || MUTATIONS[:bitwise][operator]
479
+ return [] unless replacements
480
+
481
+ replacements.map { |replacement| create_op_asgn_mutation(node, operator, replacement) }
482
+ rescue => e
483
+ warn_skipped(:op_asgn, node, e)
484
+ []
485
+ end
486
+
487
+ def mutate_logical_and(node)
488
+ build_logical_mutation(node, :or) + build_operand_removal_mutations(node)
489
+ end
490
+
491
+ def mutate_logical_or(node)
492
+ build_logical_mutation(node, :and) + build_operand_removal_mutations(node)
493
+ end
494
+
495
+ def build_operand_removal_mutations(node)
496
+ operator = logical_operator_token(node)
497
+ left, right = node.children
498
+
499
+ [[left, right], [right, left]].filter_map do |kept, removed|
500
+ mutated_code = replace_node(node, kept)
501
+ next if mutated_code == @original_content
502
+ next unless parses_cleanly?(mutated_code)
503
+
504
+ {
505
+ id: next_mutation_id,
506
+ type: :logical,
507
+ line: node.loc.line,
508
+ original: safe_unparse(node),
509
+ mutated: safe_unparse(kept),
510
+ code: mutated_code,
511
+ source_line: extract_source_line(node.loc.line),
512
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
513
+ description: "Remove operand #{safe_unparse(removed)} from #{operator}"
514
+ }
515
+ end
516
+ rescue => e
517
+ warn_skipped(:logical, node, e)
518
+ []
519
+ end
520
+
521
+ def logical_operator_token(node)
522
+ operator_loc = node.loc&.operator
523
+ return operator_loc.source if operator_loc
524
+
525
+ node.type == :and ? '&&' : '||'
526
+ end
527
+
528
+ def mutate_or_asgn_node(node)
529
+ build_op_asgn_logical_mutation(node, '&&=')
530
+ end
531
+
532
+ def mutate_and_asgn_node(node)
533
+ build_op_asgn_logical_mutation(node, '||=')
534
+ end
535
+
536
+ def build_op_asgn_logical_mutation(node, mutated_operator)
537
+ operator_loc = node.loc&.operator
538
+ return [] unless operator_loc
539
+
540
+ lines = @original_content.lines
541
+ line_index = operator_loc.line - 1
542
+ return [] if line_index >= lines.size
543
+
544
+ start_col = operator_loc.column
545
+ end_col = operator_loc.last_column
546
+ original_operator = lines[line_index][start_col...end_col]
547
+
548
+ line = lines[line_index].dup
549
+ line[start_col...end_col] = mutated_operator
550
+ lines[line_index] = line
551
+ mutated_code = lines.join
552
+
553
+ [{
554
+ id: next_mutation_id,
555
+ type: :logical,
556
+ line: node.loc.line,
557
+ original: original_operator,
558
+ mutated: mutated_operator,
559
+ code: mutated_code,
560
+ source_line: extract_source_line(node.loc.line),
561
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
562
+ description: "Change #{original_operator} to #{mutated_operator}"
563
+ }]
564
+ rescue => e
565
+ warn_skipped(:logical, node, e)
566
+ []
567
+ end
568
+
569
+ def build_logical_mutation(node, target_type)
570
+ mutated_code, original_operator, mutated_operator = replace_logical_node(node, target_type)
571
+ return [] if mutated_code.nil?
572
+
573
+ [{
574
+ id: next_mutation_id,
575
+ type: :logical,
576
+ line: node.loc.line,
577
+ original: original_operator,
578
+ mutated: mutated_operator,
579
+ code: mutated_code,
580
+ source_line: extract_source_line(node.loc.line),
581
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
582
+ description: "Change #{original_operator} to #{mutated_operator}"
583
+ }]
584
+ rescue => e
585
+ warn_skipped(:logical, node, e)
586
+ []
587
+ end
588
+
589
+ def mutate_boolean_node(node)
590
+ mutations = []
591
+ value = node.type == :true
592
+ opposite = value ? :false : :true
593
+
594
+ mutated_code = replace_node(node, Parser::AST::Node.new(opposite))
595
+ mutations << {
596
+ id: next_mutation_id,
597
+ type: :boolean,
598
+ line: node.loc.line,
599
+ original: value.to_s,
600
+ mutated: (!value).to_s,
601
+ code: mutated_code,
602
+ source_line: extract_source_line(node.loc.line),
603
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
604
+ description: "Change #{value} to #{!value}"
605
+ }
606
+
607
+ mutations
608
+ rescue => e
609
+ warn_skipped(:boolean, node, e)
610
+ []
611
+ end
612
+
613
+ def mutate_conditional_node(node)
614
+ mutations = []
615
+ condition = node.children[0]
616
+
617
+ if condition
618
+ negated = negate_condition(condition)
619
+ mutated_code = replace_node(condition, negated)
620
+ mutations << {
621
+ id: next_mutation_id,
622
+ type: :conditional,
623
+ line: node.loc.line,
624
+ original: safe_unparse(condition),
625
+ mutated: safe_unparse(negated),
626
+ code: mutated_code,
627
+ source_line: extract_source_line(node.loc.line),
628
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
629
+ description: 'Negate conditional expression'
630
+ }
631
+ end
632
+
633
+ mutations
634
+ rescue => e
635
+ warn_skipped(:conditional, node, e)
636
+ []
637
+ end
638
+
639
+ def mutate_case_node(node)
640
+ mutations = []
641
+ children = node.children
642
+
643
+ (1...(children.size - 1)).each do |position|
644
+ when_node = children[position]
645
+ next unless when_node.is_a?(Parser::AST::Node) && when_node.type == :when
646
+
647
+ remaining = children[0...position] + children[(position + 1)..-1]
648
+ new_case = Parser::AST::Node.new(:case, remaining)
649
+ mutated_code = replace_node(node, new_case)
650
+
651
+ next if mutated_code == @original_content
652
+ next unless parses_cleanly?(mutated_code)
653
+
654
+ mutations << {
655
+ id: next_mutation_id,
656
+ type: :conditional,
657
+ line: when_node.loc.line,
658
+ original: extract_source_line(when_node.loc.line),
659
+ mutated: 'removed',
660
+ code: mutated_code,
661
+ source_line: extract_source_line(when_node.loc.line),
662
+ mutated_line: '(when branch removed)',
663
+ description: 'Remove when branch'
664
+ }
665
+ end
666
+
667
+ mutations
668
+ rescue => e
669
+ warn_skipped(:conditional, node, e)
670
+ []
671
+ end
672
+
673
+ def parses_cleanly?(code)
674
+ buffer = Parser::Source::Buffer.new('(mutant-candidate)')
675
+ buffer.source = code
676
+ parser = Parser::CurrentRuby.new
677
+ parser.diagnostics.all_errors_are_fatal = true
678
+ parser.diagnostics.ignore_warnings = true
679
+ parser.parse(buffer)
680
+ true
681
+ rescue Parser::SyntaxError
682
+ false
683
+ end
684
+
685
+ def mutate_number_node(node)
686
+ mutations = []
687
+ value = node.children[0]
688
+
689
+ mutations << create_number_mutation(node, 0, "Change #{value} to 0") if value != 0
690
+
691
+ mutations << create_number_mutation(node, 1, "Change #{value} to 1") if value != 1
692
+
693
+ mutations << create_number_mutation(node, value + 1, "Increment #{value} to #{value + 1}")
694
+
695
+ mutations << create_number_mutation(node, value - 1, "Decrement #{value} to #{value - 1}")
696
+
697
+ mutations
698
+ rescue => e
699
+ warn_skipped(:number, node, e)
700
+ []
701
+ end
702
+
703
+ def mutate_string_node(node)
704
+ mutations = []
705
+
706
+ empty_node = Parser::AST::Node.new(:str, [''])
707
+
708
+ original_repr = node.type == :str ? "'#{node.children[0]}'" : '"..."'
709
+
710
+ mutated_code = replace_node(node, empty_node)
711
+ mutations << {
712
+ id: next_mutation_id,
713
+ type: :string,
714
+ line: node.loc.line,
715
+ original: original_repr,
716
+ mutated: "''",
717
+ code: mutated_code,
718
+ source_line: extract_source_line(node.loc.line),
719
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
720
+ description: 'Change string to empty string'
721
+ }
722
+
723
+ mutations
724
+ rescue => e
725
+ warn_skipped(:string, node, e)
726
+ []
727
+ end
728
+
729
+ def create_mutation(node, replacement, type)
730
+ new_node = Parser::AST::Node.new(
731
+ :send,
732
+ [
733
+ node.children[0],
734
+ replacement,
735
+ *node.children[2..-1]
736
+ ]
737
+ )
738
+
739
+ mutated_code = replace_node(node, new_node)
740
+ {
741
+ id: next_mutation_id,
742
+ type: type,
743
+ line: node.loc.line,
744
+ original: node.children[1].to_s,
745
+ mutated: replacement.to_s,
746
+ code: mutated_code,
747
+ source_line: extract_source_line(node.loc.line),
748
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
749
+ description: "Change #{node.children[1]} to #{replacement}"
750
+ }
751
+ end
752
+
753
+ def create_op_asgn_mutation(node, operator, replacement)
754
+ new_node = Parser::AST::Node.new(
755
+ :op_asgn,
756
+ [node.children[0], replacement, *node.children[2..-1]]
757
+ )
758
+
759
+ mutated_code = replace_node(node, new_node)
760
+ original_operator = "#{operator}="
761
+ mutated_operator = "#{replacement}="
762
+ {
763
+ id: next_mutation_id,
764
+ type: :arithmetic,
765
+ line: node.loc.line,
766
+ original: original_operator,
767
+ mutated: mutated_operator,
768
+ code: mutated_code,
769
+ source_line: extract_source_line(node.loc.line),
770
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
771
+ description: "Change #{original_operator} to #{mutated_operator}"
772
+ }
773
+ end
774
+
775
+ def create_number_mutation(node, new_value, description)
776
+ new_node = Parser::AST::Node.new(node.type, [new_value])
777
+ mutated_code = replace_node(node, new_node)
778
+ {
779
+ id: next_mutation_id,
780
+ type: :number,
781
+ line: node.loc.line,
782
+ original: node.children[0].to_s,
783
+ mutated: new_value.to_s,
784
+ code: mutated_code,
785
+ source_line: extract_source_line(node.loc.line),
786
+ mutated_line: extract_mutated_line(mutated_code, node.loc.line),
787
+ description: description
788
+ }
789
+ end
790
+
791
+ def replace_node(old_node, new_node)
792
+ return @original_content unless old_node.loc
793
+
794
+ lines = @original_content.lines
795
+ start_line = old_node.loc.line
796
+ end_line = old_node.loc.last_line
797
+
798
+ return @original_content if start_line > lines.size || end_line > lines.size
799
+
800
+ start_index = start_line - 1
801
+ end_index = end_line - 1
802
+
803
+ new_code = safe_unparse(new_node)
804
+
805
+ if start_line == end_line
806
+ line = lines[start_index].dup
807
+ start_col = old_node.loc.column
808
+ end_col = old_node.loc.last_column
809
+ line[start_col...end_col] = new_code
810
+ lines[start_index] = line
811
+ else
812
+ prefix = lines[start_index][0...old_node.loc.column]
813
+ suffix = lines[end_index][old_node.loc.last_column..-1]
814
+
815
+ combined = prefix + new_code + suffix
816
+ lines[start_index..end_index] = combined
817
+ end
818
+
819
+ lines.join
820
+ end
821
+
822
+ def replace_logical_node(old_node, target_type)
823
+ operator_loc = old_node.loc&.operator
824
+ return nil unless operator_loc
825
+
826
+ lines = @original_content.lines
827
+ line_index = operator_loc.line - 1
828
+ return nil if line_index >= lines.size
829
+
830
+ start_col = operator_loc.column
831
+ end_col = operator_loc.last_column
832
+ original_operator = lines[line_index][start_col...end_col]
833
+
834
+ keyword = %w[and or].include?(original_operator)
835
+ mutated_operator =
836
+ if target_type == :and
837
+ keyword ? 'and' : '&&'
838
+ else
839
+ keyword ? 'or' : '||'
840
+ end
841
+
842
+ line = lines[line_index].dup
843
+ line[start_col...end_col] = mutated_operator
844
+ lines[line_index] = line
845
+
846
+ [lines.join, original_operator, mutated_operator]
847
+ end
848
+
849
+ def negate_condition(node)
850
+ wrapped = Parser::AST::Node.new(:begin, [node])
851
+ Parser::AST::Node.new(:send, [wrapped, :!])
852
+ end
853
+
854
+ def safe_unparse(node)
855
+ Unparser.unparse(node)
856
+ end
857
+
858
+ def next_mutation_id
859
+ @mutation_id += 1
860
+ end
861
+
862
+ def extract_source_line(line_number)
863
+ lines = @original_content.lines
864
+ return '' if line_number > lines.size || line_number < 1
865
+ lines[line_number - 1].strip
866
+ end
867
+
868
+ def extract_mutated_line(mutated_code, line_number)
869
+ lines = mutated_code.lines
870
+ return '' if line_number > lines.size || line_number < 1
871
+ lines[line_number - 1].strip
872
+ end
873
+ end
874
+ end