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
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "subject"
5
+
6
+ module Saboteur
7
+ # A constant assigned a hash or array literal, as a mutation subject
8
+ # (issue #306): `RULES = { ... }` or `TAGS = [ ... ]`, with an optional
9
+ # trailing `.freeze`. Only --since selection creates these (see
10
+ # Engine::SubjectSelection), and the diff scope narrows their mutants to
11
+ # the entries the diff changed.
12
+ #
13
+ # A constant is not a method, so the surgical def re-evaluation does not
14
+ # apply: mutants run by re-evaluating the whole file with the mutated
15
+ # assignment spliced in (Execution::Reevaluator#eval_whole_file, which
16
+ # silences the "already initialized constant" warning). Re-evaluation
17
+ # replaces the constant's object; code that reads the constant at test
18
+ # time sees the mutant, while anything that copied the old object at load
19
+ # time keeps it. consumer_lines names the reads a test can cover; the
20
+ # coverage map turns them into the mutant's tests.
21
+ class ConstantSubject < Subject
22
+ attr_reader :literal_node
23
+ # { "/abs/file.rb" => [lines reading the constant] } across the include
24
+ # set, filled in after selection (the finder sees one file at a time).
25
+ attr_accessor :consumer_lines
26
+ # { "/abs/file.rb" => [def start lines] }: the def each runtime read
27
+ # sits in, for the method-call counter fallback (endless defs have no
28
+ # line coverage).
29
+ attr_accessor :consumer_defs
30
+
31
+ def initialize(name:, owner:, file:, node:, literal_node:, file_source:, nesting:, frozen_literals:,
32
+ consumer_lines: {}, consumer_defs: {})
33
+ super(name:, owner:, singleton: false, sclass: false, file:, node:, file_source:, nesting:,
34
+ frozen_literals:, sig_span: nil)
35
+ @literal_node = literal_node
36
+ @consumer_lines = consumer_lines
37
+ @consumer_defs = consumer_defs
38
+ end
39
+
40
+ # "Shop::RULES", or "RULES" at the top level. The :: separator keeps a
41
+ # constant's id apart from the Klass#method and Klass.method spellings.
42
+ def id
43
+ owner ? "#{owner}::#{name}" : name.to_s
44
+ end
45
+
46
+ # There is no method body to redefine: constants always take the
47
+ # whole-file evaluation path.
48
+ def whole_file_only?
49
+ true
50
+ end
51
+
52
+ # The assignment runs while the file loads, so its lines carry the load
53
+ # evidence; the reads of the constant in this file are the lines tests
54
+ # can cover.
55
+ def coverage_lines
56
+ ((start_line..end_line).to_a + Array(consumer_lines[file])).uniq
57
+ end
58
+
59
+ # Every edit sits inside the literal and is observable wherever the
60
+ # constant is read, so the assignment's own lines are the evidence that
61
+ # the edit ran (they execute at load, never inside a test).
62
+ def coverage_lines_for(_edit)
63
+ (start_line..end_line).to_a
64
+ end
65
+
66
+ def recovers?
67
+ false
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,281 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "subject"
6
+ require_relative "constant_subject"
7
+
8
+ module Saboteur
9
+ # Finds constant assignments of hash and array literals - `RULES = { ... }`,
10
+ # `TAGS = [ ... ]`, with a trailing `.freeze` or a similar argument-free,
11
+ # block-free call - as mutation subjects for --since runs (issue #306).
12
+ #
13
+ # Unsupported forms are reported with a reason, never silently skipped: a
14
+ # namespaced target (`A::RULES = ...`), a conditional write
15
+ # (`RULES ||= ...`), and a non-literal value (a method call with
16
+ # arguments, another constant). Class factories (`Point = Data.define(...)`)
17
+ # are scopes, not data: they are reported neither way, and a constant
18
+ # inside the factory's block binds on the enclosing lexical scope, never
19
+ # on the class the factory builds (#352, mirroring SubjectFinder).
20
+ # Constants inside any other block are found but marked skipped, like
21
+ # SubjectFinder's defs, so the report names them.
22
+ class ConstantSubjectFinder
23
+ Parsed = Data.define(:subjects, :unsupported, :errors)
24
+
25
+ # A constant assignment saboteur cannot mutate, with the reason why.
26
+ Unsupported = Data.define(:name, :owner, :file, :node, :reason) do
27
+ def id
28
+ owner ? "#{owner}::#{name}" : name.to_s
29
+ end
30
+
31
+ def overlaps_lines?(lines)
32
+ first = node.location.start_line
33
+ last = node.location.end_line
34
+ if lines.is_a?(Range)
35
+ lines.begin <= last && first <= lines.end
36
+ else
37
+ lines.any? { |line| line.between?(first, last) }
38
+ end
39
+ end
40
+ end
41
+
42
+ CLASS_FACTORIES = SubjectFinder::CLASS_FACTORIES
43
+
44
+ # source: the text to parse when it is not the file on disk (the file as
45
+ # it was at an earlier commit).
46
+ def call(file, source: File.binread(file))
47
+ result = Prism.parse(source)
48
+ return Parsed.new(subjects: [], unsupported: [], errors: result.errors.map(&:message)) if result.failure?
49
+
50
+ ctx = { file: file, source: source, frozen: frozen_literals?(result) }
51
+ out = { subjects: [], unsupported: [], ambiguous: [] }
52
+ walk(result.value, [], out, **ctx)
53
+ reject_duplicates(out, **ctx)
54
+ demote_ambiguous(out, **ctx)
55
+ Parsed.new(subjects: out[:subjects], unsupported: out[:unsupported], errors: [])
56
+ end
57
+
58
+ private
59
+
60
+ # More than one write of the same constant in one file: re-evaluating
61
+ # the file runs them all, the later one always wins, and a mutant of
62
+ # any earlier literal is never observed. EVERY write counts - a guarded
63
+ # or non-literal second write (`RULES = {a: 1}; RULES = build_rules`)
64
+ # overwrites the first just the same (#319).
65
+ def reject_duplicates(out, **ctx)
66
+ writes = Hash.new(0)
67
+ (out[:subjects] + out[:unsupported]).each { |entry| writes[entry.id] += 1 }
68
+ duplicates = out[:subjects].select { |subject| writes[subject.id] > 1 }
69
+ return if duplicates.empty?
70
+
71
+ out[:subjects] -= duplicates
72
+ duplicates.each do |subject|
73
+ out[:unsupported] << Unsupported.new(name: subject.name, owner: subject.owner, file: ctx[:file],
74
+ node: subject.node,
75
+ reason: "assigned more than once in this file")
76
+ end
77
+ end
78
+
79
+ def frozen_literals?(result)
80
+ result.magic_comments.any? do |comment|
81
+ comment.key.tr("-", "_").casecmp?("frozen_string_literal") && comment.value.casecmp?("true")
82
+ end
83
+ end
84
+
85
+ def walk(node, nesting, out, **ctx)
86
+ return if node.nil?
87
+
88
+ case node
89
+ when Prism::BlockNode, Prism::LambdaNode
90
+ walk(node.body, nesting, out, **ctx, in_block: true)
91
+ when Prism::ConstantWriteNode
92
+ constant_assignment(node, nesting, out, **ctx)
93
+ when Prism::IfNode, Prism::UnlessNode
94
+ walk_conditional(node, nesting, out, **ctx)
95
+ when Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
96
+ record_unsupported(out, node, nesting, "a conditional assignment (#{node.operator_loc.slice})", **ctx)
97
+ walk_children(node, nesting, out, **ctx)
98
+ when Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
99
+ record_unsupported(out, node, nesting, "a namespaced target (#{node.target.slice})", **ctx)
100
+ walk_children(node, nesting, out, **ctx)
101
+ # A keyword class or module re-enters the lexical scope: its constants
102
+ # are ordinary subjects even inside a block, and factory scopes never
103
+ # join their paths (#352).
104
+ when Prism::ModuleNode, Prism::ClassNode
105
+ kind = node.is_a?(Prism::ModuleNode) ? :module : :class
106
+ scope = Scope.new(kind: kind, path: node.constant_path.slice)
107
+ walk(node.body, lexical_nesting(nesting) + [scope], out, **ctx, in_block: false)
108
+ when Prism::SingletonClassNode
109
+ walk_singleton_class(node, nesting, out, **ctx)
110
+ else
111
+ walk_children(node, nesting, out, **ctx)
112
+ end
113
+ end
114
+
115
+ # A write under a condition may never run: mutating its literal cannot
116
+ # change the constant when the guard skips the write (#319).
117
+ def walk_conditional(node, nesting, out, **ctx)
118
+ walk(node.predicate, nesting, out, **ctx)
119
+ walk(node.statements, nesting, out, **ctx, guarded: true)
120
+ walk(node.respond_to?(:subsequent) ? node.subsequent : node.else_clause, nesting, out, **ctx,
121
+ guarded: true)
122
+ end
123
+
124
+ def walk_children(node, nesting, out, **ctx)
125
+ node.compact_child_nodes.each { |child| walk(child, nesting, out, **ctx) }
126
+ end
127
+
128
+ # `class << self` opens the attached class's singleton scope: constants
129
+ # assigned there bind on the singleton class, so the scope joins the
130
+ # nesting and owner_path renders it "<attached>.singleton_class" (#380,
131
+ # mirroring SubjectFinder). Unlike a keyword class, `class << self`
132
+ # does not reset in_block: a block's runtime self is unknowable at
133
+ # parse time (an `included do` block runs with the including class as
134
+ # self), so its constants stay conservatively skipped inside a block.
135
+ # A non-self receiver, a nested singleton class or the top level has no
136
+ # statically nameable singleton: the enclosing nesting stays.
137
+ def walk_singleton_class(node, nesting, out, **ctx)
138
+ unless node.expression.is_a?(Prism::SelfNode) && nesting.any? && !nesting.last.sclass?
139
+ return walk_children(node, nesting, out, **ctx)
140
+ end
141
+
142
+ walk(node.body, nesting + [Scope.new(kind: :sclass, path: "self")], out, **ctx)
143
+ end
144
+
145
+ def constant_assignment(node, nesting, out, **ctx)
146
+ return walk_factory(node, nesting, out, **ctx) if factory_scope?(node)
147
+
148
+ literal = literal_value(node.value)
149
+ reason = unsupported_reason(node, literal, **ctx)
150
+ if reason
151
+ record_unsupported(out, node, nesting, reason, **ctx)
152
+ walk_children(node, nesting, out, **ctx)
153
+ return
154
+ end
155
+
156
+ subject = ConstantSubject.new(name: node.name, owner: owner_path(nesting), file: ctx[:file], node: node,
157
+ literal_node: literal, file_source: ctx[:source], nesting: nesting,
158
+ frozen_literals: ctx[:frozen])
159
+ subject.skip_reason = "defined inside a block" if ctx[:in_block]
160
+ out[:subjects] << subject
161
+ # Another constant can hide in the literal's values (`MAP = { x: (Y = []) }`).
162
+ walk_children(node, nesting, out, **ctx)
163
+ end
164
+
165
+ # `Point = Data.define(:x) do ... end`: the receiver and arguments stay
166
+ # in the enclosing scope. The block's body is walked directly, not as a
167
+ # block, and the factory opens no lexical constant scope: a constant
168
+ # inside the block binds on the enclosing lexical scope and is an
169
+ # ordinary subject (#352, mirroring SubjectFinder).
170
+ def walk_factory(node, nesting, out, **ctx)
171
+ call = node.value
172
+ (call.compact_child_nodes - [call.block]).each { |child| walk(child, nesting, out, **ctx) }
173
+ walk(call.block.body, lexical_nesting(nesting) + [Scope.new(kind: :factory, path: node.name.to_s)], out, **ctx)
174
+ end
175
+
176
+ # A factory scope names the class being built but opens no lexical
177
+ # constant scope, so it never joins a constant's path (#339).
178
+ def lexical_nesting(nesting)
179
+ nesting.reject(&:factory?)
180
+ end
181
+
182
+ def unsupported_reason(_node, literal, guarded: false, **)
183
+ return "guarded by a condition" if guarded
184
+ return "its value is not a hash or array literal" if literal.nil?
185
+
186
+ nil
187
+ end
188
+
189
+ # `Point = Data.define(...) do ... end`: only the four core factories
190
+ # count, called on the bare constant (mirrors SubjectFinder).
191
+ def factory_scope?(node)
192
+ call = node.value
193
+ return false unless call.is_a?(Prism::CallNode) && call.block.is_a?(Prism::BlockNode)
194
+ return false unless call.receiver.is_a?(Prism::ConstantReadNode)
195
+
196
+ CLASS_FACTORIES[call.receiver.name] == call.name
197
+ end
198
+
199
+ # The hash or array literal behind the value, looking through `.freeze`
200
+ # calls only: any other receiver chain transforms the value, so the
201
+ # literal is not what the constant holds.
202
+ def literal_value(value)
203
+ value = value.receiver while value.is_a?(Prism::CallNode) && value.name == :freeze &&
204
+ value.arguments.nil? && value.block.nil? && value.receiver
205
+ value if value.is_a?(Prism::HashNode) || value.is_a?(Prism::ArrayNode)
206
+ end
207
+
208
+ def record_unsupported(out, node, nesting, reason, **ctx)
209
+ # A bare target takes the lexical owner. A ROOTED qualified target names
210
+ # its constant absolutely ("::RULES" is RULES anywhere). An UNROOTED one
211
+ # resolves through the lexical scope: "Shop::RULES" inside
212
+ # `module Outer; module Shop` writes Outer::Shop::RULES. Every id on the
213
+ # write's scope chain is a candidate, so each goes on the ambiguous list
214
+ # for demotion after the walk; over-matching only demotes a subject.
215
+ if node.respond_to?(:name)
216
+ name = node.name
217
+ owner = owner_path(nesting)
218
+ else
219
+ name = node.target.slice.sub(/\A::/, "")
220
+ owner = nil
221
+ unless node.target.slice.start_with?("::")
222
+ nesting.length.downto(0) do |depth|
223
+ prefix = owner_path(nesting[0...depth])
224
+ out[:ambiguous] << (prefix ? "#{prefix}::#{name}" : name)
225
+ end
226
+ end
227
+ end
228
+ out[:unsupported] << Unsupported.new(name: name, owner: owner, file: ctx[:file],
229
+ node: node, reason: reason)
230
+ end
231
+
232
+ # A subject whose id an unrooted qualified write may resolve to cannot be
233
+ # mutated safely: replaying the file lets that write overwrite it.
234
+ def demote_ambiguous(out, **ctx)
235
+ return if out[:ambiguous].empty?
236
+
237
+ poisoned = out[:subjects].select { |subject| out[:ambiguous].include?(subject.id) }
238
+ out[:subjects] -= poisoned
239
+ poisoned.each do |subject|
240
+ out[:unsupported] << Unsupported.new(name: subject.name, owner: subject.owner, file: ctx[:file],
241
+ node: subject.node,
242
+ reason: "an unrooted qualified write may overwrite it")
243
+ end
244
+ end
245
+
246
+ # Lexical path of the enclosing class/module, or nil at the top level.
247
+ # A singleton-class scope renders as "<attached>.singleton_class":
248
+ # constants assigned in `class << self` bind on the singleton class,
249
+ # not on the attached class (#380). Factory scopes open no lexical
250
+ # constant scope, but a factory's block is class_eval'd, so a `class <<
251
+ # self` directly inside one attaches to the factory's class.
252
+ def owner_path(nesting)
253
+ return nil if nesting.empty?
254
+
255
+ parts = []
256
+ factory_path = nil
257
+ nesting.each do |scope|
258
+ if scope.sclass?
259
+ # `self` is the innermost named scope: the factory's class inside a
260
+ # factory block (its block is class_eval'd), the attached class or
261
+ # module otherwise.
262
+ if factory_path
263
+ # factory_path carries the full prefix already: replace, never
264
+ # append, or the enclosing scope doubles (#395).
265
+ parts = ["#{factory_path}.singleton_class"]
266
+ elsif parts.any?
267
+ parts[-1] = "#{parts.last}.singleton_class"
268
+ end
269
+ factory_path = nil
270
+ elsif scope.factory?
271
+ factory_path = (parts + [scope.path.delete_prefix("::")]).join("::")
272
+ else
273
+ parts.clear if scope.path.start_with?("::")
274
+ parts << scope.path.delete_prefix("::")
275
+ factory_path = nil
276
+ end
277
+ end
278
+ parts.empty? ? nil : parts.join("::")
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # Every Coverage.result read goes through here. With the methods report
6
+ # on, the C call hashes each method owner and trusts the return type, so
7
+ # a loaded class whose `hash` does not return an Integer (forem's
8
+ # ReactionCategory, #564) makes every read raise TypeError - a "please
9
+ # report it" crash for what is a bug in the application's code. Refuse
10
+ # instead. The handler runs no app code (an ObjectSpace scan would call
11
+ # #hash/#name on hostile classes) and the error's backtrace names no
12
+ # owning class, so the message stays generic rather than accusing one.
13
+ module CoverageReads
14
+ module_function
15
+
16
+ def result(stop:, clear:)
17
+ Coverage.result(stop:, clear:)
18
+ rescue TypeError => e
19
+ raise Error, diagnostic(e)
20
+ end
21
+
22
+ def diagnostic(error)
23
+ "Coverage.result raised TypeError (#{error.message}): Coverage's method report hashes every " \
24
+ "method owner and trusts the result, and a loaded class whose #hash does not return an " \
25
+ "Integer (Ruby's #hash contract) breaks it. This is a bug in the application's code, " \
26
+ "not in saboteur."
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,128 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ module Saboteur
5
+ # The warning shown before a run on a tree with uncommitted changes
6
+ # (#309, #316). Mutants run your code and can change the tree, so the
7
+ # warning stays; it splits the raw count so a tree full of notes or trial
8
+ # setup files does not read like application edits about to be mutated.
9
+ #
10
+ # Each change lands in exactly one group, so the groups add up to the
11
+ # count: saboteur setup files first (the config file the run reads, a
12
+ # trial Gemfile and its lock), then the mutation scope (a file the
13
+ # configured include and exclude select, the way Config#included_files
14
+ # does), then the rest. A path the patterns match also counts, so a
15
+ # deleted source is still inside the scope. The scope is the configured
16
+ # one: --since, --subject and source operands narrow a run further, after
17
+ # this warning.
18
+ class DirtyTreeWarning
19
+ SHOWN_PATHS = 5
20
+ MATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
21
+ ADVICE = "Mutants run your code and could alter or delete files; " \
22
+ "run on a clean, committed tree or a CI checkout."
23
+
24
+ # The warning text for the run's working tree, or nil on a clean or
25
+ # unknown tree. The root is physical (realpath), like the paths
26
+ # Config#included_files returns.
27
+ def self.for(config, config_path:, gemfile: ENV.fetch("BUNDLE_GEMFILE", nil))
28
+ state = WorkingTree.new(config.root, ignored: config.output_paths).state
29
+ root = File.realpath(config.root)
30
+ new(state, included: relative(config.included_files, root), include: config["include"],
31
+ exclude: config["exclude"], root: root,
32
+ setup_files: setup_files(root, config_path: config_path, gemfile: gemfile)).to_s
33
+ end
34
+
35
+ # Files a trial of saboteur adds, relative to the root: the config file
36
+ # the run reads and, when BUNDLE_GEMFILE names a Gemfile other than the
37
+ # root's own, that Gemfile and its lock. Each file is compared by its
38
+ # physical path, so a /var spelling of a /private/var root still
39
+ # matches (#503).
40
+ def self.setup_files(root, config_path:, gemfile:)
41
+ files = [physical(File.expand_path(config_path || Config::FILE, root))]
42
+ trial = gemfile && physical(File.expand_path(gemfile, root))
43
+ files.push(trial, lockfile_for(trial)) if trial && trial != File.join(root, "Gemfile")
44
+ relative(files, root)
45
+ end
46
+
47
+ # Bundler's own rule, the same one preflight's execution inputs check
48
+ # against Bundler.default_lockfile: a gems.rb trial locks to gems.locked
49
+ # next to it; every other name gets ".lock" appended.
50
+ def self.lockfile_for(gemfile)
51
+ return File.join(File.dirname(gemfile), "gems.locked") if File.basename(gemfile) == "gems.rb"
52
+
53
+ "#{gemfile}.lock"
54
+ end
55
+
56
+ # git status paths are relative to the root, so the patterns are too:
57
+ # Dir.glob accepts a leading ./ or an absolute path under the root and
58
+ # File.fnmatch? does not (#503). A pattern outside the root stays
59
+ # absolute and matches no git path.
60
+ def self.relative_patterns(patterns, root)
61
+ patterns.map { |pattern| File.expand_path(pattern, root).delete_prefix(File.join(root, "")) }
62
+ end
63
+
64
+ # The path with its directory resolved, so a file that does not exist
65
+ # yet still gets the physical spelling of where it would be.
66
+ def self.physical(path)
67
+ File.join(File.realpath(File.dirname(path)), File.basename(path))
68
+ rescue SystemCallError
69
+ path
70
+ end
71
+
72
+ # A file outside the root keeps its absolute path, which no
73
+ # root-relative git path can equal.
74
+ def self.relative(files, root)
75
+ prefix = File.join(root, "")
76
+ files.map { |file| file.delete_prefix(prefix) }
77
+ end
78
+
79
+ private_class_method :physical, :relative
80
+
81
+ def initialize(state, included:, include:, exclude:, setup_files:, root:)
82
+ @state = state
83
+ @included = included.to_set
84
+ @include = self.class.relative_patterns(include, root)
85
+ @exclude = self.class.relative_patterns(exclude, root)
86
+ @setup_files = setup_files
87
+ end
88
+
89
+ def to_s
90
+ return unless @state.dirty
91
+
92
+ setup, rest = @state.changes.partition { |change| @setup_files.include?(change.path) }
93
+ scope, outside = rest.partition { |change| in_scope?(change.path) }
94
+ [summary(setup, scope, outside), named("inside the mutation scope", scope), named("setup files", setup),
95
+ " #{ADVICE}"].compact.join("\n")
96
+ end
97
+
98
+ private
99
+
100
+ def summary(setup, scope, outside)
101
+ groups = [scope.empty? ? "none inside the mutation scope" : counted("inside the mutation scope", scope),
102
+ counted("outside it", outside)]
103
+ groups << counted("saboteur setup file(s)", setup) unless setup.empty?
104
+ "saboteur: warning: #{@state.changed_files} uncommitted change(s) in the working tree: #{groups.join(', ')}."
105
+ end
106
+
107
+ def counted(label, changes)
108
+ tracked = changes.count(&:tracked)
109
+ "#{changes.size} #{label} (#{tracked} tracked, #{changes.size - tracked} untracked)"
110
+ end
111
+
112
+ def named(label, changes)
113
+ return if changes.empty?
114
+
115
+ paths = changes.first(SHOWN_PATHS).map(&:path).join(", ")
116
+ more = changes.size - SHOWN_PATHS
117
+ " #{label}: #{paths}#{" and #{more} more" if more.positive?}"
118
+ end
119
+
120
+ def in_scope?(path)
121
+ @included.include?(path) || (path.end_with?(".rb") && matches?(@include, path) && !matches?(@exclude, path))
122
+ end
123
+
124
+ def matches?(patterns, path)
125
+ patterns.any? { |pattern| File.fnmatch?(pattern, path, MATCH_FLAGS) }
126
+ end
127
+ end
128
+ end
@@ -98,6 +98,15 @@ module Saboteur
98
98
  else
