saboteur 0.7.0 → 0.8.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +657 -0
  3. data/README.md +198 -1077
  4. data/lib/saboteur/cli/exit_status.rb +32 -1
  5. data/lib/saboteur/cli/help_options.rb +2 -1
  6. data/lib/saboteur/cli/history_command.rb +135 -0
  7. data/lib/saboteur/cli/presets.rb +6 -2
  8. data/lib/saboteur/cli/selection_options.rb +31 -0
  9. data/lib/saboteur/cli/shard_options.rb +17 -0
  10. data/lib/saboteur/cli/source_set.rb +53 -0
  11. data/lib/saboteur/cli/survivors_command.rb +157 -0
  12. data/lib/saboteur/cli.rb +71 -54
  13. data/lib/saboteur/config.rb +98 -5
  14. data/lib/saboteur/config_file.rb +8 -0
  15. data/lib/saboteur/constant_reads.rb +182 -0
  16. data/lib/saboteur/constant_subject.rb +70 -0
  17. data/lib/saboteur/constant_subject_finder.rb +281 -0
  18. data/lib/saboteur/coverage_reads.rb +29 -0
  19. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  20. data/lib/saboteur/doctor.rb +9 -0
  21. data/lib/saboteur/engine/baseline_guard.rb +62 -14
  22. data/lib/saboteur/engine/boot.rb +4 -0
  23. data/lib/saboteur/engine/candidate_selection.rb +19 -0
  24. data/lib/saboteur/engine/candidates.rb +43 -3
  25. data/lib/saboteur/engine/coverage_store.rb +32 -10
  26. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  27. data/lib/saboteur/engine/diagnostics.rb +18 -0
  28. data/lib/saboteur/engine/distribution.rb +10 -0
  29. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  30. data/lib/saboteur/engine/gated_run.rb +35 -0
  31. data/lib/saboteur/engine/load_coverage.rb +45 -0
  32. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  33. data/lib/saboteur/engine/pipelines.rb +107 -0
  34. data/lib/saboteur/engine/result_cache.rb +65 -8
  35. data/lib/saboteur/engine/run_budget.rb +27 -0
  36. data/lib/saboteur/engine/sandbox_policy.rb +38 -0
  37. data/lib/saboteur/engine/scheduler.rb +109 -3
  38. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  39. data/lib/saboteur/engine/shard.rb +69 -0
  40. data/lib/saboteur/engine/subject_selection.rb +295 -20
  41. data/lib/saboteur/engine/test_guess.rb +315 -25
  42. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  43. data/lib/saboteur/engine.rb +100 -127
  44. data/lib/saboteur/execution/external_runner.rb +471 -0
  45. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  46. data/lib/saboteur/execution/peak_memory.rb +28 -0
  47. data/lib/saboteur/execution/process_table.rb +12 -0
  48. data/lib/saboteur/execution/process_tree.rb +65 -5
  49. data/lib/saboteur/execution/reevaluator.rb +68 -12
  50. data/lib/saboteur/execution/runner.rb +46 -7
  51. data/lib/saboteur/execution/sandbox.rb +15 -2
  52. data/lib/saboteur/execution/seatbelt.rb +226 -0
  53. data/lib/saboteur/execution/supervisor.rb +27 -10
  54. data/lib/saboteur/execution/worker_pool.rb +23 -1
  55. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  56. data/lib/saboteur/gates.rb +43 -2
  57. data/lib/saboteur/help.rb +60 -9
  58. data/lib/saboteur/ignore_list.rb +29 -6
  59. data/lib/saboteur/integration/rspec.rb +135 -5
  60. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  61. data/lib/saboteur/mutation_source/builtin.rb +26 -5
  62. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  63. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  64. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  65. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  66. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  67. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  68. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  69. data/lib/saboteur/mutation_source/hazard_guard.rb +108 -0
  70. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  71. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  72. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  73. data/lib/saboteur/mutation_source/operator_examples.rb +3 -1
  74. data/lib/saboteur/mutation_source/operator_registry.rb +4 -2
  75. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  76. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  77. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  79. data/lib/saboteur/preview.rb +120 -12
  80. data/lib/saboteur/refinement_activations.rb +60 -0
  81. data/lib/saboteur/reporter/agent.rb +9 -1
  82. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  83. data/lib/saboteur/reporter/agent_incomplete.rb +11 -0
  84. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  85. data/lib/saboteur/reporter/findings.rb +5 -2
  86. data/lib/saboteur/reporter/history_policy.rb +11 -0
  87. data/lib/saboteur/reporter/history_reader.rb +47 -0
  88. data/lib/saboteur/reporter/html.rb +26 -4
  89. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  90. data/lib/saboteur/reporter/stryker.rb +27 -3
  91. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  92. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  93. data/lib/saboteur/reporter/terminal.rb +40 -7
  94. data/lib/saboteur/result.rb +19 -3
  95. data/lib/saboteur/selection/coverage_map.rb +105 -10
  96. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  97. data/lib/saboteur/selection/external_test_map.rb +27 -0
  98. data/lib/saboteur/selection/git_diff.rb +93 -11
  99. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  100. data/lib/saboteur/selection/recorder.rb +22 -4
  101. data/lib/saboteur/selection/source_scope.rb +22 -0
  102. data/lib/saboteur/subject.rb +42 -7
  103. data/lib/saboteur/subject_finder.rb +173 -29
  104. data/lib/saboteur/version.rb +1 -1
  105. data/lib/saboteur/working_tree.rb +38 -15
  106. data/lib/saboteur.rb +13 -0
  107. metadata +35 -2
