saboteur 0.6.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/README.md +199 -952
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +80 -4
  8. data/lib/saboteur/cli/help_options.rb +17 -0
  9. data/lib/saboteur/cli/history_command.rb +135 -0
  10. data/lib/saboteur/cli/presets.rb +40 -0
  11. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  12. data/lib/saboteur/cli/run_reporting.rb +74 -5
  13. data/lib/saboteur/cli/selection_options.rb +31 -0
  14. data/lib/saboteur/cli/shard_options.rb +17 -0
  15. data/lib/saboteur/cli/source_set.rb +53 -0
  16. data/lib/saboteur/cli/survivors_command.rb +157 -0
  17. data/lib/saboteur/cli.rb +136 -98
  18. data/lib/saboteur/config.rb +163 -15
  19. data/lib/saboteur/config_file.rb +8 -0
  20. data/lib/saboteur/constant_reads.rb +182 -0
  21. data/lib/saboteur/constant_subject.rb +70 -0
  22. data/lib/saboteur/constant_subject_finder.rb +281 -0
  23. data/lib/saboteur/coverage_reads.rb +29 -0
  24. data/lib/saboteur/crash_report.rb +154 -0
  25. data/lib/saboteur/custom_map.rb +3 -1
  26. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  27. data/lib/saboteur/doctor.rb +49 -1
  28. data/lib/saboteur/engine/baseline_guard.rb +136 -14
  29. data/lib/saboteur/engine/boot.rb +16 -1
  30. data/lib/saboteur/engine/canary.rb +41 -10
  31. data/lib/saboteur/engine/candidate_selection.rb +28 -1
  32. data/lib/saboteur/engine/candidates.rb +43 -3
  33. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  34. data/lib/saboteur/engine/coverage_store.rb +79 -15
  35. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  36. data/lib/saboteur/engine/diagnostics.rb +41 -2
  37. data/lib/saboteur/engine/diff_scope.rb +24 -5
  38. data/lib/saboteur/engine/distribution.rb +134 -8
  39. data/lib/saboteur/engine/early_stop_recording.rb +47 -0
  40. data/lib/saboteur/engine/gated_run.rb +35 -0
  41. data/lib/saboteur/engine/load_coverage.rb +45 -0
  42. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  43. data/lib/saboteur/engine/pipelines.rb +107 -0
  44. data/lib/saboteur/engine/result_cache.rb +67 -9
  45. data/lib/saboteur/engine/run_budget.rb +90 -0
  46. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  47. data/lib/saboteur/engine/sandbox_policy.rb +56 -11
  48. data/lib/saboteur/engine/scheduler.rb +222 -20
  49. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  50. data/lib/saboteur/engine/shard.rb +69 -0
  51. data/lib/saboteur/engine/subject_selection.rb +373 -19
  52. data/lib/saboteur/engine/suite_files.rb +3 -1
  53. data/lib/saboteur/engine/test_guess.rb +438 -25
  54. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  55. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  56. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  57. data/lib/saboteur/engine/verdict_check.rb +54 -18
  58. data/lib/saboteur/engine.rb +202 -158
  59. data/lib/saboteur/execution/external_runner.rb +471 -0
  60. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  61. data/lib/saboteur/execution/peak_memory.rb +28 -0
  62. data/lib/saboteur/execution/process_table.rb +12 -0
  63. data/lib/saboteur/execution/process_tree.rb +65 -5
  64. data/lib/saboteur/execution/protocol.rb +2 -1
  65. data/lib/saboteur/execution/reevaluator.rb +68 -12
  66. data/lib/saboteur/execution/runner.rb +225 -24
  67. data/lib/saboteur/execution/sandbox.rb +21 -3
  68. data/lib/saboteur/execution/seatbelt.rb +226 -0
  69. data/lib/saboteur/execution/supervisor.rb +45 -13
  70. data/lib/saboteur/execution/worker_pool.rb +110 -8
  71. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  72. data/lib/saboteur/gates.rb +43 -2
  73. data/lib/saboteur/help.rb +108 -13
  74. data/lib/saboteur/ignore_list.rb +40 -7
  75. data/lib/saboteur/install.rb +4 -3
  76. data/lib/saboteur/integration/base.rb +7 -2
  77. data/lib/saboteur/integration/rspec.rb +175 -9
  78. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  79. data/lib/saboteur/mutation_source/builtin.rb +44 -14
  80. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  81. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  82. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  83. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  84. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  86. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  87. data/lib/saboteur/mutation_source/hazard_guard.rb +129 -3
  88. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  89. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  90. data/lib/saboteur/mutation_source/method_swap.rb +193 -0
  91. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  92. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  93. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  94. data/lib/saboteur/mutation_source/operator_examples.rb +6 -1
  95. data/lib/saboteur/mutation_source/operator_registry.rb +7 -2
  96. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  97. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  98. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  99. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  100. data/lib/saboteur/preview.rb +122 -13
  101. data/lib/saboteur/refinement_activations.rb +60 -0
  102. data/lib/saboteur/reporter/agent.rb +9 -1
  103. data/lib/saboteur/reporter/agent_commands.rb +47 -15
  104. data/lib/saboteur/reporter/agent_incomplete.rb +38 -2
  105. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  106. data/lib/saboteur/reporter/findings.rb +5 -2
  107. data/lib/saboteur/reporter/history_policy.rb +11 -0
  108. data/lib/saboteur/reporter/history_reader.rb +47 -0
  109. data/lib/saboteur/reporter/html.rb +26 -4
  110. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  111. data/lib/saboteur/reporter/next_steps.rb +14 -4
  112. data/lib/saboteur/reporter/score_history.rb +5 -1
  113. data/lib/saboteur/reporter/stryker.rb +40 -7
  114. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  115. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  116. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  117. data/lib/saboteur/reporter/terminal.rb +74 -17
  118. data/lib/saboteur/result.rb +38 -7
  119. data/lib/saboteur/selection/coverage_map.rb +107 -11
  120. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  121. data/lib/saboteur/selection/external_test_map.rb +27 -0
  122. data/lib/saboteur/selection/git_diff.rb +105 -13
  123. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  124. data/lib/saboteur/selection/recorder.rb +22 -4
  125. data/lib/saboteur/selection/source_scope.rb +22 -0
  126. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  127. data/lib/saboteur/subject.rb +53 -7
  128. data/lib/saboteur/subject_finder.rb +173 -29
  129. data/lib/saboteur/text.rb +42 -0
  130. data/lib/saboteur/version.rb +1 -1
  131. data/lib/saboteur/working_tree.rb +38 -15
  132. data/lib/saboteur.rb +29 -1
  133. metadata +50 -2