99
99
  @out.puts "Sandbox: #{Engine::SandboxPolicy.sandbox_gap(abi)}; " \
100
100
  "children would run with the hazard guard and canary only"
101
+ if RUBY_PLATFORM.match?(/darwin/i) && @config["sandbox"] == "seatbelt"
102
+ @out.puts %(Seatbelt (opt-in, #436): probe #{Execution::Seatbelt.probe} - capability probe only, ) \
103
+ "not verified isolation"
104
+ elsif RUBY_PLATFORM.match?(/darwin/i)
105
+ @out.puts "macOS: mutants run unsandboxed (Landlock is Linux-only) - a container contains " \
106
+ "filesystem writes outside the project"
107
+ @out.puts "Network: never restricted on any platform, and a plain container keeps outbound " \
108
+ "access - deny or stub the network when a mutant could reach real APIs"
109
+ end
101
110
  end
102
111
  end
103
112
 
@@ -4,6 +4,7 @@
4
4
  require "json"
5
5
  require "fileutils"
6
6
  require_relative "../text"
7
+ require_relative "../frameworks/sqlite_memory_database"
7
8
 
8
9
  module Saboteur
9
10
  class Engine
@@ -52,7 +53,7 @@ module Saboteur
52
53
  announce_first_pass_cost(number, started)
53
54
  unless data["passed"] && data["failed"].empty?
54
55
  refuse(number,
55
- "the suite failed: #{Array(data['failed']).join(', ')}")
56
+ with_hint("the suite failed: #{Array(data['failed']).join(', ')}", data))
56
57
  end
57
58
  executed = data["executed"]
58
59
  refuse(number, "executed tests do not match the loaded suite") unless
@@ -85,16 +86,24 @@ module Saboteur
85
86
  end
86
87
 
87
88
  # Once the first pass is timed, the total cost stops being a guess:
88
- # the remaining full passes cost roughly the same each.
89
+ # the remaining full passes cost roughly the same each. full_passes
90
+ # includes the cold-cache coverage build (#260), so name what actually
91
+ # remains: with baseline_passes 1 the one remaining pass is the coverage
92
+ # build, not another baseline check (#465).
89
93
  def announce_first_pass_cost(number, started)
90
94
  return unless number == 1
91
95
 
92
- remaining = @full_passes - number
93
- return if remaining.zero?
96
+ coverage_pending = @full_passes > @config["baseline_passes"]
97
+ remaining = @full_passes - number - (coverage_pending ? 1 : 0)
98
+ return if remaining.zero? && !coverage_pending
94
99
 
95
100
  elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round
96
- @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * remaining} s left for " \
97
- "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'} at that rate")
101
+ work = []
102
+ work << "the remaining #{remaining} full #{remaining == 1 ? 'pass' : 'passes'}" if remaining.positive?
103
+ work << "the coverage map build" if coverage_pending
104
+ total = remaining + (coverage_pending ? 1 : 0)
105
+ @diagnostics.inform("baseline check #{number} took #{elapsed} s; about #{elapsed * total} s left for " \
106
+ "#{work.join(' and ')} at that rate")
98
107
  end