@@ -16,9 +16,29 @@ module Saboteur
16
16
 
17
17
  SWAPS = { min: "max", max: "min", any?: "all?", all?: "any?", map: "each", each: "map" }.freeze
18
18
 
19
+ # Node types that mix used and discarded child positions get a named
20
+ # handler; anything not listed here is conservative: every child's
21
+ # value is treated as used (#440).
22
+ CONTAINER_HANDLERS = {
23
+ Prism::StatementsNode => :visit_statements,
24
+ Prism::IfNode => :visit_if,
25
+ Prism::UnlessNode => :visit_unless,
26
+ Prism::ElseNode => :visit_else,
27
+ Prism::EnsureNode => :visit_ensure,
28
+ Prism::CaseNode => :visit_case,
29
+ Prism::CaseMatchNode => :visit_case,
30
+ Prism::WhenNode => :visit_when,
31
+ Prism::InNode => :visit_in,
32
+ Prism::BeginNode => :visit_begin,
33
+ Prism::RescueNode => :visit_rescue,
34
+ Prism::WhileNode => :visit_loop,
35
+ Prism::UntilNode => :visit_loop
36
+ }.freeze
37
+
19
38
  # Base inherits Object's empty initializer; there is no parent state to set.
20
39
  def initialize(arid: []) # rubocop:disable Lint/MissingSuper
21
40
  @arid = arid.map { |pattern| Regexp.new(pattern) }
41
+ @discard_depth = 0
22
42
  end
23
43
 
24
44
  def name
@@ -27,6 +47,133 @@ module Saboteur
27
47
 
28
48
  private
29
49
 
