mutineer 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +63 -2
- data/lib/mutineer/baseline.rb +86 -0
- data/lib/mutineer/changed_lines.rb +29 -12
- data/lib/mutineer/cli.rb +137 -9
- data/lib/mutineer/config.rb +30 -2
- data/lib/mutineer/coverage_map.rb +135 -11
- data/lib/mutineer/isolation.rb +89 -40
- data/lib/mutineer/minitest_integration.rb +13 -9
- data/lib/mutineer/mutant_id.rb +66 -0
- data/lib/mutineer/mutation.rb +12 -6
- data/lib/mutineer/mutator_registry.rb +21 -3
- data/lib/mutineer/mutators/arithmetic.rb +8 -2
- data/lib/mutineer/mutators/base.rb +11 -3
- data/lib/mutineer/mutators/boolean_connector.rb +15 -5
- data/lib/mutineer/mutators/boolean_literal.rb +19 -6
- data/lib/mutineer/mutators/comparison.rb +7 -8
- data/lib/mutineer/mutators/condition_negation.rb +14 -5
- data/lib/mutineer/mutators/literal_mutation.rb +15 -4
- data/lib/mutineer/mutators/return_nil.rb +22 -7
- data/lib/mutineer/mutators/statement_removal.rb +6 -9
- data/lib/mutineer/pairing.rb +86 -0
- data/lib/mutineer/parser.rb +17 -7
- data/lib/mutineer/project.rb +73 -2
- data/lib/mutineer/reporter.rb +172 -8
- data/lib/mutineer/result.rb +128 -32
- data/lib/mutineer/runner.rb +101 -9
- data/lib/mutineer/subject.rb +10 -6
- data/lib/mutineer/test_runners/minitest.rb +5 -4
- data/lib/mutineer/test_runners/rspec.rb +10 -17
- data/lib/mutineer/test_runners.rb +9 -2
- data/lib/mutineer/version.rb +2 -1
- data/lib/mutineer/worker_pool.rb +34 -21
- data/lib/mutineer.rb +3 -0
- metadata +18 -1
data/lib/mutineer/mutation.rb
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
require_relative "parser"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# One atomic byte-range edit.
|
|
7
|
-
#
|
|
6
|
+
# One atomic byte-range edit.
|
|
7
|
+
#
|
|
8
|
+
# Immutable. One mutation per mutant — never combine. Source is mutated
|
|
9
|
+
# textually, never regenerated from the AST.
|
|
8
10
|
Mutation = Data.define(:start_offset, :end_offset, :replacement, :operator) do
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# Applies the mutation to source text.
|
|
12
|
+
#
|
|
13
|
+
# @param source [String] original source text.
|
|
14
|
+
# @return [String] mutated source text.
|
|
12
15
|
def apply(source)
|
|
13
16
|
source.byteslice(0, start_offset) + replacement + source.byteslice(end_offset..)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
#
|
|
19
|
+
# Checks whether the mutated source still parses cleanly.
|
|
20
|
+
#
|
|
21
|
+
# @param source [String] original source text.
|
|
22
|
+
# @return [Boolean] true when the mutated source re-parses without errors.
|
|
17
23
|
def valid?(source)
|
|
18
24
|
Parser.parse_string(apply(source)).errors.empty?
|
|
19
25
|
end
|
|
@@ -10,13 +10,16 @@ require_relative "mutators/literal_mutation"
|
|
|
10
10
|
require_relative "mutators/condition_negation"
|
|
11
11
|
|
|
12
12
|
module Mutineer
|
|
13
|
-
# Maps operator
|
|
13
|
+
# Maps operator names to operator classes.
|
|
14
|
+
#
|
|
15
|
+
# DEFAULT_NAMES is the v1 default set
|
|
14
16
|
# (the M4 Tier-1 + statement-removal operators per locked decision #2). The
|
|
15
17
|
# three Tier-2 operators live in ALL but are OFF by default — they only run
|
|
16
18
|
# when named via --operators or `operators:` in .mutineer.yml (KTD8). Keeping
|
|
17
19
|
# DEFAULT_NAMES an explicit subset (not ALL.keys) is what keeps the M4 default
|
|
18
20
|
# survivor set unchanged.
|
|
19
21
|
class MutatorRegistry
|
|
22
|
+
# All available mutator classes keyed by operator name.
|
|
20
23
|
ALL = {
|
|
21
24
|
"arithmetic" => Mutators::Arithmetic,
|
|
22
25
|
"comparison" => Mutators::Comparison,
|
|
@@ -28,9 +31,12 @@ module Mutineer
|
|
|
28
31
|
"condition_negation" => Mutators::ConditionNegation
|
|
29
32
|
}.freeze
|
|
30
33
|
|
|
34
|
+
# The default Tier-1 operator set.
|
|
31
35
|
DEFAULT_NAMES = %w[arithmetic comparison boolean_connector boolean_literal statement_removal].freeze
|
|
36
|
+
# Tier-2 operators that remain opt-in.
|
|
32
37
|
TIER2_NAMES = %w[return_nil literal_mutation condition_negation].freeze
|
|
33
38
|
|
|
39
|
+
# Short human-readable descriptions for each operator.
|
|
34
40
|
DESCRIPTIONS = {
|
|
35
41
|
"arithmetic" => "+ <-> -, * <-> /, % -> *, ** -> *",
|
|
36
42
|
"comparison" => "< <-> <=, > <-> >=, == <-> !=",
|
|
@@ -42,13 +48,25 @@ module Mutineer
|
|
|
42
48
|
"condition_negation" => "wrap if/unless/ternary condition in !( ... )"
|
|
43
49
|
}.freeze
|
|
44
50
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
51
|
+
# Resolves operator names to classes.
|
|
52
|
+
#
|
|
53
|
+
# @param names [Array<String>] operator names to resolve.
|
|
54
|
+
# @return [Array<Class>] mutator classes in the requested order.
|
|
55
|
+
# @raise [ArgumentError] when a name is unknown.
|
|
47
56
|
def self.resolve(names = DEFAULT_NAMES)
|
|
48
57
|
names.map { |n| ALL.fetch(n) { raise ArgumentError, "Unknown operator: #{n.inspect}" } }
|
|
49
58
|
end
|
|
50
59
|
|
|
60
|
+
# Returns whether the operator is part of the default Tier-1 set.
|
|
61
|
+
#
|
|
62
|
+
# @param name [String] operator name.
|
|
63
|
+
# @return [Boolean] true when the operator is default.
|
|
51
64
|
def self.default?(name) = DEFAULT_NAMES.include?(name)
|
|
65
|
+
|
|
66
|
+
# Returns the tier number for an operator name.
|
|
67
|
+
#
|
|
68
|
+
# @param name [String] operator name.
|
|
69
|
+
# @return [Integer] 2 for Tier-2 operators, otherwise 1.
|
|
52
70
|
def self.tier(name) = TIER2_NAMES.include?(name) ? 2 : 1
|
|
53
71
|
end
|
|
54
72
|
end
|
|
@@ -4,13 +4,19 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Arithmetic operator
|
|
8
|
-
#
|
|
7
|
+
# Arithmetic operator mutator.
|
|
8
|
+
#
|
|
9
|
+
# One mutation per occurrence, rewriting the operator token.
|
|
9
10
|
class Arithmetic < Base
|
|
11
|
+
# Token replacements for arithmetic operators.
|
|
10
12
|
REPLACEMENTS = {
|
|
11
13
|
:+ => "-", :- => "+", :* => "/", :/ => "*", :% => "*", :** => "*"
|
|
12
14
|
}.freeze
|
|
13
15
|
|
|
16
|
+
# Visits call nodes and emits arithmetic mutations.
|
|
17
|
+
#
|
|
18
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
19
|
+
# @return [void]
|
|
14
20
|
def visit_call_node(node)
|
|
15
21
|
replacement = REPLACEMENTS[node.name]
|
|
16
22
|
loc = node.message_loc
|
|
@@ -4,14 +4,22 @@ require "prism"
|
|
|
4
4
|
require_relative "../mutation"
|
|
5
5
|
|
|
6
6
|
module Mutineer
|
|
7
|
+
# Namespace for all built-in mutator implementations.
|
|
7
8
|
module Mutators
|
|
8
|
-
# Base Prism visitor for operators.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# Base Prism visitor for operators.
|
|
10
|
+
#
|
|
11
|
+
# Subclasses override `visit_*` methods to push `Mutation` objects onto
|
|
12
|
+
# `@mutations`. Visiting only `def_node.body` is the body-only enforcement:
|
|
13
|
+
# the def signature line is never touched.
|
|
11
14
|
#
|
|
12
15
|
# ponytail: one implementor in M1; Base earns its keep at M4 when
|
|
13
16
|
# comparison/boolean operators land and share this contract.
|
|
14
17
|
class Base < Prism::Visitor
|
|
18
|
+
# Walks the subject body and collects mutations.
|
|
19
|
+
#
|
|
20
|
+
# @param subject [Mutineer::Subject] subject whose body is visited.
|
|
21
|
+
# @param source [String] full source text for byte-based slicing.
|
|
22
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
15
23
|
def mutations_for(subject, source)
|
|
16
24
|
@source = source
|
|
17
25
|
@mutations = []
|
|
@@ -4,20 +4,26 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Boolean connector
|
|
8
|
-
# from the actual source token (operator_loc.slice) so symbolic and keyword
|
|
9
|
-
# forms each map to their own form — never crossing && to `or`, which would
|
|
10
|
-
# change precedence and surprise the reader (KTD-2).
|
|
7
|
+
# Boolean connector mutator.
|
|
11
8
|
#
|
|
12
|
-
#
|
|
9
|
+
# Replaces symbolic and keyword connectors with their opposite form.
|
|
13
10
|
class BooleanConnector < Base
|
|
11
|
+
# Token replacements for boolean connectors.
|
|
14
12
|
REPLACEMENTS = { "&&" => "||", "||" => "&&", "and" => "or", "or" => "and" }.freeze
|
|
15
13
|
|
|
14
|
+
# Visits `and` nodes.
|
|
15
|
+
#
|
|
16
|
+
# @param node [Prism::AndNode] node to inspect.
|
|
17
|
+
# @return [void]
|
|
16
18
|
def visit_and_node(node)
|
|
17
19
|
emit(node)
|
|
18
20
|
super
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
# Visits `or` nodes.
|
|
24
|
+
#
|
|
25
|
+
# @param node [Prism::OrNode] node to inspect.
|
|
26
|
+
# @return [void]
|
|
21
27
|
def visit_or_node(node)
|
|
22
28
|
emit(node)
|
|
23
29
|
super
|
|
@@ -25,6 +31,10 @@ module Mutineer
|
|
|
25
31
|
|
|
26
32
|
private
|
|
27
33
|
|
|
34
|
+
# Emits a connector mutation when the token is replaceable.
|
|
35
|
+
#
|
|
36
|
+
# @param node [Prism::Node] connector node.
|
|
37
|
+
# @return [void]
|
|
28
38
|
def emit(node)
|
|
29
39
|
loc = node.operator_loc
|
|
30
40
|
replacement = REPLACEMENTS[loc.slice]
|
|
@@ -4,24 +4,32 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
#
|
|
8
|
-
# for the family (§4), so nil is in-scope by design even though it is not
|
|
9
|
-
# strictly a boolean. true<->false, and nil->true (nil->true catches more
|
|
10
|
-
# return-value gaps than nil->false). Rewrites the whole node location;
|
|
11
|
-
# these nodes have no sub-token location.
|
|
7
|
+
# Boolean literal mutator.
|
|
12
8
|
#
|
|
13
|
-
#
|
|
9
|
+
# Mutates true/false and nil literals in the boolean_literal family.
|
|
14
10
|
class BooleanLiteral < Base
|
|
11
|
+
# Visits true literals.
|
|
12
|
+
#
|
|
13
|
+
# @param node [Prism::TrueNode] node to inspect.
|
|
14
|
+
# @return [void]
|
|
15
15
|
def visit_true_node(node)
|
|
16
16
|
emit(node, "false")
|
|
17
17
|
super
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Visits false literals.
|
|
21
|
+
#
|
|
22
|
+
# @param node [Prism::FalseNode] node to inspect.
|
|
23
|
+
# @return [void]
|
|
20
24
|
def visit_false_node(node)
|
|
21
25
|
emit(node, "true")
|
|
22
26
|
super
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# Visits nil literals.
|
|
30
|
+
#
|
|
31
|
+
# @param node [Prism::NilNode] node to inspect.
|
|
32
|
+
# @return [void]
|
|
25
33
|
def visit_nil_node(node)
|
|
26
34
|
emit(node, "true")
|
|
27
35
|
super
|
|
@@ -29,6 +37,11 @@ module Mutineer
|
|
|
29
37
|
|
|
30
38
|
private
|
|
31
39
|
|
|
40
|
+
# Emits a boolean-literal mutation.
|
|
41
|
+
#
|
|
42
|
+
# @param node [Prism::Node] literal node.
|
|
43
|
+
# @param replacement [String] replacement token.
|
|
44
|
+
# @return [void]
|
|
32
45
|
def emit(node, replacement)
|
|
33
46
|
loc = node.location
|
|
34
47
|
@mutations << Mutation.new(
|
|
@@ -4,23 +4,22 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Comparison
|
|
8
|
-
# highest-value Tier-1 family (spec §4) — exposes off-by-one and boundary
|
|
9
|
-
# gaps line coverage never catches. Rewrites the operator token
|
|
10
|
-
# (CallNode#message_loc), one mutation per occurrence.
|
|
7
|
+
# Comparison and boundary mutator.
|
|
11
8
|
#
|
|
12
|
-
#
|
|
13
|
-
# mutation-testing literature, not the mutant gem.
|
|
9
|
+
# Rewrites comparison operators one occurrence at a time.
|
|
14
10
|
class Comparison < Base
|
|
11
|
+
# Token replacements for comparison operators.
|
|
15
12
|
REPLACEMENTS = {
|
|
16
13
|
:< => "<=", :<= => "<", :> => ">=", :>= => ">", :== => "!=", :!= => "=="
|
|
17
14
|
}.freeze
|
|
18
15
|
|
|
16
|
+
# Visits call nodes and emits comparison mutations.
|
|
17
|
+
#
|
|
18
|
+
# @param node [Prism::CallNode] call node to inspect.
|
|
19
|
+
# @return [void]
|
|
19
20
|
def visit_call_node(node)
|
|
20
21
|
replacement = REPLACEMENTS[node.name]
|
|
21
22
|
loc = node.message_loc
|
|
22
|
-
# receiver guard: reject any unary call accidentally named like an
|
|
23
|
-
# operator; binary comparisons always have a receiver.
|
|
24
23
|
if replacement && loc && node.receiver
|
|
25
24
|
@mutations << Mutation.new(
|
|
26
25
|
start_offset: loc.start_offset,
|
|
@@ -4,18 +4,23 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Condition-negation
|
|
8
|
-
# ternary condition in `!( ... )` textually. Ruby ternaries parse as IfNode in
|
|
9
|
-
# Prism, so visit_if_node covers them too (R12). The standard validity re-parse
|
|
10
|
-
# downstream discards any wrap that fails to round-trip (R14).
|
|
7
|
+
# Condition-negation mutator.
|
|
11
8
|
#
|
|
12
|
-
#
|
|
9
|
+
# Wraps if/unless predicates in `!( ... )` textually.
|
|
13
10
|
class ConditionNegation < Base
|
|
11
|
+
# Visits if nodes.
|
|
12
|
+
#
|
|
13
|
+
# @param node [Prism::IfNode] node to inspect.
|
|
14
|
+
# @return [void]
|
|
14
15
|
def visit_if_node(node)
|
|
15
16
|
wrap(node.predicate)
|
|
16
17
|
super
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
# Visits unless nodes.
|
|
21
|
+
#
|
|
22
|
+
# @param node [Prism::UnlessNode] node to inspect.
|
|
23
|
+
# @return [void]
|
|
19
24
|
def visit_unless_node(node)
|
|
20
25
|
wrap(node.predicate)
|
|
21
26
|
super
|
|
@@ -23,6 +28,10 @@ module Mutineer
|
|
|
23
28
|
|
|
24
29
|
private
|
|
25
30
|
|
|
31
|
+
# Wraps a predicate in negation.
|
|
32
|
+
#
|
|
33
|
+
# @param predicate [Prism::Node, nil] predicate node.
|
|
34
|
+
# @return [void]
|
|
26
35
|
def wrap(predicate)
|
|
27
36
|
return unless predicate
|
|
28
37
|
|
|
@@ -4,12 +4,14 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Literal
|
|
8
|
-
# three mutations (0, 1, n+1) with no-op guards for 0 and 1; strings collapse
|
|
9
|
-
# to "" unless already empty. One mutation per emitted candidate (R11).
|
|
7
|
+
# Literal mutation mutator.
|
|
10
8
|
#
|
|
11
|
-
#
|
|
9
|
+
# Mutates integers and strings with the Tier-2 literal rules.
|
|
12
10
|
class LiteralMutation < Base
|
|
11
|
+
# Visits integer literals.
|
|
12
|
+
#
|
|
13
|
+
# @param node [Prism::IntegerNode] node to inspect.
|
|
14
|
+
# @return [void]
|
|
13
15
|
def visit_integer_node(node)
|
|
14
16
|
n = node.value
|
|
15
17
|
emit(node.location, "0") unless n.zero?
|
|
@@ -18,6 +20,10 @@ module Mutineer
|
|
|
18
20
|
super
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
# Visits string literals.
|
|
24
|
+
#
|
|
25
|
+
# @param node [Prism::StringNode] node to inspect.
|
|
26
|
+
# @return [void]
|
|
21
27
|
def visit_string_node(node)
|
|
22
28
|
loc = node.location
|
|
23
29
|
token = @source.byteslice(loc.start_offset...loc.end_offset)
|
|
@@ -27,6 +33,11 @@ module Mutineer
|
|
|
27
33
|
|
|
28
34
|
private
|
|
29
35
|
|
|
36
|
+
# Emits a literal mutation.
|
|
37
|
+
#
|
|
38
|
+
# @param loc [Prism::Location] source location.
|
|
39
|
+
# @param replacement [String] replacement literal.
|
|
40
|
+
# @return [void]
|
|
30
41
|
def emit(loc, replacement)
|
|
31
42
|
@mutations << Mutation.new(
|
|
32
43
|
start_offset: loc.start_offset,
|
|
@@ -4,15 +4,15 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Return-
|
|
8
|
-
# 1. an explicit `return <expr>` -> `return nil`, unless the value is
|
|
9
|
-
# already nil (no-op guard).
|
|
10
|
-
# 2. a method body whose final expression is neither a ReturnNode nor a
|
|
11
|
-
# NilNode -> that expression becomes `nil`.
|
|
12
|
-
# Nested defs are their own subjects, so we never descend into them (R10).
|
|
7
|
+
# Return-nil mutator.
|
|
13
8
|
#
|
|
14
|
-
#
|
|
9
|
+
# Replaces explicit return values and final expressions with nil.
|
|
15
10
|
class ReturnNil < Base
|
|
11
|
+
# Collects return-nil mutations for a subject.
|
|
12
|
+
#
|
|
13
|
+
# @param subject [Mutineer::Subject] subject to inspect.
|
|
14
|
+
# @param source [String] full source text.
|
|
15
|
+
# @return [Array<Mutineer::Mutation>] collected mutations.
|
|
16
16
|
def mutations_for(subject, source)
|
|
17
17
|
@source = source
|
|
18
18
|
@mutations = []
|
|
@@ -24,6 +24,10 @@ module Mutineer
|
|
|
24
24
|
@mutations
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Visits return nodes.
|
|
28
|
+
#
|
|
29
|
+
# @param node [Prism::ReturnNode] node to inspect.
|
|
30
|
+
# @return [void]
|
|
27
31
|
def visit_return_node(node)
|
|
28
32
|
args = node.arguments
|
|
29
33
|
if args
|
|
@@ -37,10 +41,17 @@ module Mutineer
|
|
|
37
41
|
|
|
38
42
|
# Nested method definitions are discovered as their own subjects; do not
|
|
39
43
|
# recurse into them (prevents double-counting their statements).
|
|
44
|
+
#
|
|
45
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
46
|
+
# @return [void]
|
|
40
47
|
def visit_def_node(node); end
|
|
41
48
|
|
|
42
49
|
private
|
|
43
50
|
|
|
51
|
+
# Mutates a method's final expression to nil when eligible.
|
|
52
|
+
#
|
|
53
|
+
# @param body [Prism::Node] method body node.
|
|
54
|
+
# @return [void]
|
|
44
55
|
def final_expression_nil(body)
|
|
45
56
|
return unless body.is_a?(Prism::StatementsNode)
|
|
46
57
|
|
|
@@ -50,6 +61,10 @@ module Mutineer
|
|
|
50
61
|
emit(last.location)
|
|
51
62
|
end
|
|
52
63
|
|
|
64
|
+
# Emits a return-nil mutation.
|
|
65
|
+
#
|
|
66
|
+
# @param loc [Prism::Location] source location.
|
|
67
|
+
# @return [void]
|
|
53
68
|
def emit(loc)
|
|
54
69
|
@mutations << Mutation.new(
|
|
55
70
|
start_offset: loc.start_offset,
|
|
@@ -4,14 +4,14 @@ require_relative "base"
|
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
6
|
module Mutators
|
|
7
|
-
# Statement-removal
|
|
8
|
-
# "nil". Tests whether the suite detects a missing side effect. The final
|
|
9
|
-
# expression is always skipped — replacing the return value with nil is the
|
|
10
|
-
# M5 return-nil operator's distinct concern (KTD-1). A body with < 2
|
|
11
|
-
# statements has no non-final statement, so it generates nothing.
|
|
7
|
+
# Statement-removal mutator.
|
|
12
8
|
#
|
|
13
|
-
#
|
|
9
|
+
# Replaces each non-final method statement with `nil`.
|
|
14
10
|
class StatementRemoval < Base
|
|
11
|
+
# Visits statement nodes and emits removals.
|
|
12
|
+
#
|
|
13
|
+
# @param node [Prism::StatementsNode] statement list to inspect.
|
|
14
|
+
# @return [void]
|
|
15
15
|
def visit_statements_node(node)
|
|
16
16
|
stmts = node.body
|
|
17
17
|
return if stmts.length < 2
|
|
@@ -25,9 +25,6 @@ module Mutineer
|
|
|
25
25
|
operator: :statement_removal
|
|
26
26
|
)
|
|
27
27
|
end
|
|
28
|
-
# ponytail: no super — recursing into a nested StatementsNode would
|
|
29
|
-
# re-emit removals already covered at the top level and double-count.
|
|
30
|
-
# Each subject's body is visited once.
|
|
31
28
|
end
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Source -> test pairing by path convention (#11). Pure stdlib path logic:
|
|
5
|
+
# no Rails, no class loading, no process. Two jobs:
|
|
6
|
+
# * expand_sources — a directory argument becomes its sorted **/*.rb files.
|
|
7
|
+
# * infer_test — a source's test file by convention (app/ and lib/
|
|
8
|
+
# sources map to test/.../_test.rb or spec/.../_spec.rb),
|
|
9
|
+
# preserving namespaced subdirectories. First EXISTING
|
|
10
|
+
# candidate wins.
|
|
11
|
+
#
|
|
12
|
+
# Independently unit-testable: every method is pure in/out over the
|
|
13
|
+
# filesystem, so the pairing contract is exercised with plain fixtures, no
|
|
14
|
+
# Rails, no fork.
|
|
15
|
+
module Pairing
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Expand each positional source: a directory -> its sorted **/*.rb files
|
|
19
|
+
# (relative to project_root); a file (or glob, or anything non-directory)
|
|
20
|
+
# -> itself. Flattened, deduped, order-stable.
|
|
21
|
+
#
|
|
22
|
+
# @param args [Array<String>] source paths or directories.
|
|
23
|
+
# @param project_root [String] repository root for relative expansion.
|
|
24
|
+
# @return [Array<String>] flattened, deduped source file list.
|
|
25
|
+
def expand_sources(args, project_root:)
|
|
26
|
+
root = File.expand_path(project_root)
|
|
27
|
+
Array(args).flat_map do |arg|
|
|
28
|
+
abs = File.expand_path(arg, root)
|
|
29
|
+
if File.directory?(abs)
|
|
30
|
+
Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f.delete_prefix("#{root}/") }
|
|
31
|
+
else
|
|
32
|
+
[arg]
|
|
33
|
+
end
|
|
34
|
+
end.uniq
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The first EXISTING candidate test path for a source (relative to
|
|
38
|
+
# project_root), or nil. `prefer` is the resolved framework ("minitest" |
|
|
39
|
+
# "rspec"): its candidates are tried first, the other framework's as
|
|
40
|
+
# fallback, so a minitest default still finds a spec and vice-versa.
|
|
41
|
+
#
|
|
42
|
+
# @param source_rel [String] relative source path.
|
|
43
|
+
# @param project_root [String] repository root for existence checks.
|
|
44
|
+
# @param prefer [String] preferred framework name.
|
|
45
|
+
# @return [String, nil] existing test path or nil.
|
|
46
|
+
def infer_test(source_rel, project_root:, prefer: "minitest")
|
|
47
|
+
base, lib = logical_path(source_rel)
|
|
48
|
+
candidates(base, lib, prefer).find do |rel|
|
|
49
|
+
File.exist?(File.expand_path(rel, project_root))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Strip the source root to a logical path (no ".rb") and flag lib/
|
|
54
|
+
# sources. app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything
|
|
55
|
+
# else -> the path minus ".rb" (still attempted). Namespaced subdirs are
|
|
56
|
+
# preserved verbatim — structural, never constant resolution.
|
|
57
|
+
#
|
|
58
|
+
# @param source_rel [String] relative source path.
|
|
59
|
+
# @return [Array(String, Boolean)] logical base path and whether it came from lib/.
|
|
60
|
+
def logical_path(source_rel)
|
|
61
|
+
no_ext = source_rel.sub(/\.rb\z/, "")
|
|
62
|
+
if no_ext.start_with?("app/")
|
|
63
|
+
[no_ext.sub(%r{\Aapp/}, ""), false]
|
|
64
|
+
elsif no_ext.start_with?("lib/")
|
|
65
|
+
[no_ext.sub(%r{\Alib/}, ""), true]
|
|
66
|
+
else
|
|
67
|
+
[no_ext, false]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Ordered candidate test paths. lib/ sources also get test/lib/... and
|
|
72
|
+
# spec/lib/... (Rails apps put lib tests under either layout).
|
|
73
|
+
#
|
|
74
|
+
# @param base [String] logical source path without extension.
|
|
75
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
76
|
+
# @param prefer [String] preferred framework name.
|
|
77
|
+
# @return [Array<String>] candidate test paths in preferred order.
|
|
78
|
+
def candidates(base, lib, prefer)
|
|
79
|
+
minitest = ["test/#{base}_test.rb"]
|
|
80
|
+
minitest << "test/lib/#{base}_test.rb" if lib
|
|
81
|
+
rspec = ["spec/#{base}_spec.rb"]
|
|
82
|
+
rspec << "spec/lib/#{base}_spec.rb" if lib
|
|
83
|
+
prefer == "rspec" ? rspec + minitest : minitest + rspec
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/mutineer/parser.rb
CHANGED
|
@@ -3,22 +3,32 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
module Mutineer
|
|
6
|
-
# Raised only for I/O failures while reading a source file.
|
|
7
|
-
#
|
|
6
|
+
# Raised only for I/O failures while reading a source file.
|
|
7
|
+
#
|
|
8
|
+
# Prism syntax errors are NOT raised — they are in-band via ParseResult#errors.
|
|
8
9
|
class ParseError < StandardError; end
|
|
9
10
|
|
|
10
|
-
# Thin boundary around Prism.
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# Thin boundary around Prism.
|
|
12
|
+
#
|
|
13
|
+
# Both methods return a Prism::ParseResult so all callers use result.value,
|
|
14
|
+
# result.source.source (raw bytes), and result.errors uniformly. No wrapping
|
|
15
|
+
# struct.
|
|
13
16
|
class Parser
|
|
14
|
-
#
|
|
17
|
+
# Parses a file with Prism.
|
|
18
|
+
#
|
|
19
|
+
# @param path [String] source file path.
|
|
20
|
+
# @return [Prism::ParseResult] Prism parse result.
|
|
21
|
+
# @raise [Mutineer::ParseError] when file I/O fails.
|
|
15
22
|
def self.parse_file(path)
|
|
16
23
|
Prism.parse_file(path)
|
|
17
24
|
rescue SystemCallError => e
|
|
18
25
|
raise ParseError, e.message
|
|
19
26
|
end
|
|
20
27
|
|
|
21
|
-
#
|
|
28
|
+
# Parses source text with Prism.
|
|
29
|
+
#
|
|
30
|
+
# @param source [String] source text.
|
|
31
|
+
# @return [Prism::ParseResult] Prism parse result.
|
|
22
32
|
def self.parse_string(source)
|
|
23
33
|
Prism.parse(source)
|
|
24
34
|
end
|