99
108
 
100
109
  # An admitted pass still gets only the time left in the run budget:
@@ -117,6 +126,24 @@ module Saboteur
117
126
  "phase timeout)"
118
127
  end
119
128
 
129
+ # A containment limit stopping a healthy suite means the limit, not
130
+ # the suite: name the knob and its current value, like the sqlite
131
+ # :memory: hint (#387), so the user is not sent debugging a green
132
+ # suite (#459).
133
+ def stopped_reason(result)
134
+ reason = "the suite stopped: #{result.stopped_by} (#{result.detail})"
135
+ case result.stopped_by
136
+ when :processes
137
+ "#{reason}. If this suite forks heavily by design, raise the limit with " \
138
+ "c.max_processes (currently #{@config['max_processes']})"
139
+ when :memory
140
+ "#{reason}. If this suite legitimately needs more memory, raise the limit with " \
141
+ "c.max_memory_mb (currently #{@config['max_memory_mb']} MB)"
142
+ else
143
+ reason
144
+ end
145
+ end
146
+
120
147
  def check(number)
121
148
  Execution::ForkReadiness.prepare_heap
122
149
  heartbeat = @diagnostics.progress_heartbeat("baseline check #{number}").watch(log_path(number))
@@ -126,14 +153,14 @@ module Saboteur
126
153
  record(writer, number)