50
+ # Overrides OperatorVisit#visit to track positions whose value is
51
+ # syntactically discarded: a non-last statement, and - recursing from a
52
+ # discarded statement - the tail expression of each branch of an
53
+ # if/elsif/else/unless/case/when, a begin/rescue body, or a
54
+ # while/until body (a loop's value never observes its body), or an
55
+ # ensure body (its value never contributes to the result). each
56
+ # and map differ only in their return value (both run the block the
57
+ # same way), so swapping them in a discarded-value position yields
58
+ # an equivalent mutant (#399, #440). A call whose value flows into
59
+ # a predicate, receiver, block result, or control-flow argument
60
+ # (return/break/next) is used and stays swappable, as is the whole
61
+ # body of a def or block. A lazy receiver breaks the equivalence:
62
+ # lazy.map defers the block, so discarding it runs nothing (#431).
63
+ # Only a `.lazy` in the receiver chain is detected; a Lazy held in a
64
+ # variable, parameter or ivar is not, and its swaps are suppressed.
65
+ def visit(node, subject, found)
66
+ return if node.nil? || OperatorVisit::SCOPE_NODES.any? { |scope| node.is_a?(scope) }
67
+
68
+ handler = CONTAINER_HANDLERS[node.class]
69
+ handler ? send(handler, node, subject, found) : visit_used_children(node, subject, found)
70
+ end
71
+
72
+ # Non-last statements are discarded-value positions; the last
73
+ # statement inherits the surrounding context (#399).
74
+ def visit_statements(node, subject, found)
75
+ node.body.each_with_index do |statement, index|
76
+ next if arid?(statement)
77
+
78
+ if index < node.body.size - 1
79
+ discarded { visit(statement, subject, found) }
80
+ else
81
+ visit(statement, subject, found)
82
+ end
83
+ end
84
+ end
85
+
86
+ def visit_else(node, subject, found)
87
+ visit(node.statements, subject, found)
88
+ end
89
+
90
+ # An ensure body's value never contributes to the result, so it is
91
+ # always a discarded-value position (#440).
92
+ def visit_ensure(node, subject, found)
93
+ discarded { visit(node.statements, subject, found) }
94
+ end
95
+
96
+ def visit_in(node, subject, found)
97
+ used { visit(node.pattern, subject, found) }
98
+ visit(node.statements, subject, found)
99
+ end
100
+
101
+ def visit_if(node, subject, found)
102
+ visit_branch(node, subject, found, :subsequent)
103
+ end
104
+
105
+ def visit_unless(node, subject, found)
106
+ visit_branch(node, subject, found, :else_clause)
107
+ end
108
+
109
+ # Branch bodies inherit the discard context; the predicate's value
110
+ # selects the branch, so it is always used (#440).
111
+ def visit_branch(node, subject, found, else_reader)
112
+ used { visit(node.predicate, subject, found) }
113
+ visit(node.statements, subject, found)
114
+ visit(node.public_send(else_reader), subject, found)
115
+ end
116
+
117
+ def visit_case(node, subject, found)
118
+ used { visit(node.predicate, subject, found) }
119
+ node.conditions.each { |condition| visit(condition, subject, found) }
120
+ visit(node.else_clause, subject, found)
121
+ end
122
+
123
+ def visit_when(node, subject, found)
124
+ node.conditions.each { |condition| used { visit(condition, subject, found) } }
125
+ visit(node.statements, subject, found)
126
+ end
127
+
128
+ def visit_begin(node, subject, found)
129
+ visit(node.statements, subject, found)
130
+ visit(node.rescue_clause, subject, found)
131
+ visit(node.else_clause, subject, found)
132
+ discarded { visit(node.ensure_clause, subject, found) }
133
+ end
134
+
135
+ def visit_rescue(node, subject, found)
136
+ node.exceptions.each { |exception| used { visit(exception, subject, found) } }
137
+ visit(node.statements, subject, found)
138
+ visit(node.subsequent, subject, found)
139
+ end
140
+
141
+ # A loop's value never observes its body, so the body is always a
142
+ # discarded-value position (#440).
143
+ def visit_loop(node, subject, found)
144
+ used { visit(node.predicate, subject, found) }
145
+ discarded { visit(node.statements, subject, found) }
146
+ end
147
+
148
+ # Default: every child's value is consumed by its parent.
149
+ def visit_used_children(node, subject, found)
150
+ mutate_node(node, subject, found)
151
+ node.compact_child_nodes.each { |child| used { visit(child, subject, found) } }
152
+ end
153
+
154
+ # Marks value-discarded positions during the walk. `used` resets the
155
+ # depth where a child's value is consumed by its parent.
156
+ def discarded
157
+ @discard_depth += 1
158
+ yield
159
+ ensure
160
+ @discard_depth -= 1
161
+ end
162
+
163
+ def used
164
+ depth = @discard_depth
165
+ @discard_depth = 0
166
+ yield
167
+ ensure
168
+ @discard_depth = depth
169
+ end
170
+
171
+ # Any `.lazy` in the receiver chain, syntactically.
172
+ def lazy_receiver?(node)
173
+ node = node.receiver while node.is_a?(Prism::CallNode) && node.name != :lazy
174
+ node.is_a?(Prism::CallNode)
175
+ end
176
+
30
177
  def mutate_node(node, subject, found)
31
178
  swap(node, subject, found) if node.is_a?(Prism::CallNode)
32
179
  end
@@ -34,6 +181,7 @@ module Saboteur
34
181
  def swap(node, subject, found)
35
182
  replacement = SWAPS[node.name]
36
183
  return unless replacement && node.receiver
184
+ return if @discard_depth.positive? && %w[each map].include?(node.name.to_s) && !lazy_receiver?(node.receiver)
37
185
 
38
186
  location = node.message_loc
