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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +772 -0
- data/README.md +221 -1072
- data/lib/saboteur/attribute_finder.rb +113 -0
- data/lib/saboteur/cli/exit_status.rb +32 -1
- data/lib/saboteur/cli/help_options.rb +2 -1
- data/lib/saboteur/cli/history_command.rb +135 -0
- data/lib/saboteur/cli/presets.rb +47 -3
- data/lib/saboteur/cli/run_budget_options.rb +3 -1
- data/lib/saboteur/cli/selection_options.rb +31 -0
- data/lib/saboteur/cli/shard_options.rb +17 -0
- data/lib/saboteur/cli/source_set.rb +35 -0
- data/lib/saboteur/cli/survivors_command.rb +157 -0
- data/lib/saboteur/cli.rb +74 -55
- data/lib/saboteur/config.rb +102 -6
- data/lib/saboteur/config_file.rb +19 -7
- data/lib/saboteur/constant_reads.rb +182 -0
- data/lib/saboteur/constant_subject.rb +70 -0
- data/lib/saboteur/constant_subject_finder.rb +281 -0
- data/lib/saboteur/coverage_reads.rb +29 -0
- data/lib/saboteur/custom_map.rb +12 -1
- data/lib/saboteur/dirty_tree_warning.rb +130 -0
- data/lib/saboteur/doctor.rb +13 -0
- data/lib/saboteur/engine/baseline_guard.rb +135 -21
- data/lib/saboteur/engine/boot.rb +11 -3
- data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
- data/lib/saboteur/engine/candidate_selection.rb +20 -0
- data/lib/saboteur/engine/candidates.rb +43 -3
- data/lib/saboteur/engine/coverage_store.rb +58 -14
- data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +19 -1
- data/lib/saboteur/engine/distribution.rb +10 -0
- data/lib/saboteur/engine/early_stop_recording.rb +3 -2
- data/lib/saboteur/engine/gated_run.rb +37 -0
- data/lib/saboteur/engine/load_coverage.rb +63 -0
- data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
- data/lib/saboteur/engine/pipelines.rb +115 -0
- data/lib/saboteur/engine/result_cache.rb +65 -8
- data/lib/saboteur/engine/run_budget.rb +32 -2
- data/lib/saboteur/engine/sandbox_policy.rb +59 -3
- data/lib/saboteur/engine/scheduler.rb +131 -17
- data/lib/saboteur/engine/selection_announcement.rb +59 -0
- data/lib/saboteur/engine/shard.rb +69 -0
- data/lib/saboteur/engine/subject_selection.rb +380 -21
- data/lib/saboteur/engine/test_guess.rb +407 -27
- data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
- data/lib/saboteur/engine.rb +103 -129
- data/lib/saboteur/execution/external_runner.rb +489 -0
- data/lib/saboteur/execution/fiddle_loader.rb +34 -0
- data/lib/saboteur/execution/marker_sweep.rb +80 -0
- data/lib/saboteur/execution/peak_memory.rb +28 -0
- data/lib/saboteur/execution/process_table.rb +19 -3
- data/lib/saboteur/execution/process_tree.rb +65 -5
- data/lib/saboteur/execution/reevaluator.rb +75 -12
- data/lib/saboteur/execution/runner.rb +46 -7
- data/lib/saboteur/execution/sandbox.rb +25 -6
- data/lib/saboteur/execution/seatbelt.rb +233 -0
- data/lib/saboteur/execution/supervisor.rb +29 -11
- data/lib/saboteur/execution/test_command_shape.rb +149 -0
- data/lib/saboteur/execution/worker_pool.rb +23 -1
- data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
- data/lib/saboteur/gates.rb +43 -2
- data/lib/saboteur/help.rb +60 -9
- data/lib/saboteur/ignore_list.rb +29 -6
- data/lib/saboteur/integration/rspec.rb +223 -5
- data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
- data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
- data/lib/saboteur/mutation_source/builtin.rb +28 -5
- data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
- data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
- data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
- data/lib/saboteur/mutation_source/element_removal.rb +73 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
- data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/method_swap.rb +148 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
- data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
- data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
- data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
- data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
- data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
- data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
- data/lib/saboteur/preview.rb +125 -12
- data/lib/saboteur/refinement_activations.rb +60 -0
- data/lib/saboteur/reporter/agent.rb +9 -1
- data/lib/saboteur/reporter/agent_commands.rb +10 -3
- data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
- data/lib/saboteur/reporter/agent_summary.rb +4 -3
- data/lib/saboteur/reporter/findings.rb +5 -2
- data/lib/saboteur/reporter/history_policy.rb +11 -0
- data/lib/saboteur/reporter/history_reader.rb +47 -0
- data/lib/saboteur/reporter/html.rb +26 -4
- data/lib/saboteur/reporter/mutation_text.rb +6 -0
- data/lib/saboteur/reporter/stryker.rb +27 -3
- data/lib/saboteur/reporter/survivor_classification.rb +101 -0
- data/lib/saboteur/reporter/survivor_list.rb +15 -9
- data/lib/saboteur/reporter/terminal.rb +56 -8
- data/lib/saboteur/result.rb +19 -3
- data/lib/saboteur/selection/coverage_map.rb +120 -10
- data/lib/saboteur/selection/dependency_shield.rb +80 -0
- data/lib/saboteur/selection/external_test_map.rb +27 -0
- data/lib/saboteur/selection/git_diff.rb +93 -11
- data/lib/saboteur/selection/layout_only_lines.rb +80 -0
- data/lib/saboteur/selection/recorder.rb +22 -4
- data/lib/saboteur/selection/source_scope.rb +22 -0
- data/lib/saboteur/subject.rb +42 -7
- data/lib/saboteur/subject_finder.rb +173 -29
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +38 -15
- data/lib/saboteur.rb +14 -0
- 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
|
data/lib/saboteur/custom_map.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|