127
154
  end
128
155
  refuse_budget_stop(number, admitted) if budget_stopped?(result, admitted)
129
- refuse(number, "the suite stopped: #{result.stopped_by} (#{result.detail})") if result.stopped_by
156
+ refuse(number, stopped_reason(result)) if result.stopped_by
130
157
  # Before the payload checks: a child killed by a signal (the OOM
131
158
  # killer, say) writes nothing, and the signal is the explanation. A
132
159
  # child the supervisor itself killed after it had reported is not a
133
160
  # failure: its payload is checked below.
134
161
  refuse(number, unsuccessful_exit(result.exit_status, result.memory_pressure)) if result.child_failed?
135
162
  refuse(number, "the child did not report a result") if result.payload.empty?
136
- refuse(number, "the suite left child processes behind") if result.leftover_pids.any?
163
+ refuse(number, leftover_message(result), excerpt: false) if result.leftover_pids.any?
137
164
  parse_result(number, result.payload)
138
165
  end
139
166
 
@@ -151,7 +178,7 @@ module Saboteur
151
178
  def parse_result(number, payload)
152
179
  data = JSON.parse(payload)
153
180
  refuse(number, "the child reported an invalid result") unless data.is_a?(Hash)
154
- refuse(number, data["error"]) if data["error"]
181
+ refuse(number, with_hint(data["error"], data)) if data["error"]
155
182
  refuse(number, "the child reported invalid test results") unless valid_result?(data)