39
187
  edit = Edit.new(file: subject.file, start_byte: location.start_offset,
@@ -24,7 +24,7 @@ module Saboteur
24
24
 
25
25
  def mutations_for(subject)
26
26
  found = []
27
- visit(subject.node.body, subject, found)
27
+ visit(subject.body_node, subject, found)
28
28
  found
29
29
  end
30
30
 
@@ -24,6 +24,7 @@ module Saboteur
24
24
  "literal_boolean" => "true -> false",
25
25
  "argument_removal" => "send(a, b) -> send(a)",
26
26
  "pair_removal" => "{a: 1, b: 2} -> {b: 2}",
27
+ "element_removal" => "TAGS = [a, b] -> TAGS = [b]",
27
28
  "endpoint_replacement" => "xs.first -> xs.last",
28
29
  "chain_removal" => "x.first.to_s -> x.to_s",
29
30
  "range" => "1..3 -> 1...3",
@@ -31,7 +32,8 @@ module Saboteur
31
32
  "condition_negation" => "x ? a : b -> !(x) ? a : b",
32
33
  "method_swap" => "xs.min -> xs.max",
33
34
  "operand_replacement" => "a + b -> a",
34
- "interpolation_removal" => "\"hi \#{name}\" -> \"hi \""
35
+ "interpolation_removal" => "\"hi \#{name}\" -> \"hi \"",
36
+ "regex_replacement" => "/\\A\\d+\\z/ -> /\\d+\\z/"
35
37
  }.freeze
36
38
  end
37
39
  end
@@ -31,13 +31,15 @@ module Saboteur
31
31
  "literal_boolean" => "Swap a boolean literal",
32
32
  "argument_removal" => "Remove a call argument",
33
33
  "pair_removal" => "Remove a hash pair",
34
+ "element_removal" => "Remove an element of a constant array literal",
34
35
  "endpoint_replacement" => "Swap explicit no-argument first and last",
35
36
  "chain_removal" => "Remove one call from a method chain",
36
37
  "method_swap" => "Swap a method with its semantic opposite",
37
38
  "operand_replacement" => "Replace a binary operation with one operand",
38
- "interpolation_removal" => "Remove part of an interpolated string"
39
+ "interpolation_removal" => "Remove part of an interpolated string",
40
+ "regex_replacement" => "Drop a regex anchor or swap a regex quantifier"
39
41
  }.freeze
40
- POLICY_VERSION = 2
42
+ POLICY_VERSION = 3
41
43
  TIERS = { "all" => DESCRIPTIONS.keys.freeze }.freeze
42
44
 
43
45
  def self.list(out, names: [], excluded: [])
@@ -13,7 +13,7 @@ module Saboteur
13
13
 
14
14
  def mutations_for(subject)
15
15
  found = []
16
- visit(subject.node.body, subject, found)
16
+ visit(subject.body_node, subject, found)
17
17
  found
18
18
  end
19
19
 
@@ -3,11 +3,14 @@
3
3
 
4
4
  require "prism"
5
5
  require_relative "base"
6
+ require_relative "entry_edits"
6
7
 
7
8
  module Saboteur
8
9
  module MutationSource
9
10
  # Deletes one association from a hash literal or keyword argument list.
10
11
  class PairRemoval < Base
12
+ include EntryEdits
13
+
11
14
  BOUNDARIES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
12
15
  Prism::ParametersNode, Prism::BlockParametersNode].freeze
13
16
 
@@ -19,10 +22,12 @@ module Saboteur
19
22
  "pair_removal"
20
23
  end
21
24
 
22
- def mutations_for(subject)
25
+ # root: the node to walk instead of the subject's method body (a
26
+ # constant's literal, see MutationSource::ConstantLiterals).
27
+ def mutations_for(subject, root: nil)
23
28
  found = []
24
29
  tokens = Prism.lex(subject.file_source).value.map(&:first)
25
- visit(subject.node.body, subject, found, tokens)
30
+ visit(root || subject.body_node, subject, found, tokens)
26
31
  found
27
32
  end
28
33
 
@@ -56,24 +61,16 @@ module Saboteur
56
61
  next unless pair.is_a?(Prism::AssocNode)
57
62
  next if heredoc?(pair)
58
63
 
59
- edits = [delete(subject, pair.location)]
60
- separator = separator_for(node, pairs, index, tokens, call)
61
- edits << delete(subject, separator) if separator
64
+ edits = [delete_entry(subject, pair.location)]
65
+ # Removing the first keyword argument may need the comma after the
66
+ # call's last positional argument.
67
+ before = call.arguments.arguments[0...-1].last if call
68
+ separator = entry_separator(pairs, index, ending_for(node, call), tokens, before: before)
69
+ edits << delete_entry(subject, separator) if separator
62
70
  found << Mutation.new(subject_id: subject.id, operator: name, edits: edits, source: name)
