saboteur 0.7.0 → 0.8.1

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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +772 -0
  3. data/README.md +221 -1072
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/exit_status.rb +32 -1
  6. data/lib/saboteur/cli/help_options.rb +2 -1
  7. data/lib/saboteur/cli/history_command.rb +135 -0
  8. data/lib/saboteur/cli/presets.rb +47 -3
  9. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  10. data/lib/saboteur/cli/selection_options.rb +31 -0
  11. data/lib/saboteur/cli/shard_options.rb +17 -0
  12. data/lib/saboteur/cli/source_set.rb +35 -0
  13. data/lib/saboteur/cli/survivors_command.rb +157 -0
  14. data/lib/saboteur/cli.rb +74 -55
  15. data/lib/saboteur/config.rb +102 -6
  16. data/lib/saboteur/config_file.rb +19 -7
  17. data/lib/saboteur/constant_reads.rb +182 -0
  18. data/lib/saboteur/constant_subject.rb +70 -0
  19. data/lib/saboteur/constant_subject_finder.rb +281 -0
  20. data/lib/saboteur/coverage_reads.rb +29 -0
  21. data/lib/saboteur/custom_map.rb +12 -1
  22. data/lib/saboteur/dirty_tree_warning.rb +130 -0
  23. data/lib/saboteur/doctor.rb +13 -0
  24. data/lib/saboteur/engine/baseline_guard.rb +135 -21
  25. data/lib/saboteur/engine/boot.rb +11 -3
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  27. data/lib/saboteur/engine/candidate_selection.rb +20 -0
  28. data/lib/saboteur/engine/candidates.rb +43 -3
  29. data/lib/saboteur/engine/coverage_store.rb +58 -14
  30. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  31. data/lib/saboteur/engine/diagnostics.rb +19 -1
  32. data/lib/saboteur/engine/distribution.rb +10 -0
  33. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  34. data/lib/saboteur/engine/gated_run.rb +37 -0
  35. data/lib/saboteur/engine/load_coverage.rb +63 -0
  36. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  37. data/lib/saboteur/engine/pipelines.rb +115 -0
  38. data/lib/saboteur/engine/result_cache.rb +65 -8
  39. data/lib/saboteur/engine/run_budget.rb +32 -2
  40. data/lib/saboteur/engine/sandbox_policy.rb +59 -3
  41. data/lib/saboteur/engine/scheduler.rb +131 -17
  42. data/lib/saboteur/engine/selection_announcement.rb +59 -0
  43. data/lib/saboteur/engine/shard.rb +69 -0
  44. data/lib/saboteur/engine/subject_selection.rb +380 -21
  45. data/lib/saboteur/engine/test_guess.rb +407 -27
  46. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  47. data/lib/saboteur/engine.rb +103 -129
  48. data/lib/saboteur/execution/external_runner.rb +489 -0
  49. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  50. data/lib/saboteur/execution/marker_sweep.rb +80 -0
  51. data/lib/saboteur/execution/peak_memory.rb +28 -0
  52. data/lib/saboteur/execution/process_table.rb +19 -3
  53. data/lib/saboteur/execution/process_tree.rb +65 -5
  54. data/lib/saboteur/execution/reevaluator.rb +75 -12
  55. data/lib/saboteur/execution/runner.rb +46 -7
  56. data/lib/saboteur/execution/sandbox.rb +25 -6
  57. data/lib/saboteur/execution/seatbelt.rb +233 -0
  58. data/lib/saboteur/execution/supervisor.rb +29 -11
  59. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  60. data/lib/saboteur/execution/worker_pool.rb +23 -1
  61. data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
  62. data/lib/saboteur/gates.rb +43 -2
  63. data/lib/saboteur/help.rb +60 -9
  64. data/lib/saboteur/ignore_list.rb +29 -6
  65. data/lib/saboteur/integration/rspec.rb +223 -5
  66. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  67. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  68. data/lib/saboteur/mutation_source/builtin.rb +28 -5
  69. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  70. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  71. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  72. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  73. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  74. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  75. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  76. data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
  77. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  79. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  80. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  81. data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
  82. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  83. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  84. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  86. data/lib/saboteur/preview.rb +125 -12
  87. data/lib/saboteur/refinement_activations.rb +60 -0
  88. data/lib/saboteur/reporter/agent.rb +9 -1
  89. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  90. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  91. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  92. data/lib/saboteur/reporter/findings.rb +5 -2
  93. data/lib/saboteur/reporter/history_policy.rb +11 -0
  94. data/lib/saboteur/reporter/history_reader.rb +47 -0
  95. data/lib/saboteur/reporter/html.rb +26 -4
  96. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  97. data/lib/saboteur/reporter/stryker.rb +27 -3
  98. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  99. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  100. data/lib/saboteur/reporter/terminal.rb +56 -8
  101. data/lib/saboteur/result.rb +19 -3
  102. data/lib/saboteur/selection/coverage_map.rb +120 -10
  103. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  104. data/lib/saboteur/selection/external_test_map.rb +27 -0
  105. data/lib/saboteur/selection/git_diff.rb +93 -11
  106. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  107. data/lib/saboteur/selection/recorder.rb +22 -4
  108. data/lib/saboteur/selection/source_scope.rb +22 -0
  109. data/lib/saboteur/subject.rb +42 -7
  110. data/lib/saboteur/subject_finder.rb +173 -29
  111. data/lib/saboteur/version.rb +1 -1
  112. data/lib/saboteur/working_tree.rb +38 -15
  113. data/lib/saboteur.rb +14 -0
  114. metadata +39 -16