156
183
  data
157
184
  rescue JSON::ParserError
@@ -172,21 +199,42 @@ module Saboteur
172
199
  @hooks.run(:after_fork)
173
200
  Kernel.srand(@config["seed"])
174
201
  result = @integration.run_baseline(seed: @config["seed"])
175
- writer.write(JSON.generate(passed: result.passed, failed: result.failed_ids, executed: result.executed_ids))
202
+ payload = { passed: result.passed, failed: result.failed_ids, executed: result.executed_ids }
203
+ payload[:hint] = Frameworks::SqliteMemoryDatabase.fork_loss_hint unless result.passed
204
+ writer.write(JSON.generate(payload))
176
205
  rescue Exception => e # rubocop:disable Lint/RescueException
177
- writer.write(JSON.generate(error: "#{e.class}: #{e.message}"))
206
+ writer.write(JSON.generate(error: "#{e.class}: #{e.message}",
207
+ hint: Frameworks::SqliteMemoryDatabase.fork_loss_hint))
178
208
  ensure
179
209
  writer.close
180
210
  end
181
211
 
212
+ # A child that recognises its own failure shape (an empty sqlite
213
+ # :memory: database after the fork, #387) says how to fix it.
214
+ def with_hint(reason, data)
215
+ data["hint"] ? "#{reason}. #{data['hint']}" : reason
216
+ end
217
+
182
218
  def log_path(number)