63
71
  end
64
72
  end
65
73
 
66
- def separator_for(node, pairs, index, tokens, call)
67
- pair = pairs[index]
68
- ending = ending_for(node, call)
69
- after = comma_between(pair.location.end_offset, ending, tokens)
70
- return after if after
71
-
72
- previous = pairs[0...index].last
73
- previous ||= call.arguments.arguments[0...-1].last if call
74
- comma_between(previous.location.end_offset, pair.location.start_offset, tokens) if previous
75
- end
76
-
77
74
  def ending_for(node, call)
78
75
  return node.closing_loc.start_offset if node.is_a?(Prism::HashNode)
79
76
  return call.closing_loc.start_offset if call.closing_loc
@@ -81,21 +78,6 @@ module Saboteur
81
78
 
82
79
  node.location.end_offset
83
80
  end
84
-
85
- def comma_between(first, last, tokens)
86
- tokens.find do |token|
87
- token.type == :COMMA && token.location.start_offset >= first && token.location.end_offset <= last
88
- end&.location
89
- end
90
-
91
- def heredoc?(node)
92
- (node.respond_to?(:opening_loc) && node.opening_loc&.slice&.start_with?("<<")) ||
93
- node.compact_child_nodes.any? { |child| heredoc?(child) }
94
- end
95
-
96
- def delete(subject, location)
97
- Edit.new(file: subject.file, start_byte: location.start_offset, end_byte: location.end_offset, replacement: "")
98
- end
99
81
  end
100
82
  end
101
83
  end
@@ -19,7 +19,7 @@ module Saboteur
19
19
 
20
20
  def mutations_for(subject)
21
21
  found = []
22
- visit(subject.node.body, subject, found)
22
+ visit(subject.body_node, subject, found)
23
23
  found
24
24
  end
25
25
 
@@ -0,0 +1,93 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "base"
6
+ require_relative "operator_visit"
7
+
8
+ module Saboteur
9
+ module MutationSource
10
+ # Weakens a regex literal: drops one anchor (\A, \z, \Z, ^, $) at a time
11
+ # and swaps each + or * quantifier for the other (#135). Escaped
12
+ # characters, character classes, and lazy or possessive stacks stay
13
+ # untouched, and interpolated regexes stay out: their bytes are not a
14
+ # stable 1:1 map. Anchors make validations decisive, so a dropped anchor
15
+ # or a widened quantifier is exactly the mutant a weak test misses.
16
+ class RegexReplacement < Base
17
+ include OperatorVisit
18
+
19
+ # Base inherits Object's empty initializer; there is no parent state to set.
20
+ def initialize(arid: []) # rubocop:disable Lint/MissingSuper
21
+ @arid = arid.map { |pattern| Regexp.new(pattern) }
22
+ end
23
+
24
+ def name
25
+ "regex_replacement"
26
+ end
27
+
28
+ BACKSLASH = 0x5C
29
+ ANCHOR_ESCAPES = %w[\\A \\z \\Z].freeze
30
+
31
+ private
32
+
33
+ def mutate_node(node, subject, found)
34
+ return unless node.is_a?(Prism::RegularExpressionNode)
35
+
36
+ base = node.content_loc.start_offset
37
+ scan_anchors_and_quantifiers(node.content) do |offset, length, replacement|
38
+ edit = Edit.new(file: subject.file, start_byte: base + offset, end_byte: base + offset + length,
39
+ replacement: replacement)
40
+ found << Mutation.new(subject_id: subject.id, operator: name, edits: [edit], source: name)
41
+ end
42
+ end
43
+
44
+ # Yields [offset, length, replacement] for each anchor drop and
45
+ # quantifier swap, scanning bytes so offsets line up with the file.
46
+ def scan_anchors_and_quantifiers(pattern)
47
+ bytes = pattern.bytes
48
+ depth = 0
49
+ index = 0
50
+ while index < bytes.size
51
+ if bytes[index] == BACKSLASH
52
+ yield index, 2, "" if depth.zero? && ANCHOR_ESCAPES.include?(pattern.byteslice(index, 2))
53
+ index += 2
54
+ else
55
+ depth = scan_plain_byte(bytes, index, depth) { |offset, replacement| yield offset, 1, replacement }
56
+ index += 1
57
+ end
58
+ end
59
+ end
60
+
61
+ # One non-escape byte; returns the character-class depth after it.
62
+ def scan_plain_byte(bytes, index, depth)
63
+ case bytes[index]
64
+ # [ opens a class only at top level or as a POSIX [:class:];
65
+ # inside a class it is a literal member.
66
+ when 0x5B then depth.zero? || bytes[index + 1] == 0x3A ? depth + 1 : depth
67
+ when 0x5D then depth.positive? ? depth - 1 : 0 # ]
68
+ when 0x5E, 0x24 # ^ $
69
+ yield index, "" if depth.zero?
70
+ depth
71
+ when 0x2B, 0x2A # + *
72
+ yield index, (bytes[index] == 0x2B ? "*" : "+") if depth.zero? && plain_quantifier?(bytes, index)
73
+ depth
74
+ else
75
+ depth
76
+ end
77
+ end
78
+
79
+ # A bare quantifier: not the start of the pattern, not after an open
80
+ # group or alternation, and not stacked into a lazy (+?, ??) or
81
+ # possessive (*+, ++) form on either side.
82
+ def plain_quantifier?(bytes, index)
83
+ return false if index.zero?
84
+
85
+ previous = bytes[index - 1]
86
+ return false if [0x28, 0x7C, 0x3F, 0x2B, 0x2A].include?(previous) # ( | ? + *
87
+
88
+ nxt = bytes[index + 1]
89
+ nxt != 0x2B && nxt != 0x2A && nxt != 0x3F # + * ?
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,30 +1,40 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "json"
4
5
  require_relative "engine"
