mutineer 1.1.0 → 1.3.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 +115 -0
- data/README.md +54 -5
- data/lib/mutineer/baseline.rb +74 -11
- data/lib/mutineer/cli.rb +71 -4
- data/lib/mutineer/coverage_map.rb +37 -40
- data/lib/mutineer/daemon_backend.rb +5 -4
- data/lib/mutineer/isolation.rb +9 -4
- data/lib/mutineer/mutant_id.rb +72 -13
- data/lib/mutineer/mutator_registry.rb +9 -3
- data/lib/mutineer/mutators/array_literal.rb +51 -0
- data/lib/mutineer/mutators/base.rb +15 -0
- data/lib/mutineer/mutators/operand_removal.rb +65 -0
- data/lib/mutineer/pairing.rb +10 -5
- 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 +90 -26
- 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
|
@@ -99,7 +99,7 @@ module Mutineer
|
|
|
99
99
|
# @param source_file [String] original source file path.
|
|
100
100
|
# @return [Object] whatever `load` returns.
|
|
101
101
|
def self.apply_whole_file(mutated, source_file)
|
|
102
|
-
Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(source_file)) do |f|
|
|
102
|
+
Tempfile.create(["mutineer_mutant", ".rb"], File.dirname(File.expand_path(source_file))) do |f|
|
|
103
103
|
f.write(mutated)
|
|
104
104
|
f.flush
|
|
105
105
|
load f.path
|
|
@@ -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
|
#
|
|
@@ -15,6 +15,8 @@ require_relative "mutators/safe_navigation"
|
|
|
15
15
|
require_relative "mutators/range_literal"
|
|
16
16
|
require_relative "mutators/negation_removal"
|
|
17
17
|
require_relative "mutators/chain_link"
|
|
18
|
+
require_relative "mutators/operand_removal"
|
|
19
|
+
require_relative "mutators/array_literal"
|
|
18
20
|
|
|
19
21
|
module Mutineer
|
|
20
22
|
# Maps operator names to operator classes.
|
|
@@ -41,7 +43,9 @@ module Mutineer
|
|
|
41
43
|
"safe_navigation" => Mutators::SafeNavigation,
|
|
42
44
|
"range" => Mutators::RangeLiteral,
|
|
43
45
|
"negation_removal" => Mutators::NegationRemoval,
|
|
44
|
-
"chain_link" => Mutators::ChainLink
|
|
46
|
+
"chain_link" => Mutators::ChainLink,
|
|
47
|
+
"operand_removal" => Mutators::OperandRemoval,
|
|
48
|
+
"array_literal" => Mutators::ArrayLiteral
|
|
45
49
|
}.freeze
|
|
46
50
|
|
|
47
51
|
# The default Tier-1 operator set.
|
|
@@ -49,7 +53,7 @@ module Mutineer
|
|
|
49
53
|
# Tier-2 operators that remain opt-in.
|
|
50
54
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
|
|
51
55
|
safe_navigation range negation_removal
|
|
52
|
-
chain_link].freeze
|
|
56
|
+
chain_link operand_removal array_literal].freeze
|
|
53
57
|
|
|
54
58
|
# Short human-readable descriptions for each operator.
|
|
55
59
|
DESCRIPTIONS = {
|
|
@@ -67,7 +71,9 @@ module Mutineer
|
|
|
67
71
|
"safe_navigation" => "&. -> .",
|
|
68
72
|
"range" => ".. <-> ...",
|
|
69
73
|
"negation_removal" => "!x, not x -> x",
|
|
70
|
-
"chain_link" => "drop one call from a chain: a.b.c -> a.c"
|
|
74
|
+
"chain_link" => "drop one call from a chain: a.b.c -> a.c",
|
|
75
|
+
"operand_removal" => "a && b -> a, b",
|
|
76
|
+
"array_literal" => "[a, b] -> []"
|
|
71
77
|
}.freeze
|
|
72
78
|
|
|
73
79
|
# Resolves operator names to classes.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Array-literal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces a non-empty array literal with an empty one, one mutation per
|
|
10
|
+
# literal: `[a, b]` and `%i[a b]` become `[]`. The mutant survives when no
|
|
11
|
+
# test checks the contents of the array.
|
|
12
|
+
class ArrayLiteral < Base
|
|
13
|
+
# Visits array nodes and emits array-literal mutations.
|
|
14
|
+
#
|
|
15
|
+
# @param node [Prism::ArrayNode] array node to inspect.
|
|
16
|
+
# @return [void]
|
|
17
|
+
def visit_array_node(node)
|
|
18
|
+
emit(node)
|
|
19
|
+
super # nested arrays each get their own mutation
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
23
|
+
# its own, so a visit here counts its arrays twice.
|
|
24
|
+
#
|
|
25
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
26
|
+
# @return [void]
|
|
27
|
+
def visit_def_node(node); end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Emits a mutation for a non-empty array with brackets.
|
|
32
|
+
#
|
|
33
|
+
# The operator skips an implicit array (`x = 1, 2`), because it has no
|
|
34
|
+
# brackets. It also skips an array that holds a heredoc, because the
|
|
35
|
+
# heredoc body stays behind as code.
|
|
36
|
+
#
|
|
37
|
+
# @param node [Prism::ArrayNode] array node to inspect.
|
|
38
|
+
# @return [void]
|
|
39
|
+
def emit(node)
|
|
40
|
+
return if node.opening_loc.nil? || node.elements.empty? || heredoc?(node)
|
|
41
|
+
|
|
42
|
+
@mutations << Mutation.new(
|
|
43
|
+
start_offset: node.location.start_offset,
|
|
44
|
+
end_offset: node.location.end_offset,
|
|
45
|
+
replacement: "[]",
|
|
46
|
+
operator: :array_literal
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -26,6 +26,21 @@ module Mutineer
|
|
|
26
26
|
subject.def_node.body&.accept(self)
|
|
27
27
|
@mutations
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Returns whether a node is, or contains, a heredoc.
|
|
33
|
+
#
|
|
34
|
+
# A heredoc's body lies outside its node's byte range. A mutation that
|
|
35
|
+
# deletes the node leaves the body behind as code, so the mutant
|
|
36
|
+
# always raises.
|
|
37
|
+
#
|
|
38
|
+
# @param node [Prism::Node] node to inspect.
|
|
39
|
+
# @return [Boolean] true when a heredoc is inside the node.
|
|
40
|
+
def heredoc?(node)
|
|
41
|
+
(node.respond_to?(:heredoc?) && node.heredoc?) ||
|
|
42
|
+
node.compact_child_nodes.any? { |child| heredoc?(child) }
|
|
43
|
+
end
|
|
29
44
|
end
|
|
30
45
|
end
|
|
31
46
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Mutineer
|
|
6
|
+
module Mutators
|
|
7
|
+
# Operand-removal mutator (Tier-2).
|
|
8
|
+
#
|
|
9
|
+
# Replaces a boolean expression with one of its operands, two mutations
|
|
10
|
+
# per `&&`, `||`, `and` or `or`: `a && b` becomes `(a)` and `(b)`. The
|
|
11
|
+
# parentheses keep the precedence of `and` and `or`. A surviving mutant
|
|
12
|
+
# shows that no test needs the operand that was removed.
|
|
13
|
+
class OperandRemoval < Base
|
|
14
|
+
# Node types for jumps. A jump in a value context does not parse, so
|
|
15
|
+
# the operator never keeps a jump alone.
|
|
16
|
+
JUMPS = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode, Prism::RetryNode].freeze
|
|
17
|
+
|
|
18
|
+
# Visits `and` nodes.
|
|
19
|
+
#
|
|
20
|
+
# @param node [Prism::AndNode] node to inspect.
|
|
21
|
+
# @return [void]
|
|
22
|
+
def visit_and_node(node)
|
|
23
|
+
emit(node)
|
|
24
|
+
super # nested connectors (a && b && c) each get their own mutations
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Visits `or` nodes.
|
|
28
|
+
#
|
|
29
|
+
# @param node [Prism::OrNode] node to inspect.
|
|
30
|
+
# @return [void]
|
|
31
|
+
def visit_or_node(node)
|
|
32
|
+
emit(node)
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
37
|
+
# its own, so a visit here counts its connectors twice.
|
|
38
|
+
#
|
|
39
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
40
|
+
# @return [void]
|
|
41
|
+
def visit_def_node(node); end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Emits one mutation that keeps the left operand, then one that keeps
|
|
46
|
+
# the right operand. The order is fixed, because the two mutants share
|
|
47
|
+
# a token and their occurrence ordinals tell them apart.
|
|
48
|
+
#
|
|
49
|
+
# @param node [Prism::AndNode, Prism::OrNode] connector node.
|
|
50
|
+
# @return [void]
|
|
51
|
+
def emit(node)
|
|
52
|
+
[[node.left, node.right], [node.right, node.left]].each do |kept, removed|
|
|
53
|
+
next if JUMPS.any? { |type| kept.is_a?(type) } || heredoc?(removed)
|
|
54
|
+
|
|
55
|
+
@mutations << Mutation.new(
|
|
56
|
+
start_offset: node.location.start_offset,
|
|
57
|
+
end_offset: node.location.end_offset,
|
|
58
|
+
replacement: "(#{kept.slice})",
|
|
59
|
+
operator: :operand_removal
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/mutineer/pairing.rb
CHANGED
|
@@ -5,9 +5,9 @@ module Mutineer
|
|
|
5
5
|
# no Rails, no class loading, no process. Two jobs:
|
|
6
6
|
# * expand_sources — a directory argument becomes its sorted **/*.rb files.
|
|
7
7
|
# * infer_test — a source's test file by convention (app/ and lib/
|
|
8
|
-
# sources map to test/.../_test.rb
|
|
9
|
-
#
|
|
10
|
-
# candidate wins.
|
|
8
|
+
# sources map to test/.../_test.rb, test/.../test_*.rb
|
|
9
|
+
# or spec/.../_spec.rb), preserving namespaced
|
|
10
|
+
# subdirectories. First EXISTING candidate wins.
|
|
11
11
|
#
|
|
12
12
|
# Independently unit-testable: every method is pure in/out over the
|
|
13
13
|
# filesystem, so the pairing contract is exercised with plain fixtures, no
|
|
@@ -68,8 +68,8 @@ module Mutineer
|
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
-
# Ordered candidate test paths.
|
|
72
|
-
#
|
|
71
|
+
# Ordered candidate test paths: _test.rb, then Minitest's test_*.rb, then
|
|
72
|
+
# _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.
|
|
73
73
|
#
|
|
74
74
|
# @param base [String] logical source path without extension.
|
|
75
75
|
# @param lib [Boolean] whether the source originated from lib/.
|
|
@@ -78,6 +78,11 @@ module Mutineer
|
|
|
78
78
|
def candidates(base, lib, prefer)
|
|
79
79
|
minitest = ["test/#{base}_test.rb"]
|
|
80
80
|
minitest << "test/lib/#{base}_test.rb" if lib
|
|
81
|
+
unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
|
|
82
|
+
prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
|
|
83
|
+
minitest << "test/#{prefixed}.rb"
|
|
84
|
+
minitest << "test/lib/#{prefixed}.rb" if lib
|
|
85
|
+
end
|
|
81
86
|
rspec = ["spec/#{base}_spec.rb"]
|
|
82
87
|
rspec << "spec/lib/#{base}_spec.rb" if lib
|
|
83
88
|
prefer == "rspec" ? rspec + minitest : minitest + rspec
|
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
|
data/lib/mutineer/reporter.rb
CHANGED
|
@@ -35,11 +35,13 @@ module Mutineer
|
|
|
35
35
|
# or to `out`. Diagnostics always go to `err`. `scoped` marks a diff-scoped
|
|
36
36
|
# (`--since`) run; the JSON report records it so a consumer (or a later
|
|
37
37
|
# `--baseline` load) knows the score covers only the changed-line mutants.
|
|
38
|
+
# `legacy_id_matches` (`{ignore:, baseline:}`) counts stored ids still in the
|
|
39
|
+
# old format (#126); only the JSON report records it (`summary.legacy_id_matches`).
|
|
38
40
|
def report(out: $stdout, err: $stderr, threshold: 0.0, format: "human", output: nil,
|
|
39
|
-
baseline: nil, scoped: false)
|
|
41
|
+
baseline: nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
40
42
|
rendered =
|
|
41
43
|
if format == "json"
|
|
42
|
-
json_report(baseline, scoped: scoped)
|
|
44
|
+
json_report(baseline, scoped: scoped, legacy_id_matches: legacy_id_matches)
|
|
43
45
|
elsif format == "html"
|
|
44
46
|
html_report
|
|
45
47
|
else
|
|
@@ -131,8 +133,11 @@ module Mutineer
|
|
|
131
133
|
# @param baseline [Mutineer::Baseline::Delta, nil] baseline delta.
|
|
132
134
|
# @param scoped [Boolean] the run was diff-scoped (`--since`), so its score
|
|
133
135
|
# covers only the changed-line mutants (additive `summary.scoped` key).
|
|
136
|
+
# @param legacy_id_matches [Hash{Symbol => Integer}] `{ignore:, baseline:}`:
|
|
137
|
+
# old-format ignore entries that matched, and survivors matched in an
|
|
138
|
+
# old-format baseline only through their old id (#126).
|
|
134
139
|
# @return [String] JSON text.
|
|
135
|
-
def json_report(baseline = nil, scoped: false)
|
|
140
|
+
def json_report(baseline = nil, scoped: false, legacy_id_matches: { ignore: 0, baseline: 0 })
|
|
136
141
|
killed = @agg.killed_count
|
|
137
142
|
survived = @agg.survived_count
|
|
138
143
|
# null (not 0.0) on an empty denominator, matching the nil-vs-0.0
|
|
@@ -141,7 +146,7 @@ module Mutineer
|
|
|
141
146
|
score = @agg.mutation_score
|
|
142
147
|
|
|
143
148
|
doc = {
|
|
144
|
-
schema_version: "1.
|
|
149
|
+
schema_version: "1.4",
|
|
145
150
|
summary: {
|
|
146
151
|
total: @agg.total, killed: killed, survived: survived,
|
|
147
152
|
no_coverage: @agg.no_coverage_count,
|
|
@@ -155,7 +160,15 @@ module Mutineer
|
|
|
155
160
|
# Additive: true when the run was diff-scoped (--since). The score then
|
|
156
161
|
# covers only the changed-line mutants, so it is not comparable to a
|
|
157
162
|
# full-run score; Baseline#diff reads this to skip the score-drop gate.
|
|
158
|
-
scoped: scoped
|
|
163
|
+
scoped: scoped,
|
|
164
|
+
# Additive (1.4, #126): ids hash the project-relative file path. A
|
|
165
|
+
# baseline without this key stores old-format ids; Baseline#diff then
|
|
166
|
+
# also matches on old ids.
|
|
167
|
+
id_format: 2,
|
|
168
|
+
# Additive (1.4, #126): stored ids still in the old format. `ignore` is
|
|
169
|
+
# the number of old-format ignore entries that matched; `baseline` the
|
|
170
|
+
# survivors matched in the baseline only through their old id.
|
|
171
|
+
legacy_id_matches: legacy_id_matches
|
|
159
172
|
},
|
|
160
173
|
survivors: @agg.surviving_mutants.map { |r| survivor_json(r) }
|
|
161
174
|
.sort_by { |h| [h[:file], h[:line], h[:operator]] },
|
data/lib/mutineer/result.rb
CHANGED
|
@@ -28,8 +28,8 @@ module Mutineer
|
|
|
28
28
|
# `subject`, `mutation`, and `id` are nil when the Result is built by
|
|
29
29
|
# Isolation/Runner (which only know the outcome); the orchestrator attaches
|
|
30
30
|
# them afterwards via `result.with(subject:, mutation:, id:)` so the Reporter
|
|
31
|
-
# can render survivor diffs and emit the
|
|
32
|
-
# MutantId.
|
|
31
|
+
# can render survivor diffs and emit the id. `id` is the content-based
|
|
32
|
+
# MutantId (it includes the project-relative file path).
|
|
33
33
|
Result = Data.define(:status, :details, :subject, :mutation, :id) do
|
|
34
34
|
# Builds a killed result.
|
|
35
35
|
#
|