183
219
  File.join(@config.path("cache_dir"), "baseline-#{number}.log")
184
220
  end
185
221
 
186
- def refuse(number, reason)
222
+ # Leftover children are a pass-with-leaks verdict, not a suite
223
+ # failure: the message names the leaked processes (the supervisor's
224
+ # pre-kill notes) and skips the log excerpt, which holds only RSpec
225
+ # output and can never show them (#486).
226
+ def leftover_message(result)
227
+ notes = result.leftover_notes
228
+ detail = notes.empty? ? result.leftover_pids.join(", ") : notes.join(", ")
229
+ count = result.leftover_pids.size
230
+ "the suite passed but left #{count} child #{count == 1 ? 'process' : 'processes'} behind: #{detail}"
231
+ end
232
+
233
+ def refuse(number, reason, excerpt: true)
187
234
  raise Error, "baseline check #{number} failed at seed #{@config['seed']}: #{reason}. " \
188
- "#{failure_excerpt(number)}See the full log at #{log_path(number)}. Fix the suite or its " \
189
- "setup, then run saboteur again with the same seed. No mutants were started."
235
+ "#{failure_excerpt(number) if excerpt}See the full log at #{log_path(number)}. " \
236
+ "Fix the suite or its setup, then run saboteur again with the same seed. " \
237
+ "No mutants were started."
190
238
  end
191
239
 
192
240
  # The first lines of the failure, inline, so the cause is readable