5
6
  require_relative "text"
7
+ require_relative "version"
6
8
 
7
9
  module Saboteur
8
10
  # Source-only planning. Never boots the host or executes its test suite.
9
11
  class Preview
10
- def initialize(config, out:, err:)
12
+ # Version of the --format json inventory (#469). Consumers ignore fields
13
+ # they do not know; only a breaking change raises this number.
14
+ SCHEMA_VERSION = 1
15
+
16
+ def initialize(config, out:, err:, format: "text")
11
17
  @config = config
12
18
  @out = out
13
19
  @diagnostics = Engine::Diagnostics.new(err)
20
+ @json = format == "json"
21
+ @inventory = []
22
+ @skipped = []
14
23
  end
15
24
 
16
25
  def call
17
26
  announce_operators
18
27
  selection = Engine::SubjectSelection.new(@config, @diagnostics)
19
28
  subjects = selection.select(@config.included_files)
20
- scope = selection.changed_lines && Engine::DiffScope.new(selection.changed_lines)
29
+ scope = selection.behavior_lines && Engine::DiffScope.new(selection.behavior_lines)
21
30
  guard = MutationSource::HazardGuard.new(@config["hazard"]) unless @config["allow_hazardous"]
22
31
  candidates = Engine::Candidates.new(Engine::Candidates.build_sources(@config), @diagnostics, hazard_guard: guard)
23
- lists = subjects.map { |subject| planned(subject, candidates, scope) }
32
+ lists = shard_lists(subjects, subjects.map { |subject| planned(subject, candidates, scope) })
24
33
  inline_suppressions
25
34
  incomplete = @diagnostics.discovery_failures.size
26
- @out.puts("Total: #{lists.sum(&:size)} mutants; #{@diagnostics.omissions.sum(&:count)} " \
27
- "dropped by max_mutants_per_method#{incomplete_note(incomplete)}")
35
+ return print_inventory(subjects, lists, incomplete) if @json
36
+
37
+ print_totals(lists, incomplete)
28
38
  incomplete.zero? ? estimate(subjects, lists) : @out.puts("no estimate: source discovery was incomplete")
29
39
  print_warnings
30
40
  nil
@@ -32,25 +42,37 @@ module Saboteur
32
42
 
33
43
  private
34
44
 
45
+ def shard_lists(subjects, lists)
46
+ return lists unless @config["shard"]
47
+
48
+ @shard = Engine::Shard.parse(@config["shard"])
49
+ @shard.select(subjects, lists)
50
+ end
51
+
52
+ def print_totals(lists, incomplete)
53
+ @out.puts(@shard.line) if @shard
54
+ @out.puts("Total: #{lists.sum(&:size)} mutants; #{@diagnostics.omissions.sum(&:count)} " \
55
+ "dropped by max_mutants_per_method#{incomplete_note(incomplete)}")
56
+ end
57
+
35
58
  def print_warnings