@@ -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,154 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "json"
6
+ require "tmpdir"
7
+ require "pathname"
8
+
9
+ module Saboteur
10
+ # Last-resort crash reporting, shared by the CLI's StandardError rescue
11
+ # and the exe-level wrapper: one bounded line on stderr, the full
12
+ # backtrace in a log file the line names, the backtrace on stderr with
13
+ # --verbose, and the same failure as agent JSON when an agent listens.
14
+ # Always returns EXIT_TOOL.
15
+ module CrashReport
16
+ ISSUES_URL = "https://github.com/vamsipavanmahesh/saboteur/issues"
17
+
18
+ # Same value as CLI::EXIT_TOOL, kept local so the reporter still works
19
+ # when the CLI itself never finished loading.
20
+ EXIT_TOOL = 3
21
+
22
+ # The crash log's file name under the run's cache dir; the runner reads
23
+ # it back to explain a nested run's crash in the failure detail (#296).
24
+ LOG_NAME = "internal-error.log"
25
+
26
+ module_function
27
+
28
+ def call(error, verbose:, agent:, err:, out:, log_path: nil)
29
+ summary = "internal error (#{error.class}: #{one_line(error.message)})"
30
+ line = "saboteur: #{summary} - please report it: #{ISSUES_URL}"
31
+ written = write_log(log_path, error)
32
+ line = "#{line}; full backtrace: #{display_path(written)}" if written
33
+ err.puts(line)
34
+ err.puts(full_text(error)) if verbose
35
+ out.print(agent_json(summary)) if agent
36
+ EXIT_TOOL
37
+ end
38
+
39
+ # A crash message can span lines or carry bytes that are invalid in
40
+ # UTF-8 (a LANG=C failure, say); the one-line diagnostic must survive
41
+ # both, and stay one bounded line.
42
+ def one_line(message)
43
+ text = scrub(message).gsub(/\s+/, " ").strip
44
+ text.length > 160 ? "#{text[0...160]}..." : text
45
+ end
46
+
47
+ # Class, full message and backtrace, scrubbed so locale-invalid bytes
48
+ # cannot corrupt the log or the terminal.
49
+ def full_text(error)
50
+ (["#{error.class}: #{scrub(error.message)}"] + Array(error.backtrace).map { |frame| scrub(frame) }).join("\n")
51
+ end
52
+
53
+ # Writes the log next to the other run state; returns the written path,
54
+ # or nil when the log cannot be written - the crash itself must still
55
+ # be reported, so an unwritable directory is not a second failure.
56
+ # Reporter::Agent.failure's payload, reproduced minimally for crashes
57
+ # before the CLI's reporter files load: same schema and pretty rendering;
58
+ # the summary is already normalized, so no message cap is needed here.
59
+ def agent_json(summary)
60
+ payload =
61
+ if defined?(Reporter::Agent)
62
+ Reporter::Agent.failure(summary, EXIT_TOOL)
63
+ else
64
+ { version: 1, status: "error", verdict: "tool_failure", exitCode: EXIT_TOOL,
65
+ message: summary, next: { help: "saboteur help run" } }
66
+ end
67
+ "#{JSON.pretty_generate(payload)}\n"
68
+ end
69
+
70
+ def write_log(log_path, error)
71
+ return nil unless log_path
72
+
73
+ # The primary path is user-configured but still predictable: without
74
+ # O_NOFOLLOW a planted symlink would redirect the truncate/chmod, and
75
+ # without O_NONBLOCK a planted FIFO would block the open. Where the
76
+ # platform lacks either, only the EXCL-created temp fallback is safe
77
+ # (#296).
78
+ if safe_log_io?
79
+ begin
80
+ FileUtils.mkdir_p(File.dirname(log_path))
81
+ # Private like the temp fallback: the runner only reads logs with
82
+ # no group/other bits, from the same no-follow fd (#296).
83
+ flags = File::CREAT | File::WRONLY | File::TRUNC | nofollow_flag | nonblock_flag
84
+ File.open(log_path, flags, 0o600) do |file|
85
+ file.chmod(0o600)
86
+ file.write("#{full_text(error)}\n")
87
+ end
88
+ return log_path
89
+ rescue SystemCallError, IOError
90
+ # Fall through to the temp fallback.
91
+ end
92
+ end
93
+ write_tmp_log(error)
94
+ end
95
+
96
+ # The fallback is computed lazily, only once the primary write failed:
97
+ # an eager tmp_log_path call could itself raise (Dir.tmpdir finds no
98
+ # usable dir) and mask the crash it was meant to log (#296).
99
+ #
100
+ # The fallback path is predictable and lives in a shared directory, so
101
+ # it is created privately: CREAT|EXCL|WRONLY|NOFOLLOW never follows or
102
+ # overwrites a pre-existing file or symlink, and the mode pins the log
103
+ # to 0600. A taken or hostile path means no log - the crash report
104
+ # itself must still go out, so every failure here degrades to nil.
105
+ def write_tmp_log(error)
106
+ File.open(tmp_log_path, File::CREAT | File::EXCL | File::WRONLY | nofollow_flag, 0o600) do |file|
107
+ file.write("#{full_text(error)}\n")
108
+ end
109
+ tmp_log_path
110
+ rescue SystemCallError, IOError, ArgumentError
111
+ nil
112
+ end
113
+
114
+ # O_NOFOLLOW where the platform has it (every CI target does).
115
+ def nofollow_flag
116
+ File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
117
+ end
118
+
119
+ # O_NONBLOCK where the platform has it (every CI target does).
120
+ def nonblock_flag
121
+ File.const_defined?(:NONBLOCK) ? File::NONBLOCK : 0
122
+ end
123
+
124
+ # The private crash-log write/read needs both flags: without O_NOFOLLOW
125
+ # a planted symlink redirects the open, without O_NONBLOCK a planted
126
+ # FIFO blocks it. Where either is missing, the log is omitted rather
127
+ # than opened unsafely (#296).
128
+ def safe_log_io?
129
+ File.const_defined?(:NOFOLLOW) && File.const_defined?(:NONBLOCK)
130
+ end
131
+
132
+ # Where the log lands when a configured cache dir is not writable - a
133
+ # sandboxed child holds the project read-only, but the system temp dir is
134
+ # granted (#296). Only a configured log path falls back: cache_dir = nil
135
+ # still means no log at all. Keyed by pid so parallel children never
136
+ # share a file; the runner reads the same path back in the same process.
137
+ def tmp_log_path(pid: Process.pid)
138
+ File.join(Dir.tmpdir, "saboteur-#{pid}-#{LOG_NAME}")
139
+ end
140
+
141
+ # The message names the log; relative to the working directory when it
142
+ # sits inside it, so the line stays short.
143
+ def display_path(path)
144
+ relative = Pathname(path).relative_path_from(Pathname(Dir.pwd)).to_s
145
+ relative.start_with?("..") ? path : relative
146
+ rescue ArgumentError
147
+ path
148
+ end
149
+
150
+ def scrub(string)
151
+ string.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
152
+ end
153
+ end
154
+ end
@@ -1,6 +1,8 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "text"
5
+
4
6
  module Saboteur
5
7
  # Explicit source-file -> spec-file mapping from config/saboteur_custom_map.rb:
6
8
  #
@@ -109,7 +111,7 @@ module Saboteur
109
111
  def duplicate_keys(path)
110
112
  seen = Hash.new(0)
111
113
  File.readlines(path).each do |line|
112
- seen[::Regexp.last_match(1)] += 1 if line =~ /^\s*"(.+?)"\s*=>/
114
+ seen[::Regexp.last_match(1)] += 1 if Text.utf8(line) =~ /^\s*"(.+?)"\s*=>/
113
115
  end
114
116
  seen.select { |_, count| count > 1 }.keys
115
117
  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