@@ -0,0 +1,182 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "prism"
5
+ require_relative "text"
6
+
7
+ module Saboteur
8
+ # The lines where a constant is read, per file: the tests covering those
9
+ # lines are the ones that can observe a change to the constant's value
10
+ # (issue #306).
11
+ #
12
+ # Matching is lexical and honest about its edges. A bare `RULES` matches
13
+ # when its scope is the constant's owner or nested under it (constant
14
+ # lookup climbs lexical scopes) or the constant is top-level; a
15
+ # `Shop::RULES` path matches by its full spelling. A scope that writes its
16
+ # own `RULES` shadows the outer constant: reads under it resolve to the
17
+ # inner one and do not count. Reads reached through a superclass,
18
+ # `const_get`, or a compact `class Shop::Item` scope are not resolved: a
19
+ # constant only read that way finds no consumer here, and the run reports
20
+ # it as ran-at-load instead of guessing.
21
+ #
22
+ # Runtime reads (inside a def body) observe the constant when the mutant
23
+ # runs; load-time reads outside the constant's own file do not: the other
24
+ # file was loaded once with the original value and re-evaluating the
25
+ # constant's file does not run it again, so a read like
26
+ # `COPY = A::RULES` there keeps the original (#319). A capture inside a
27
+ # boot-time block (`1.times { COPY = A::RULES }`) is treated the same:
28
+ # statement-level blocks run while the file loads. That is a heuristic -
29
+ # a block passed to a deferring call (at_exit, define_method) runs later,
30
+ # but treating it as load-time errs toward dropping a constant rather
31
+ # than reporting a mutant nobody can observe.
32
+ module ConstantReads
33
+ Reads = Data.define(:runtime, :load, :defs)
34
+
35
+ module_function
36
+
37
+ # { "/abs/file.rb" => Reads } for the files that read the constant.
38
+ # runtime: def-body and block lines - a block runs when its caller runs
39
+ # it (an `it` block during the example), and line coverage decides
40
+ # whether any test reached it. load: top-level or class-body statement
41
+ # lines (observable only in the constant's own file, which the mutant
42
+ # re-evaluates). defs: the start line of each def a runtime read sits
43
+ # in, for the method-call counter fallback (endless defs have no line
44
+ # coverage). Only files whose text mentions the name are parsed.
45
+ def across(files, constant)
46
+ mention = /\b#{Regexp.escape(constant.name.to_s)}\b/
47
+ files.each_with_object({}) do |file, hits|
48
+ source = File.binread(file)
49
+ next unless mentions?(source, mention)
50
+
51
+ reads = reads_in(source, constant)
52
+ hits[file] = reads unless reads.runtime.empty? && reads.load.empty?
53
+ end
54
+ end
55
+
56
+ # The lines a mutant of the constant can be observed through: runtime
57
+ # reads everywhere, load reads in the constant's own file only.
58
+ def observable_lines(reads_by_file, constant)
59
+ reads_by_file.each_with_object({}) do |(file, reads), hits|
60
+ lines = reads.runtime.dup
61
+ lines.concat(reads.load) if file == constant.file
62
+ hits[file] = lines.uniq.sort unless lines.empty?
63
+ end
64
+ end
65
+
66
+ def mentions?(source, mention)
67
+ Text.utf8_identity(source).match?(mention)
68
+ rescue Encoding::CompatibilityError, ArgumentError
69
+ false
70
+ end
71
+
72
+ def reads_in(source, constant)
73
+ result = Prism.parse(source)
74
+ return Reads.new([], [], []) if result.failure?
75
+
76
+ out = { runtime: [], load: [], defs: [] }
77
+ walk(result.value, [], constant, out, in_def: nil, in_block: false, shadowed: nil)
78
+ Reads.new(out[:runtime].uniq.sort, out[:load].uniq.sort, out[:defs].uniq.sort)
79
+ end
80
+
81
+ def walk(node, nesting, constant, out, in_def:, in_block:, shadowed:, capture: false)
82
+ return if node.nil?
83
+
84
+ case node
85
+ when Prism::DefNode
86
+ walk(node.body, nesting, constant, out, in_def: node.location.start_line, in_block: false,
87
+ shadowed: shadowed)
88
+ when Prism::BlockNode, Prism::LambdaNode
89
+ walk(node.body, nesting, constant, out, in_def: in_def, in_block: true, capture: capture,
90
+ shadowed: shadowed)
91
+ when Prism::ConstantWriteNode, Prism::ConstantPathWriteNode
92
+ # A capture (`COPY = <expr>`): the value reads the constant once,
93
+ # when the write runs. Walk the value flagged as a capture so a
94
+ # boot-time block write (`1.times { COPY = A::RULES }`) lands in
95
+ # load, not runtime (#319).
96
+ walk(node.value, nesting, constant, out, in_def: in_def, in_block: in_block, capture: true,
97
+ shadowed: shadowed)
98
+ when Prism::ConstantReadNode
99
+ record(node, out, in_def, in_block, capture) if !shadowed && bare_match?(node, nesting, constant)
100
+ when Prism::ConstantPathNode
101
+ # `Shop::RULES` holds no ConstantReadNode for RULES, only for Shop;
102
+ # match the path's full spelling.
103
+ record(node, out, in_def, in_block, capture) if node.slice.delete_prefix("::") == constant.id
104
+ walk_children(node, nesting, constant, out, in_def: in_def, in_block: in_block, capture: capture,
105
+ shadowed: shadowed)
106
+ when Prism::ModuleNode, Prism::ClassNode, Prism::SingletonClassNode
107
+ walk_scope(node, nesting, constant, out, shadowed)
108
+ else
109
+ walk_children(node, nesting, constant, out, in_def: in_def, in_block: in_block, capture: capture,
110
+ shadowed: shadowed)
111
+ end
112
+ end
113
+
114
+ # `class << self` joins the nesting as "<attached>.singleton_class",
115
+ # matching how ConstantSubjectFinder names that owner (#380); a non-self
116
+ # receiver or a top-level singleton has no static name and adds nothing.
117
+ # The shadow carries the path of the scope that set it. Entering the
118
+ # constant's owner scope lifts a shadow set outside it: the owner's
119
+ # own definition is lexically closer there (#395). A shadow set
120
+ # inside the owner subtree still binds below the nested scope that
121
+ # wrote the constant.
122
+ def walk_scope(node, nesting, constant, out, shadowed)
123
+ inner = scope_nesting(node, nesting)
124
+ path = inner.join("::")
125
+ scopes = [constant.owner.to_s]
126
+ shadowed = nil if shadowed && scopes.any? { |scope| path == scope || path.start_with?("#{scope}::") } &&
127
+ scopes.none? { |scope| shadowed == scope || shadowed.start_with?("#{scope}::") }
128
+ shadowed = path if shadowed.nil? && path != scopes.first && writes_constant?(node.body, constant.name)
129
+ walk(node.body, inner, constant, out, in_def: nil, in_block: false, shadowed: shadowed)
130
+ end
131
+
132
+ def scope_nesting(node, nesting)
133
+ return nesting + [node.constant_path.slice.delete_prefix("::")] unless node.is_a?(Prism::SingletonClassNode)
134
+ return nesting unless node.expression.is_a?(Prism::SelfNode) && nesting.any?
135
+
136
+ nesting[0...-1] + ["#{nesting.last}.singleton_class"]
137
+ end
138
+
139
+ def walk_children(node, nesting, constant, out, in_def:, in_block:, capture:, shadowed:)
140
+ node.compact_child_nodes.each do |child|
141
+ walk(child, nesting, constant, out, in_def: in_def, in_block: in_block, capture: capture,
142
+ shadowed: shadowed)
143
+ end
144
+ end
145
+
146
+ def record(node, out, in_def, in_block, capture)
147
+ line = node.location.start_line
148
+ if in_def
149
+ out[:runtime] << line
150
+ out[:defs] << in_def
151
+ elsif in_block && !capture
152
+ out[:runtime] << line
153
+ else
154
+ # A bare statement read, or a capture in a boot-time block: both
155
+ # resolved while the file loaded, with the original value.
156
+ out[:load] << line
157
+ end
158
+ end
159
+
160
+ # A bare `RULES` reads this constant when the read's scope is the
161
+ # constant's owner (or its singleton scope, whose ancestry reaches the
162
+ # attached class) or nested under either, or it is top-level (#380).
163
+ def bare_match?(node, nesting, constant)
164
+ return false unless node.name == constant.name
165
+ return true if constant.owner.nil?
166
+
167
+ path = nesting.join("::")
168
+ [constant.owner, "#{constant.owner}.singleton_class"].any? do |scope|
169
+ path == scope || path.start_with?("#{scope}::")
170
+ end
171
+ end
172
+
173
+ # True when the scope body assigns the constant name at its own level
174
+ # (not inside a nested def or scope).
175
+ def writes_constant?(body, name)
176
+ statements = body&.body || []
177
+ statements.any? do |statement|
178
+ statement.is_a?(Prism::ConstantWriteNode) && statement.name == name
179
+ end
180
+ end
181
+ end
182
+ end
@@ -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
@@ -64,7 +64,14 @@ module Saboteur
64
64
  unless spec.is_a?(String)
65
65
  raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; spec paths must be Strings"
66
66
  end
67
- return if key.is_a?(Regexp) && spec.match?(/\\\d/) # backreference filled in at match time
67
+
68
+ numbered = key.is_a?(Regexp) && spec.match?(/\\\d/)
69
+ named = key.is_a?(Regexp) && spec.match?(/%[-0-9]*[<{][^>}]+[>}]|\\k</)
70
+ if numbered && named
71
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
72
+ 'regex map specs support \1 backreferences only'
73
+ end
74
+ return if numbered # backreference filled in at match time
68
75
 
69
76
  expanded = File.expand_path(spec, root)
70
77
  unless expanded.start_with?("#{root}/")
@@ -72,6 +79,10 @@ module Saboteur
72
79
  end
73
80
  return if Dir.glob(expanded).any?
74
81
 
82
+ if named
83
+ raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}; " \
84
+ 'regex map specs support \1 backreferences only'
85
+ end
75
86
  raise ConfigError, "#{path}: #{key.inspect} maps to #{spec.inspect}, which matches no file"
76
87
  end
77
88