mutineer 1.2.0 → 1.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -0
- data/README.md +53 -4
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/changed_lines.rb +34 -2
- data/lib/mutineer/cli.rb +102 -81
- data/lib/mutineer/config.rb +157 -49
- data/lib/mutineer/coverage_map.rb +7 -30
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +8 -3
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +12 -3
- data/lib/mutineer/mutators/condition_forcing.rb +141 -0
- data/lib/mutineer/mutators/operator_assignment.rb +120 -0
- data/lib/mutineer/project.rb +47 -18
- data/lib/mutineer/project_path.rb +57 -0
- data/lib/mutineer/reporter.rb +18 -5
- data/lib/mutineer/result.rb +2 -2
- data/lib/mutineer/runner.rb +64 -20
- data/lib/mutineer/subject.rb +13 -2
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer.rb +2 -0
- metadata +4 -1
|
@@ -43,9 +43,10 @@ module Mutineer
|
|
|
43
43
|
#
|
|
44
44
|
# @param config [Mutineer::Config] run configuration (daemon set).
|
|
45
45
|
# @param operator_classes [Array<Class>] resolved operators.
|
|
46
|
-
# @return [Array(Mutineer::AggregateResult, Hash<String,String
|
|
46
|
+
# @return [Array(Mutineer::AggregateResult, Hash<String,String>, Hash)] aggregate,
|
|
47
|
+
# source map, and the {Runner.collect_jobs} extras.
|
|
47
48
|
def self.execute(config, operator_classes)
|
|
48
|
-
jobs, ignored_results, source_map = Runner.collect_jobs(config, operator_classes)
|
|
49
|
+
jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
|
|
49
50
|
jobs = Runner.filter_since(jobs, source_map, config) if config.since
|
|
50
51
|
abs_tests = config.tests.map { |t| File.expand_path(t, config.project_root) }
|
|
51
52
|
|
|
@@ -58,7 +59,7 @@ module Mutineer
|
|
|
58
59
|
# tool-side. A file a hard-killed run left in app/models breaks the app's own
|
|
59
60
|
# Zeitwerk boot, not just Mutineer's next run.
|
|
60
61
|
Runner.sweep_orphans(Runner.source_dirs(config), DAEMON_TEMP_GLOB)
|
|
61
|
-
return [AggregateResult.new(ignored_results), source_map]
|
|
62
|
+
return [AggregateResult.new(ignored_results), source_map, extras]
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# Build the coverage map once (app-side). nil when the build fails: runners
|
|
@@ -84,7 +85,7 @@ module Mutineer
|
|
|
84
85
|
run_serial(jobs, config, abs_tests, coverage_map, source_map)
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
[AggregateResult.new(results + ignored_results), source_map]
|
|
88
|
+
[AggregateResult.new(results + ignored_results), source_map, extras]
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
# Build the coverage map via a short-lived daemon (boots the app once, captures
|
data/lib/mutineer/isolation.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Mutineer
|
|
|
132
132
|
# namespace constants resolve exactly as the reload strategy would. A
|
|
133
133
|
# bare redefinition on the owner would collapse Module.nesting to [owner]
|
|
134
134
|
# and raise NameError on such constants (C2 scope-collapse).
|
|
135
|
-
keywords = nesting_keywords(subject.
|
|
135
|
+
keywords = nesting_keywords(subject.lexical_namespace)
|
|
136
136
|
prefix = keywords.map { |kw, name| "#{kw} #{name}" }.join("\n")
|
|
137
137
|
prefix += "\n" unless prefix.empty?
|
|
138
138
|
|
|
@@ -188,13 +188,18 @@ module Mutineer
|
|
|
188
188
|
# Foo], so an unqualified constant defined only in Foo would resolve under
|
|
189
189
|
# redefine but not reload — a strategy disagreement.
|
|
190
190
|
#
|
|
191
|
+
# A root-anchored element (`::Top`, #145) resolves from Object and keeps its
|
|
192
|
+
# `::` in the wrapper, so `module Outer; class ::Top` rebuilds nesting
|
|
193
|
+
# [Top, Outer] exactly as the source does.
|
|
194
|
+
#
|
|
191
195
|
# @api private
|
|
192
|
-
# @param namespace [Array<String>]
|
|
196
|
+
# @param namespace [Array<String>] class/module chain as written.
|
|
193
197
|
# @return [Array<[String, String]>] wrapper keywords and names.
|
|
194
198
|
def self.nesting_keywords(namespace)
|
|
195
199
|
mod = Object
|
|
196
200
|
namespace.map do |name|
|
|
197
|
-
|
|
201
|
+
# const_get resolves a compact "Foo::Bar" too
|
|
202
|
+
mod = name.start_with?("::") ? Object.const_get(name.delete_prefix("::")) : mod.const_get(name)
|
|
198
203
|
[mod.is_a?(Class) ? "class" : "module", name]
|
|
199
204
|
end
|
|
200
205
|
end
|
data/lib/mutineer/mutant_id.rb
CHANGED
|
@@ -3,16 +3,18 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# Content-based
|
|
6
|
+
# Content-based id for a mutant — NOT byte offsets. Pure function, reused
|
|
7
7
|
# by the Runner (matching the ignore list), the Reporter (emitting a copy-
|
|
8
8
|
# pasteable id per survivor), and #13 baseline gating (diffing id-sets run to
|
|
9
9
|
# run). `digest` is stdlib, so zero new deps.
|
|
10
10
|
#
|
|
11
|
-
# Offset-free by design: keyed on the subject's
|
|
12
|
-
# byte position) + operator + the normalized
|
|
13
|
-
# ordinal among same-(operator, token) twins
|
|
14
|
-
#
|
|
15
|
-
#
|
|
11
|
+
# Offset-free by design: keyed on the subject's project-relative file path +
|
|
12
|
+
# qualified_name (a method, not a byte position) + operator + the normalized
|
|
13
|
+
# mutated token + an occurrence ordinal among same-(operator, token) twins
|
|
14
|
+
# WITHIN the subject + (only when positive) the subject's ordinal among
|
|
15
|
+
# same-named subjects in its file. So it survives any edit outside the subject method,
|
|
16
|
+
# where raw start/end offsets shift on every edit earlier in the file and
|
|
17
|
+
# would silently stop matching. Moving or renaming the file changes the id.
|
|
16
18
|
module MutantId
|
|
17
19
|
module_function
|
|
18
20
|
|
|
@@ -20,6 +22,9 @@ module Mutineer
|
|
|
20
22
|
#
|
|
21
23
|
# NUL-joined so token delimiters (`||=`, spaces, `::`, `#`) can never collide
|
|
22
24
|
# with the separator; `SHA256[0,12]` gives a fixed-length, copy-pasteable key.
|
|
25
|
+
# The file path is part of the key, so the same qualified name in two files
|
|
26
|
+
# (an owner-less `def`, or a class reopened elsewhere) cannot collide. `path`
|
|
27
|
+
# is required so no caller can get the colliding legacy id by accident.
|
|
23
28
|
#
|
|
24
29
|
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in;
|
|
25
30
|
# its `qualified_name` anchors the id to a method rather than a byte position.
|
|
@@ -27,12 +32,17 @@ module Mutineer
|
|
|
27
32
|
# @param source [String] the full, unmutated source the mutation indexes into.
|
|
28
33
|
# @param occurrence [Integer] 0-based ordinal among twins sharing the same
|
|
29
34
|
# (operator, token) within the subject, disambiguating otherwise-identical mutants.
|
|
35
|
+
# @param path [String] the subject's file, normalized with {ProjectPath.relative}
|
|
36
|
+
# against the project root (an absolute real path when outside the root).
|
|
37
|
+
# @param subject_ordinal [Integer] 0-based ordinal among subjects in the same
|
|
38
|
+
# file sharing this qualified name (two owner-less `def index` in two DSL
|
|
39
|
+
# blocks). Hashed only when positive, so a subject whose name is unique in
|
|
40
|
+
# its file keeps the id it had without it.
|
|
30
41
|
# @return [String] a 12-character hex id, stable across edits outside the subject.
|
|
31
|
-
def for(subject, mutation, source, occurrence = 0)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)[0, 12]
|
|
42
|
+
def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
|
|
43
|
+
parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
|
|
44
|
+
parts << subject_ordinal if subject_ordinal.positive?
|
|
45
|
+
digest(parts)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
# Computes ids for a subject's full mutation list, in input order, assigning
|
|
@@ -42,17 +52,66 @@ module Mutineer
|
|
|
42
52
|
# @param subject [Mutineer::Subject] the subject the mutations belong to.
|
|
43
53
|
# @param source [String] the full, unmutated source for token normalization.
|
|
44
54
|
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
55
|
+
# @param path [String] the subject's normalized file path (see {.for}).
|
|
56
|
+
# @param subject_ordinal [Integer] the subject's ordinal among same-named
|
|
57
|
+
# subjects in its file (see {.for}).
|
|
45
58
|
# @return [Array<String>] one 12-character id per mutation, positionally aligned.
|
|
46
|
-
def for_subject(subject, source, mutations)
|
|
59
|
+
def for_subject(subject, source, mutations, path:, subject_ordinal: 0)
|
|
60
|
+
with_occurrences(mutations, source) do |m, occ|
|
|
61
|
+
self.for(subject, m, source, occ, path: path, subject_ordinal: subject_ordinal)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The pre-1.3 id: the {.for} formula without the path, so it collides across
|
|
66
|
+
# files. Kept only to match ignore entries and baselines stored in the old
|
|
67
|
+
# format; removed in 2.0.
|
|
68
|
+
#
|
|
69
|
+
# @param subject [Mutineer::Subject] the subject (method) the mutant lives in.
|
|
70
|
+
# @param mutation [Mutineer::Mutation] the atomic edit whose operator is hashed.
|
|
71
|
+
# @param source [String] the full, unmutated source the mutation indexes into.
|
|
72
|
+
# @param occurrence [Integer] 0-based ordinal among same-(operator, token) twins.
|
|
73
|
+
# @return [String] a 12-character hex id in the old format.
|
|
74
|
+
def legacy_for(subject, mutation, source, occurrence = 0)
|
|
75
|
+
digest([subject.qualified_name, mutation.operator,
|
|
76
|
+
normalized_token(mutation, source), occurrence])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# {.for_subject} for the pre-1.3 id format (see {.legacy_for}).
|
|
80
|
+
#
|
|
81
|
+
# @param subject [Mutineer::Subject] the subject the mutations belong to.
|
|
82
|
+
# @param source [String] the full, unmutated source for token normalization.
|
|
83
|
+
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
84
|
+
# @return [Array<String>] one old-format id per mutation, positionally aligned.
|
|
85
|
+
def legacy_for_subject(subject, source, mutations)
|
|
86
|
+
with_occurrences(mutations, source) { |m, occ| legacy_for(subject, m, source, occ) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Maps each mutation to the block's result, passing its 0-based occurrence
|
|
90
|
+
# among earlier mutations with the same (operator, token).
|
|
91
|
+
#
|
|
92
|
+
# @param mutations [Array<Mutineer::Mutation>] the subject's mutations, in order.
|
|
93
|
+
# @param source [String] the full, unmutated source for token normalization.
|
|
94
|
+
# @yieldparam mutation [Mutineer::Mutation] the current mutation.
|
|
95
|
+
# @yieldparam occurrence [Integer] its ordinal among same-(operator, token) twins.
|
|
96
|
+
# @return [Array] the block's results, positionally aligned.
|
|
97
|
+
def with_occurrences(mutations, source)
|
|
47
98
|
seen = Hash.new(0)
|
|
48
99
|
mutations.map do |m|
|
|
49
100
|
key = [m.operator, normalized_token(m, source)]
|
|
50
101
|
occ = seen[key]
|
|
51
102
|
seen[key] += 1
|
|
52
|
-
|
|
103
|
+
yield m, occ
|
|
53
104
|
end
|
|
54
105
|
end
|
|
55
106
|
|
|
107
|
+
# NUL-joins the id parts and returns the first 12 hex chars of their SHA256.
|
|
108
|
+
#
|
|
109
|
+
# @param parts [Array] the values that make up the id.
|
|
110
|
+
# @return [String] a 12-character hex id.
|
|
111
|
+
def digest(parts)
|
|
112
|
+
Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]
|
|
113
|
+
end
|
|
114
|
+
|
|
56
115
|
# Extracts the exact code being mutated, whitespace-collapsed — the same
|
|
57
116
|
# normalization the Reporter's `diff_for` uses for its token label.
|
|
58
117
|
#
|
|
@@ -17,6 +17,8 @@ require_relative "mutators/negation_removal"
|
|
|
17
17
|
require_relative "mutators/chain_link"
|
|
18
18
|
require_relative "mutators/operand_removal"
|
|
19
19
|
require_relative "mutators/array_literal"
|
|
20
|
+
require_relative "mutators/condition_forcing"
|
|
21
|
+
require_relative "mutators/operator_assignment"
|
|
20
22
|
|
|
21
23
|
module Mutineer
|
|
22
24
|
# Maps operator names to operator classes.
|
|
@@ -45,7 +47,10 @@ module Mutineer
|
|
|
45
47
|
"negation_removal" => Mutators::NegationRemoval,
|
|
46
48
|
"chain_link" => Mutators::ChainLink,
|
|
47
49
|
"operand_removal" => Mutators::OperandRemoval,
|
|
48
|
-
"array_literal" => Mutators::ArrayLiteral
|
|
50
|
+
"array_literal" => Mutators::ArrayLiteral,
|
|
51
|
+
"condition_true" => Mutators::ConditionTrue,
|
|
52
|
+
"condition_false" => Mutators::ConditionFalse,
|
|
53
|
+
"operator_assignment" => Mutators::OperatorAssignment
|
|
49
54
|
}.freeze
|
|
50
55
|
|
|
51
56
|
# The default Tier-1 operator set.
|
|
@@ -53,7 +58,8 @@ module Mutineer
|
|
|
53
58
|
# Tier-2 operators that remain opt-in.
|
|
54
59
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
55
60
|
safe_navigation range negation_removal
|
|
56
|
-
chain_link operand_removal array_literal
|
|
61
|
+
chain_link operand_removal array_literal
|
|
62
|
+
condition_true condition_false operator_assignment].freeze
|
|
57
63
|
|
|
58
64
|
# Short human-readable descriptions for each operator.
|
|
59
65
|
DESCRIPTIONS = {
|
|
@@ -73,7 +79,10 @@ module Mutineer
|
|
|
73
79
|
"negation_removal" => "!x, not x -> x",
|
|
74
80
|
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
75
81
|
"operand_removal" => "a && b -> a, b",
|
|
76
|
-
"array_literal" => "[a, b] -> []"
|
|
82
|
+
"array_literal" => "[a, b] -> []",
|
|
83
|
+
"condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
|
|
84
|
+
"condition_false" => "if/elsif/unless/ternary/modifier/case-in guard condition -> false",
|
|
85
|
+
"operator_assignment" => "+= <-> -=, *= <-> /=, %= -> *=, **= -> *= (not ||=, &&=)"
|
|
77
86
|
}.freeze
|
|
78
87
|
|
|
79
88
|
# Resolves operator names to classes.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Shared base of the condition-forcing mutators (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces the condition of an `if`, `elsif`, `unless`, ternary, modifier
|
|
10
|
+
# guard (`x if y`) or `case`/`in` guard with a literal, so its branch always
|
|
11
|
+
# runs or never runs. {ConditionTrue} forces `true` and {ConditionFalse}
|
|
12
|
+
# forces `false`; one mutation per condition each.
|
|
13
|
+
#
|
|
14
|
+
# A condition that is already `true`, `false` or `nil`, even in
|
|
15
|
+
# parentheses, is left alone: forcing it changes nothing or repeats the
|
|
16
|
+
# `boolean_literal` flip. This holds even when `boolean_literal` does not run.
|
|
17
|
+
# A condition that holds a heredoc is left alone too: the heredoc body lies
|
|
18
|
+
# outside the condition, so it would stay behind as code. A condition that
|
|
19
|
+
# assigns a variable keeps its code and only its value is forced.
|
|
20
|
+
#
|
|
21
|
+
# The never-runs side of an else-less conditional can be the same program
|
|
22
|
+
# as the `nil` that `statement_removal` or `return_nil` puts in place of the
|
|
23
|
+
# whole conditional. It is still made: dropping it would make the mutants
|
|
24
|
+
# of one operator depend on which other operators run, and on what the user
|
|
25
|
+
# suppressed for them. The two then get the same verdict.
|
|
26
|
+
#
|
|
27
|
+
# `condition_negation` wraps the condition in `!( ... )`, which neither
|
|
28
|
+
# forced value is.
|
|
29
|
+
class ConditionForcing < Base
|
|
30
|
+
# Condition nodes that `boolean_literal` already mutates.
|
|
31
|
+
LITERALS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
|
|
32
|
+
|
|
33
|
+
# Visits if nodes: `if`, `elsif`, a ternary, a modifier `if` and an
|
|
34
|
+
# `in ... if` guard.
|
|
35
|
+
#
|
|
36
|
+
# @param node [Prism::IfNode] node to inspect.
|
|
37
|
+
# @return [void]
|
|
38
|
+
def visit_if_node(node)
|
|
39
|
+
force(node)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Visits unless nodes: `unless`, a modifier `unless` and an
|
|
44
|
+
# `in ... unless` guard.
|
|
45
|
+
#
|
|
46
|
+
# @param node [Prism::UnlessNode] node to inspect.
|
|
47
|
+
# @return [void]
|
|
48
|
+
def visit_unless_node(node)
|
|
49
|
+
force(node)
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Nested method definitions are discovered as their own subjects; do not
|
|
54
|
+
# recurse into them (prevents double-counting their conditions).
|
|
55
|
+
#
|
|
56
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
57
|
+
# @return [void]
|
|
58
|
+
def visit_def_node(node); end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Emits the forced condition unless the condition is a literal or holds a
|
|
63
|
+
# heredoc, whose body would stay behind as code.
|
|
64
|
+
#
|
|
65
|
+
# @param node [Prism::IfNode, Prism::UnlessNode] the conditional.
|
|
66
|
+
# @return [void]
|
|
67
|
+
def force(node)
|
|
68
|
+
predicate = node.predicate
|
|
69
|
+
return if LITERALS.include?(unwrap(predicate).class) || heredoc?(predicate)
|
|
70
|
+
|
|
71
|
+
loc = predicate.location
|
|
72
|
+
@mutations << Mutation.new(
|
|
73
|
+
start_offset: loc.start_offset,
|
|
74
|
+
end_offset: loc.end_offset,
|
|
75
|
+
replacement: replacement(predicate),
|
|
76
|
+
operator: self.class::OPERATOR
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the forced value for the condition, always in parentheses, so it
|
|
81
|
+
# cannot fuse with a keyword or a `?` next to it: `x if@a` becomes
|
|
82
|
+
# `x if(true)` and `@a?1:2` becomes `(true)?1:2`. A condition that assigns
|
|
83
|
+
# a variable keeps its code, so later reads still see the variable:
|
|
84
|
+
# `(m = x; true)`.
|
|
85
|
+
#
|
|
86
|
+
# @param predicate [Prism::Node] the condition.
|
|
87
|
+
# @return [String] the replacement source.
|
|
88
|
+
def replacement(predicate)
|
|
89
|
+
value = self.class::VALUE.to_s
|
|
90
|
+
writes?(predicate) ? "(#{predicate.slice}; #{value})" : "(#{value})"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns whether the node writes a variable or constant anywhere inside
|
|
94
|
+
# it: a local, instance, class or global variable, or a constant, in a
|
|
95
|
+
# plain, compound, multiple or pattern write, or a named regex capture.
|
|
96
|
+
#
|
|
97
|
+
# @param node [Prism::Node] the node to inspect.
|
|
98
|
+
# @return [Boolean] true when a write or target node is inside.
|
|
99
|
+
def writes?(node)
|
|
100
|
+
return true if node.type.name.end_with?("_write_node", "_target_node")
|
|
101
|
+
|
|
102
|
+
node.compact_child_nodes.any? { |child| writes?(child) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the node inside parentheses that hold exactly one expression,
|
|
106
|
+
# so `(true)` and `((true))` read as `true`.
|
|
107
|
+
#
|
|
108
|
+
# @param node [Prism::Node] the condition.
|
|
109
|
+
# @return [Prism::Node] the innermost wrapped node, or `node` itself.
|
|
110
|
+
def unwrap(node)
|
|
111
|
+
body = node.body if node.is_a?(Prism::ParenthesesNode)
|
|
112
|
+
body = body.body.first if body.is_a?(Prism::StatementsNode) && body.body.size == 1
|
|
113
|
+
body && !body.is_a?(Prism::StatementsNode) ? unwrap(body) : node
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Condition-forcing mutator: the condition becomes `true` (Tier-2).
|
|
118
|
+
#
|
|
119
|
+
# An `if` branch always runs; an `unless` branch never runs. The mutant
|
|
120
|
+
# survives when no test runs the code with the condition false (for an
|
|
121
|
+
# `if`) or true (for an `unless`) and checks what changes.
|
|
122
|
+
class ConditionTrue < ConditionForcing
|
|
123
|
+
# The literal the condition is replaced with.
|
|
124
|
+
VALUE = true
|
|
125
|
+
# The operator name mutations carry.
|
|
126
|
+
OPERATOR = :condition_true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Condition-forcing mutator: the condition becomes `false` (Tier-2).
|
|
130
|
+
#
|
|
131
|
+
# An `if` branch never runs; an `unless` branch always runs. The mutant
|
|
132
|
+
# survives when no test needs the branch the condition guards (for an
|
|
133
|
+
# `if`) or skips it (for an `unless`).
|
|
134
|
+
class ConditionFalse < ConditionForcing
|
|
135
|
+
# The literal the condition is replaced with.
|
|
136
|
+
VALUE = false
|
|
137
|
+
# The operator name mutations carry.
|
|
138
|
+
OPERATOR = :condition_false
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Operator-assignment mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Swaps the operator of a compound assignment, one mutation per
|
|
10
|
+
# occurrence: `t += x` -> `t -= x`. Prism parses `t += x` as its own
|
|
11
|
+
# node, not as a CallNode, so the arithmetic mutator never sees it.
|
|
12
|
+
#
|
|
13
|
+
# The mapping is the same as for arithmetic. Other compound operators
|
|
14
|
+
# (`||=`, `&&=`, `|=`, `<<=` and the rest) are not changed.
|
|
15
|
+
class OperatorAssignment < Base
|
|
16
|
+
# Token replacements for compound arithmetic operators.
|
|
17
|
+
REPLACEMENTS = {
|
|
18
|
+
:+ => "-=", :- => "+=", :* => "/=", :/ => "*=", :% => "*=", :** => "*="
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Visits `t += x`.
|
|
22
|
+
#
|
|
23
|
+
# @param node [Prism::LocalVariableOperatorWriteNode] node to inspect.
|
|
24
|
+
# @return [void]
|
|
25
|
+
def visit_local_variable_operator_write_node(node)
|
|
26
|
+
emit(node)
|
|
27
|
+
super # the value can hold its own compound assignment
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Visits `@t += x`.
|
|
31
|
+
#
|
|
32
|
+
# @param node [Prism::InstanceVariableOperatorWriteNode] node to inspect.
|
|
33
|
+
# @return [void]
|
|
34
|
+
def visit_instance_variable_operator_write_node(node)
|
|
35
|
+
emit(node)
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Visits `@@t += x`.
|
|
40
|
+
#
|
|
41
|
+
# @param node [Prism::ClassVariableOperatorWriteNode] node to inspect.
|
|
42
|
+
# @return [void]
|
|
43
|
+
def visit_class_variable_operator_write_node(node)
|
|
44
|
+
emit(node)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Visits `$t += x`.
|
|
49
|
+
#
|
|
50
|
+
# @param node [Prism::GlobalVariableOperatorWriteNode] node to inspect.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def visit_global_variable_operator_write_node(node)
|
|
53
|
+
emit(node)
|
|
54
|
+
super
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Visits `T += x`.
|
|
58
|
+
#
|
|
59
|
+
# @param node [Prism::ConstantOperatorWriteNode] node to inspect.
|
|
60
|
+
# @return [void]
|
|
61
|
+
def visit_constant_operator_write_node(node)
|
|
62
|
+
emit(node)
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Visits `A::T += x` and `::T += x`.
|
|
67
|
+
#
|
|
68
|
+
# @param node [Prism::ConstantPathOperatorWriteNode] node to inspect.
|
|
69
|
+
# @return [void]
|
|
70
|
+
def visit_constant_path_operator_write_node(node)
|
|
71
|
+
emit(node)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Visits `a.b += x` and `a&.b += x`.
|
|
76
|
+
#
|
|
77
|
+
# @param node [Prism::CallOperatorWriteNode] node to inspect.
|
|
78
|
+
# @return [void]
|
|
79
|
+
def visit_call_operator_write_node(node)
|
|
80
|
+
emit(node)
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Visits `a[i] += x`.
|
|
85
|
+
#
|
|
86
|
+
# @param node [Prism::IndexOperatorWriteNode] node to inspect.
|
|
87
|
+
# @return [void]
|
|
88
|
+
def visit_index_operator_write_node(node)
|
|
89
|
+
emit(node)
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
94
|
+
# its own, so a visit here counts its compound assignments twice.
|
|
95
|
+
#
|
|
96
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
97
|
+
# @return [void]
|
|
98
|
+
def visit_def_node(node); end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Emits a mutation when the node's operator has a replacement.
|
|
103
|
+
#
|
|
104
|
+
# @param node [Prism::Node] a node with `binary_operator` and `binary_operator_loc`.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def emit(node)
|
|
107
|
+
replacement = REPLACEMENTS[node.binary_operator]
|
|
108
|
+
return unless replacement
|
|
109
|
+
|
|
110
|
+
loc = node.binary_operator_loc
|
|
111
|
+
@mutations << Mutation.new(
|
|
112
|
+
start_offset: loc.start_offset,
|
|
113
|
+
end_offset: loc.end_offset,
|
|
114
|
+
replacement: replacement,
|
|
115
|
+
operator: :operator_assignment
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
data/lib/mutineer/project.rb
CHANGED
|
@@ -36,23 +36,26 @@ module Mutineer
|
|
|
36
36
|
def initialize(file)
|
|
37
37
|
@file = file
|
|
38
38
|
@namespace_stack = []
|
|
39
|
+
@lexical_stack = [] # class/module names as written, `::X` kept (#145)
|
|
39
40
|
@subjects = []
|
|
40
41
|
@singleton_depth = 0
|
|
41
42
|
@module_function_active = false # bareword `module_function` seen in this module body
|
|
42
|
-
@module_function_names = [] #
|
|
43
|
+
@module_function_names = [] # [namespace, name] from `module_function :a` / `module_function def` (#98)
|
|
43
44
|
super()
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# Promote `module_function :name` / `module_function def name` subjects to
|
|
47
48
|
# singleton after the full walk — the naming call may appear before or after
|
|
48
|
-
# the def, so it can't be decided at visit_def_node time (#20).
|
|
49
|
+
# the def, so it can't be decided at visit_def_node time (#20). Only methods
|
|
50
|
+
# of the module that made the call are promoted (#98); namespaces compare
|
|
51
|
+
# joined, since `module A::B` and nested `module A; module B` differ as arrays.
|
|
49
52
|
#
|
|
50
53
|
# @return [void]
|
|
51
54
|
def promote_module_functions!
|
|
52
55
|
return if @module_function_names.empty?
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
@subjects.each { |s| s.singleton = true if
|
|
57
|
+
named = @module_function_names.to_set
|
|
58
|
+
@subjects.each { |s| s.singleton = true if named.include?([s.namespace.join("::"), s.name]) }
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
# Visits class nodes and tracks namespace nesting.
|
|
@@ -60,12 +63,7 @@ module Mutineer
|
|
|
60
63
|
# @param node [Prism::ClassNode] class node.
|
|
61
64
|
# @return [void]
|
|
62
65
|
def visit_class_node(node)
|
|
63
|
-
|
|
64
|
-
saved = @module_function_active
|
|
65
|
-
@module_function_active = false # module_function state does not cross a class boundary
|
|
66
|
-
super
|
|
67
|
-
@module_function_active = saved
|
|
68
|
-
@namespace_stack.pop
|
|
66
|
+
with_namespace(node.constant_path) { super }
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
# Visits module nodes and tracks namespace nesting.
|
|
@@ -73,12 +71,7 @@ module Mutineer
|
|
|
73
71
|
# @param node [Prism::ModuleNode] module node.
|
|
74
72
|
# @return [void]
|
|
75
73
|
def visit_module_node(node)
|
|
76
|
-
|
|
77
|
-
saved = @module_function_active
|
|
78
|
-
@module_function_active = false # each module body starts without module_function active
|
|
79
|
-
super
|
|
80
|
-
@module_function_active = saved
|
|
81
|
-
@namespace_stack.pop
|
|
74
|
+
with_namespace(node.constant_path) { super }
|
|
82
75
|
end
|
|
83
76
|
|
|
84
77
|
# Track `module_function` so its methods are recorded as singletons (#20) —
|
|
@@ -94,9 +87,10 @@ module Mutineer
|
|
|
94
87
|
if args.empty?
|
|
95
88
|
@module_function_active = true
|
|
96
89
|
else
|
|
90
|
+
namespace = @namespace_stack.join("::")
|
|
97
91
|
args.each do |arg|
|
|
98
|
-
@module_function_names << arg.value.to_sym if arg.is_a?(Prism::SymbolNode)
|
|
99
|
-
@module_function_names << arg.name if arg.is_a?(Prism::DefNode)
|
|
92
|
+
@module_function_names << [namespace, arg.value.to_sym] if arg.is_a?(Prism::SymbolNode)
|
|
93
|
+
@module_function_names << [namespace, arg.name] if arg.is_a?(Prism::DefNode)
|
|
100
94
|
end
|
|
101
95
|
end
|
|
102
96
|
end
|
|
@@ -127,6 +121,7 @@ module Mutineer
|
|
|
127
121
|
@subjects << Subject.new(
|
|
128
122
|
file: @file,
|
|
129
123
|
namespace: @namespace_stack.dup,
|
|
124
|
+
lexical: @lexical_stack.dup,
|
|
130
125
|
name: node.name,
|
|
131
126
|
singleton: !node.receiver.nil? || @singleton_depth.positive? || @module_function_active,
|
|
132
127
|
def_node: node
|
|
@@ -136,6 +131,40 @@ module Mutineer
|
|
|
136
131
|
|
|
137
132
|
private
|
|
138
133
|
|
|
134
|
+
# Runs the block with `path` pushed as the current namespace. A
|
|
135
|
+
# root-anchored path (`module ::X` / `class ::X`) names the top-level X,
|
|
136
|
+
# not X nested in the enclosing scope, so the namespace restarts there.
|
|
137
|
+
# Bareword `module_function` state does not cross a class or module
|
|
138
|
+
# boundary: each body starts without it, and the outer state returns after.
|
|
139
|
+
#
|
|
140
|
+
# @param path [Prism::Node] the class/module constant path.
|
|
141
|
+
# @yield the class or module body visit.
|
|
142
|
+
# @return [void]
|
|
143
|
+
def with_namespace(path)
|
|
144
|
+
saved_stack = @namespace_stack
|
|
145
|
+
saved_lexical = @lexical_stack
|
|
146
|
+
saved_active = @module_function_active
|
|
147
|
+
name = extract_constant_name(path)
|
|
148
|
+
root = root_anchored?(path)
|
|
149
|
+
@namespace_stack = root ? [name] : saved_stack + [name]
|
|
150
|
+
@lexical_stack = saved_lexical + [root ? "::#{name}" : name]
|
|
151
|
+
@module_function_active = false
|
|
152
|
+
yield
|
|
153
|
+
ensure
|
|
154
|
+
@namespace_stack = saved_stack
|
|
155
|
+
@lexical_stack = saved_lexical
|
|
156
|
+
@module_function_active = saved_active
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# True when a constant path starts with `::` (e.g. `::X` or `::A::B`).
|
|
160
|
+
#
|
|
161
|
+
# @param node [Prism::Node] constant path node.
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def root_anchored?(node)
|
|
164
|
+
node = node.parent while node.is_a?(Prism::ConstantPathNode) && node.parent
|
|
165
|
+
node.is_a?(Prism::ConstantPathNode)
|
|
166
|
+
end
|
|
167
|
+
|
|
139
168
|
# Extracts a constant name from a Prism constant node.
|
|
140
169
|
#
|
|
141
170
|
# @api private
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Realpath-based path normalization against a project root. Shared by the
|
|
5
|
+
# coverage cache (map keys) and mutant ids (the path hashed into each id), so
|
|
6
|
+
# `lib/x.rb`, `./lib/x.rb`, an absolute path and a path through a symlinked
|
|
7
|
+
# root all resolve to the same key. Realpaths on both sides also absorb the
|
|
8
|
+
# macOS `/var` vs `/private/var` alias.
|
|
9
|
+
module ProjectPath
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Path of `path` relative to the real project root. A path outside the root
|
|
13
|
+
# comes back as its absolute real path.
|
|
14
|
+
#
|
|
15
|
+
# @param path [String] relative (to `root`) or absolute path.
|
|
16
|
+
# @param root [String] project root.
|
|
17
|
+
# @return [String] root-relative path, or an absolute path when outside `root`.
|
|
18
|
+
def relative(path, root)
|
|
19
|
+
abs = absolute(path, root)
|
|
20
|
+
real_root = root_real(root)
|
|
21
|
+
prefix = real_root.end_with?("/") ? real_root : "#{real_root}/"
|
|
22
|
+
return abs unless abs.start_with?(prefix)
|
|
23
|
+
|
|
24
|
+
abs.delete_prefix(prefix)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Expands `path` against `root`, resolved to its real path when it exists.
|
|
28
|
+
#
|
|
29
|
+
# @param path [String] relative (to `root`) or absolute path.
|
|
30
|
+
# @param root [String] project root.
|
|
31
|
+
# @return [String] absolute path.
|
|
32
|
+
def absolute(path, root)
|
|
33
|
+
# Join, don't expand: File.expand_path collapses `..` textually, before any
|
|
34
|
+
# symlink is followed, so `link/../x.rb` would name the wrong file. The file
|
|
35
|
+
# system resolves `..` physically in File.realpath.
|
|
36
|
+
# A leading `~` names the home directory (as File.expand_path reads it), so
|
|
37
|
+
# expand it rather than joining it under the root.
|
|
38
|
+
raw = if File.absolute_path?(path) then path
|
|
39
|
+
elsif path.start_with?("~") then File.expand_path(path)
|
|
40
|
+
else File.join(File.expand_path(root), path)
|
|
41
|
+
end
|
|
42
|
+
File.exist?(raw) ? File.realpath(raw) : File.expand_path(raw)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Canonical project root (`/var` vs `/private/var`). Any file system error
|
|
46
|
+
# (missing, unreadable, a symlink loop) falls back to the expanded path, so
|
|
47
|
+
# a caller such as the CLI's run-root warning never crashes on it.
|
|
48
|
+
#
|
|
49
|
+
# @param root [String] project root.
|
|
50
|
+
# @return [String] realpath of the root when it exists, else its expanded path.
|
|
51
|
+
def root_real(root)
|
|
52
|
+
File.realpath(File.expand_path(root))
|
|
53
|
+
rescue SystemCallError
|
|
54
|
+
File.expand_path(root)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|