36
59
  @diagnostics.warnings.each { |warning| @out.puts("warning: #{warning}") }
37
60
  end
38
61
 
39
62
  def announce_operators
40
- @out.puts("Operators: #{@config.operator_policy['effective'].join(', ')}")
63
+ @out.puts("Operators: #{@config.operator_policy['effective'].join(', ')}") unless @json
41
64
  end
42
65
 
43
66
  # Candidate filtering precedes diff restriction and the per-method cap.
44
67
  # Name that scope rather than implying these rows were executed or scored.
45
68
  def inline_suppressions
46
69
  entries = @diagnostics.inline_suppressions
47
- return if entries.empty?
70
+ return if entries.empty? || @json
48
71
 
49
72
  @out.puts("Inline suppressions: #{entries.size} candidates in selected methods " \
50
73
  "(before diff restriction and per-method cap; outside the planned total)")
51
74
  entries.each do |entry|
52
- path = entry[:file].delete_prefix("#{@config.root}/")
53
- @out.puts(" #{path}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
75
+ @out.puts(" #{relative_path(entry[:file])}:#{entry[:line]} #{entry[:subject]} #{entry[:operator]} " \
54
76
  "#{entry[:digest]}: #{entry[:reason]}")
55
77
  end
56
78
  end
@@ -106,11 +128,97 @@ module Saboteur
106
128
  list = candidates.for(subject)
107
129
  list = scope.restrict(subject, list) if scope
108
130
  limit = @config["max_mutants_per_method"]
109
- @diagnostics.record_omission(subject, count: list.size - limit, limit: limit) if list.size > limit
131
+ if list.size > limit
132
+ @diagnostics.record_omission(subject, count: list.size - limit, limit: limit)
133
+ list.drop(limit).each { |(mutation, _)| record_skip(subject, mutation, "max_mutants_per_method") } if @json
134
+ end
110
135
  list = list.first(limit)
111
- path = subject.file.delete_prefix("#{@config.root}/")
112
- @out.puts("#{path}:#{subject.start_line} #{subject.id} #{list.size} mutants")
136
+ if @json
137
+ list.each { |(mutation, _)| @inventory << mutant_entry(subject, mutation) }
138
+ else
139
+ @out.puts("#{relative_path(subject.file)}:#{subject.start_line} #{subject.id} #{list.size} mutants")
140
+ end
113
141
  list
114
142
  end
143
+
144
+ # The machine-readable inventory (#469): every planned mutant named by
145
+ # the stable digest a run or --id replay computes, plus the candidates
146
+ # that were planned but not runnable, each with its skip reason.
147
+ def print_inventory(subjects, lists, incomplete)
148
+ @out.puts(JSON.pretty_generate(
149
+ schema_version: SCHEMA_VERSION,
150
+ saboteur_version: VERSION,
151
+ mode: "dry-run",
152
+ operators: @config.operator_policy["effective"],
153
+ test_selection: @config["test_selection"],
154
+ tests: @config["tests"],
155
+ subjects: subjects.zip(lists).map { |subject, list| subject_entry(subject, list) },
156
+ mutants: @inventory,
157
+ skipped: @skipped + suppression_entries,
158
+ totals: {
159
+ mutants: lists.sum(&:size),
160
+ dropped_by_max_mutants_per_method: @diagnostics.omissions.sum(&:count),
161
+ incomplete_files: incomplete
162
+ },
163
+ estimated_seconds: incomplete.zero? ? estimated_seconds(subjects, lists) : nil,
164
+ warnings: @diagnostics.warnings
165
+ ))
166
+ nil
167
+ end
168
+
169
+ def estimated_seconds(subjects, lists)
170
+ map = cached_map
171
+ seconds = map && work_seconds(map, subjects, lists)
172
+ seconds&.round(2)
173
+ end
174
+
175
+ def subject_entry(subject, list)
176
+ { id: subject.id, file: relative_path(subject.file), start_line: subject.start_line,
177
+ end_line: subject.end_line, mutants: list.size }
178
+ end
179
+
180
+ # Byte offsets index the file as read from disk, like the Stryker
181
+ # report; original and replacement text make each edit self-contained.
182
+ def mutant_entry(subject, mutation)
183
+ {
184
+ digest: mutation.digest(subject_start: subject.start_byte, original_source: subject.file_source),
185
+ subject: subject.id,
186
+ operator: mutation.operator.to_s,
187
+ file: relative_path(subject.file),
188
+ line: subject.line_of(mutation.edits.first.start_byte),
189
+ edits: mutation.edits.map do |edit|
190
+ { start_byte: edit.start_byte, end_byte: edit.end_byte,
191
+ original: source_span(subject, edit), replacement: edit.replacement }
192
+ end
193
+ }
194
+ end
195
+
196
+ def record_skip(subject, mutation, reason)
197
+ @skipped << mutant_entry(subject, mutation).merge(reason: reason)
198
+ end
199
+
200
+ # Inline-suppressed and hazard-refused candidates with their reasons,
201
+ # recorded during candidate filtering, before diff restriction and the
202
+ # per-method cap (the same scope the text audit names).
203
+ def suppression_entries
204
+ inline = @diagnostics.inline_suppressions.map do |entry|
205
+ { digest: entry[:digest], subject: entry[:subject], operator: entry[:operator].to_s,
206
+ file: relative_path(entry[:file]), line: entry[:line], reason: "inline_suppression: #{entry[:reason]}" }
207
+ end
208
+ hazards = @diagnostics.generation_events.select { |event| event.source == "hazard_guard" }.map do |event|
209
+ { subject: event.subject, operator: event.operator.to_s, file: relative_path(event.file),
210
+ reason: "hazardous" }
211
+ end
212
+ inline + hazards
213
+ end
214
+
215
+ def relative_path(file)
216
+ file.delete_prefix("#{@config.root}/")
217
+ end
218
+
219
+ def source_span(subject, edit)
220
+ subject.file_source.byteslice(edit.start_byte, edit.end_byte - edit.start_byte).to_s
221
+ .force_encoding(Encoding::UTF_8)
222
+ end
115
223
  end
116
224
  end
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # The lexical `using` sites of one parsed file (#634). A bare `using`
6
+ # activates its refinement from where it sits to the end of its own
7
+ # scope: a class or module body, or the whole file at the top level.
8
+ # Activation is lexical, not namespaced: a `class ::Q` nested inside
9
+ # the body still sees it, while a reopened body with the same constant
10
+ # path after the scope's `end` does not. Sites therefore compare byte
11
+ # ranges, not constant paths: a site covers a subject that starts after
12
+ # the `using` and before the scope's closing `end`. A `using` inside a
13
+ # method or block body cannot be scoped this way, so one anywhere keeps
14
+ # the old whole-file skip. Skipping only ever means "not mutated",
15
+ # never a wrong verdict.
16
+ class RefinementActivations
17
+ Site = Data.define(:start_byte, :scope_end, :dynamic_scope) do
18
+ def covers?(subject)
19
+ !dynamic_scope && subject.start_byte > start_byte && subject.start_byte < scope_end
20
+ end
21
+ end
22
+
23
+ def self.mark(subjects, program)
24
+ sites = []
25
+ collect(program, program.end_offset, false, sites)
26
+ return if sites.empty?
27
+
28
+ if sites.any?(&:dynamic_scope)
29
+ subjects.each { |subject| subject.skip_reason = "refinements" }
30
+ return
31
+ end
32
+
33
+ subjects.each do |subject|
34
+ subject.skip_reason = "refinements" if sites.any? { |site| site.covers?(subject) }
35
+ end
36
+ end
37
+
38
+ def self.collect(node, scope_end, dynamic, sites)
39
+ if node.is_a?(Prism::CallNode) && node.name == :using && node.receiver.nil?
40
+ sites << Site.new(start_byte: node.location.start_offset, scope_end: scope_end, dynamic_scope: dynamic)
41
+ end
42
+
43
+ descend(node, scope_end, dynamic, sites)
44
+ end
45
+
46
+ def self.descend(node, scope_end, dynamic, sites)
47
+ case node
48
+ when Prism::ClassNode
49
+ collect(node.superclass, scope_end, dynamic, sites) if node.superclass
50
+ collect(node.body, node.end_offset, dynamic, sites) if node.body
51
+ when Prism::ModuleNode, Prism::SingletonClassNode
52
+ collect(node.body, node.end_offset, dynamic, sites) if node.body
53
+ when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode
54
+ node.compact_child_nodes.each { |child| collect(child, scope_end, true, sites) }
55
+ else
56
+ node.compact_child_nodes.each { |child| collect(child, scope_end, dynamic, sites) }
57
+ end
58
+ end
59
+ end
60
+ end