mutineer 1.4.0 → 1.6.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 +475 -0
- data/README.md +224 -28
- data/lib/mutineer/changed_lines.rb +24 -1
- data/lib/mutineer/cli.rb +117 -20
- data/lib/mutineer/config.rb +64 -13
- data/lib/mutineer/coverage_map.rb +533 -100
- data/lib/mutineer/daemon_backend.rb +51 -33
- data/lib/mutineer/daemon_client.rb +212 -22
- data/lib/mutineer/daemon_server.rb +74 -27
- data/lib/mutineer/external_backend.rb +2 -2
- data/lib/mutineer/file_swap.rb +1 -1
- data/lib/mutineer/isolation.rb +92 -6
- data/lib/mutineer/job_plan.rb +352 -0
- data/lib/mutineer/kill_channel.rb +284 -0
- data/lib/mutineer/kill_matrix.rb +88 -0
- data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
- data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
- data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
- data/lib/mutineer/minitest_integration.rb +75 -11
- data/lib/mutineer/mutant_id.rb +5 -4
- data/lib/mutineer/mutators/array_literal.rb +0 -7
- data/lib/mutineer/mutators/base.rb +63 -4
- data/lib/mutineer/mutators/chain_link.rb +0 -7
- data/lib/mutineer/mutators/collection_method.rb +1 -1
- data/lib/mutineer/mutators/condition_forcing.rb +0 -7
- data/lib/mutineer/mutators/operand_removal.rb +0 -7
- data/lib/mutineer/mutators/operator_assignment.rb +0 -7
- data/lib/mutineer/mutators/return_nil.rb +5 -11
- data/lib/mutineer/mutators/string_literal.rb +1 -1
- data/lib/mutineer/orphan_guard.rb +28 -0
- data/lib/mutineer/pairing.rb +222 -15
- data/lib/mutineer/parser.rb +12 -3
- data/lib/mutineer/project.rb +344 -21
- data/lib/mutineer/rails_worker_db.rb +83 -9
- data/lib/mutineer/reporter.rb +306 -53
- data/lib/mutineer/result.rb +73 -5
- data/lib/mutineer/runner.rb +123 -242
- data/lib/mutineer/statement_lines.rb +183 -0
- data/lib/mutineer/subject.rb +6 -2
- data/lib/mutineer/test_runners/minitest.rb +4 -2
- data/lib/mutineer/test_runners/rspec.rb +282 -3
- data/lib/mutineer/test_runners.rb +5 -3
- data/lib/mutineer/version.rb +1 -1
- data/lib/mutineer/worker_pool.rb +4 -2
- data/lib/mutineer.rb +2 -0
- metadata +10 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "minitest_integration/stop_at_first_failure"
|
|
4
|
+
require_relative "minitest_integration/kill_recorder"
|
|
4
5
|
|
|
5
6
|
module Mutineer
|
|
6
7
|
# Child-process-only: loads a test file in the current process and runs it
|
|
@@ -16,9 +17,11 @@ module Mutineer
|
|
|
16
17
|
# boundary for unexpected errors. Swallowing those here would create a
|
|
17
18
|
# second exit-2 path and break this method's 0/1 return contract.
|
|
18
19
|
class MinitestIntegration
|
|
19
|
-
# The seed of a run that stops at the first failure,
|
|
20
|
-
#
|
|
21
|
-
# order
|
|
20
|
+
# The seed of a mutant run (one that stops at the first failure, or a
|
|
21
|
+
# `--matrix` run that records every test), unless `SEED` is set. With the
|
|
22
|
+
# stop, the test order can decide `killed` vs `timeout`; a fixed order keeps
|
|
23
|
+
# the verdict stable for a `--baseline` gate. A matrix run uses the same
|
|
24
|
+
# order, so its first failure comes at the same point as the stop's.
|
|
22
25
|
STOP_AT_FIRST_FAILURE_SEED = 1
|
|
23
26
|
|
|
24
27
|
# Tested via runner_test.rb, not in isolation — a direct unit test
|
|
@@ -31,8 +34,15 @@ module Mutineer
|
|
|
31
34
|
# @param test_files [String, Array<String>] one file or many files.
|
|
32
35
|
# @param stop_at_first_failure [Boolean] end the run at the first failing
|
|
33
36
|
# test. Only the mutant path passes true.
|
|
37
|
+
# @param record_to [IO, nil] a `--matrix` run: send each test's outcome to
|
|
38
|
+
# this channel (see {KillRecorder}) and run every test.
|
|
34
39
|
# @return [Integer] 0 on success, 1 on failure.
|
|
35
|
-
|
|
40
|
+
# @raise [ArgumentError] when both options are given: a matrix run never stops.
|
|
41
|
+
def self.run(test_files, stop_at_first_failure: false, record_to: nil)
|
|
42
|
+
if stop_at_first_failure && record_to
|
|
43
|
+
raise ArgumentError, "stop_at_first_failure and record_to cannot be combined: a matrix run runs every test"
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
begin
|
|
37
47
|
require "minitest"
|
|
38
48
|
rescue LoadError
|
|
@@ -52,22 +62,76 @@ module Mutineer
|
|
|
52
62
|
# Drop runnables inherited from the parent suite (this is the child's
|
|
53
63
|
# private copy — the parent is unaffected) so only the target test runs.
|
|
54
64
|
Minitest::Runnable.reset
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# gets the normal full, randomly ordered run.
|
|
61
|
-
args = ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] unless ENV["SEED"]
|
|
65
|
+
# Each test class's file position: the file that first defined it.
|
|
66
|
+
rank = {}
|
|
67
|
+
Array(test_files).each_with_index do |f, i|
|
|
68
|
+
load f
|
|
69
|
+
Minitest::Runnable.runnables.each { |klass| rank[klass] ||= i }
|
|
62
70
|
end
|
|
71
|
+
|
|
72
|
+
armed =
|
|
73
|
+
if record_to
|
|
74
|
+
KillRecorder.arm!(record_to, Minitest::Runnable.runnables)
|
|
75
|
+
elsif stop_at_first_failure
|
|
76
|
+
StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
|
|
77
|
+
end
|
|
78
|
+
# Pin the seed only when a hook is armed; an unknown Minitest shape gets
|
|
79
|
+
# the normal full, randomly ordered run.
|
|
80
|
+
args = armed && !ENV["SEED"] ? ["--seed", STOP_AT_FIRST_FAILURE_SEED.to_s] : []
|
|
81
|
+
keep_file_order(rank) if armed
|
|
63
82
|
# No silencing here: the fork boundary that calls this method has already
|
|
64
83
|
# pointed stdout at File::NULL (see ChildStdout).
|
|
65
84
|
passed = Minitest.run(args)
|
|
85
|
+
# Reached only when the suite returned: a test that exits the process
|
|
86
|
+
# skips it, and the parent then keeps the row incomplete.
|
|
87
|
+
KillRecorder.finish! if record_to
|
|
66
88
|
|
|
67
89
|
# A plugin can replace the summary reporter, so a stop decides by itself.
|
|
68
90
|
passed && !StopAtFirstFailure.stopped_here? ? 0 : 1
|
|
69
91
|
ensure
|
|
70
92
|
StopAtFirstFailure.disarm!
|
|
93
|
+
KillRecorder.disarm!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Runs the test classes in the order of their files (#203). Minitest
|
|
97
|
+
# shuffles the classes with the seed, then runs the serial classes before
|
|
98
|
+
# the parallel (`parallelize_me!`) ones. A stable sort by file position
|
|
99
|
+
# after that shuffle puts the files in the given order and keeps the
|
|
100
|
+
# seeded order within one file. The serial-then-parallel split is
|
|
101
|
+
# unchanged. Only the child's own class list gets {FileOrder}.
|
|
102
|
+
#
|
|
103
|
+
# @api private
|
|
104
|
+
# @param rank [Hash{Class => Integer}] each class's file position.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.keep_file_order(rank)
|
|
107
|
+
FileOrder.rank = rank
|
|
108
|
+
::Minitest::Runnable.runnables.extend(FileOrder)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Extends the list of test classes: its `shuffle` keeps the file order.
|
|
112
|
+
module FileOrder
|
|
113
|
+
class << self
|
|
114
|
+
# Each test class's file position.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash{Class => Integer}, nil]
|
|
117
|
+
attr_accessor :rank
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The seeded shuffle, then a stable sort by file position.
|
|
121
|
+
#
|
|
122
|
+
# @return [Array<Class>]
|
|
123
|
+
def shuffle(...)
|
|
124
|
+
rank = FileOrder.rank || {}
|
|
125
|
+
super.sort_by.with_index { |klass, i| [rank.fetch(klass, rank.size), i] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Minitest 5.15 and older drop empty classes before the shuffle, so the
|
|
129
|
+
# filtered list keeps the file order too.
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<Class>]
|
|
132
|
+
def reject(...)
|
|
133
|
+
super.extend(FileOrder)
|
|
134
|
+
end
|
|
71
135
|
end
|
|
72
136
|
end
|
|
73
137
|
end
|
data/lib/mutineer/mutant_id.rb
CHANGED
|
@@ -12,9 +12,9 @@ module Mutineer
|
|
|
12
12
|
# qualified_name (a method, not a byte position) + operator + the normalized
|
|
13
13
|
# mutated token + an occurrence ordinal among same-(operator, token) twins
|
|
14
14
|
# WITHIN the subject + (only when positive) the subject's ordinal among
|
|
15
|
-
# same-named subjects in its file.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
15
|
+
# same-named subjects in its file. Unrelated edits that only shift byte offsets
|
|
16
|
+
# can preserve the id. File moves, renames, project-root changes, and changes to
|
|
17
|
+
# repeated-name or repeated-mutation order can change it.
|
|
18
18
|
module MutantId
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -38,7 +38,8 @@ module Mutineer
|
|
|
38
38
|
# file sharing this qualified name (two owner-less `def index` in two DSL
|
|
39
39
|
# blocks). Hashed only when positive, so a subject whose name is unique in
|
|
40
40
|
# its file keeps the id it had without it.
|
|
41
|
-
# @return [String] a 12-character hex id, stable
|
|
41
|
+
# @return [String] a 12-character hex id, stable while its keyed identity and
|
|
42
|
+
# occurrence order stay unchanged.
|
|
42
43
|
def for(subject, mutation, source, occurrence = 0, path:, subject_ordinal: 0)
|
|
43
44
|
parts = [path, subject.qualified_name, mutation.operator, normalized_token(mutation, source), occurrence]
|
|
44
45
|
parts << subject_ordinal if subject_ordinal.positive?
|
|
@@ -19,13 +19,6 @@ module Mutineer
|
|
|
19
19
|
super # nested arrays each get their own mutation
|
|
20
20
|
end
|
|
21
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
22
|
private
|
|
30
23
|
|
|
31
24
|
# Emits a mutation for a non-empty array with brackets.
|
|
@@ -11,9 +11,6 @@ module Mutineer
|
|
|
11
11
|
# Subclasses override `visit_*` methods to push `Mutation` objects onto
|
|
12
12
|
# `@mutations`. Visiting only `def_node.body` is the body-only enforcement:
|
|
13
13
|
# the def signature line is never touched.
|
|
14
|
-
#
|
|
15
|
-
# ponytail: one implementor in M1; Base earns its keep at M4 when
|
|
16
|
-
# comparison/boolean operators land and share this contract.
|
|
17
14
|
class Base < Prism::Visitor
|
|
18
15
|
# Walks the subject body and collects mutations.
|
|
19
16
|
#
|
|
@@ -23,12 +20,74 @@ module Mutineer
|
|
|
23
20
|
def mutations_for(subject, source)
|
|
24
21
|
@source = source
|
|
25
22
|
@mutations = []
|
|
26
|
-
subject.def_node.body
|
|
23
|
+
@body = subject.def_node.body
|
|
24
|
+
@body&.accept(self)
|
|
25
|
+
drop_dangling_heredocs
|
|
27
26
|
@mutations
|
|
28
27
|
end
|
|
29
28
|
|
|
29
|
+
# Skips a nested method definition. The project finds it as a subject of
|
|
30
|
+
# its own, so a visit here counts its mutations twice. Inside
|
|
31
|
+
# `class << obj`, the project finds no subject, so the visit continues.
|
|
32
|
+
#
|
|
33
|
+
# @param node [Prism::DefNode] nested definition node.
|
|
34
|
+
# @return [void]
|
|
35
|
+
def visit_def_node(node)
|
|
36
|
+
super if @in_undiscovered
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Tracks a `class << obj` whose `obj` is not `self`. The project does not
|
|
40
|
+
# recurse into it, so its defs are no subjects (see
|
|
41
|
+
# `Project::SubjectVisitor#visit_singleton_class_node`).
|
|
42
|
+
#
|
|
43
|
+
# @param node [Prism::SingletonClassNode] singleton-class node.
|
|
44
|
+
# @return [void]
|
|
45
|
+
def visit_singleton_class_node(node)
|
|
46
|
+
return super if node.expression.is_a?(Prism::SelfNode)
|
|
47
|
+
|
|
48
|
+
outer, @in_undiscovered = @in_undiscovered, true
|
|
49
|
+
super
|
|
50
|
+
@in_undiscovered = outer
|
|
51
|
+
end
|
|
52
|
+
|
|
30
53
|
private
|
|
31
54
|
|
|
55
|
+
# Drops a mutation that deletes a heredoc opener and leaves its body.
|
|
56
|
+
#
|
|
57
|
+
# The body sits past the opener's node. Replacing the opener with text
|
|
58
|
+
# that does not contain it turns the body into Ruby, which parses and
|
|
59
|
+
# then raises. A replacement that still contains the opener keeps the
|
|
60
|
+
# body attached, so that mutation stays.
|
|
61
|
+
#
|
|
62
|
+
# @return [void]
|
|
63
|
+
def drop_dangling_heredocs
|
|
64
|
+
@mutations.reject! { |mutation| dangling_heredoc?(mutation) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns whether the mutation removes a heredoc opener whose body hangs
|
|
68
|
+
# past the replaced range.
|
|
69
|
+
#
|
|
70
|
+
# @param mutation [Mutineer::Mutation] candidate edit.
|
|
71
|
+
# @param node [Prism::Node, nil] subtree to search. Defaults to the body.
|
|
72
|
+
# @return [Boolean] true when the edit would leave a heredoc body behind.
|
|
73
|
+
def dangling_heredoc?(mutation, node = @body)
|
|
74
|
+
return false if node.nil? || !node.is_a?(Prism::Node)
|
|
75
|
+
|
|
76
|
+
if node.respond_to?(:heredoc?) && node.heredoc? && node.respond_to?(:opening_loc)
|
|
77
|
+
opening = node.opening_loc
|
|
78
|
+
if opening && opening.start_offset >= mutation.start_offset && opening.start_offset < mutation.end_offset
|
|
79
|
+
closing = node.respond_to?(:closing_loc) ? node.closing_loc : nil
|
|
80
|
+
hangs = closing.nil? || closing.end_offset > mutation.end_offset
|
|
81
|
+
if hangs
|
|
82
|
+
opener = @source.byteslice(opening.start_offset, opening.end_offset - opening.start_offset)
|
|
83
|
+
return true if opener.empty? || !mutation.replacement.include?(opener)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
node.compact_child_nodes.any? { |child| dangling_heredoc?(mutation, child) }
|
|
89
|
+
end
|
|
90
|
+
|
|
32
91
|
# Returns whether a node is, or contains, a heredoc.
|
|
33
92
|
#
|
|
34
93
|
# A heredoc's body lies outside its node's byte range. A mutation that
|
|
@@ -88,13 +88,6 @@ module Mutineer
|
|
|
88
88
|
super
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
# Nested method definitions are discovered as their own subjects; do not
|
|
92
|
-
# recurse into them (prevents double-counting their chains).
|
|
93
|
-
#
|
|
94
|
-
# @param node [Prism::DefNode] nested definition node.
|
|
95
|
-
# @return [void]
|
|
96
|
-
def visit_def_node(node); end
|
|
97
|
-
|
|
98
91
|
private
|
|
99
92
|
|
|
100
93
|
# Walks a final call's receiver chain, recording each link so it is not
|
|
@@ -12,7 +12,7 @@ module Mutineer
|
|
|
12
12
|
# Method-name swaps. All targets are core-Ruby Enumerable/Array methods so
|
|
13
13
|
# the mutant exercises real behaviour rather than always raising.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
15
|
+
# include? -> exclude? is left out: exclude? is not
|
|
16
16
|
# core Ruby, so that mutant would always NoMethodError (a weak mutant).
|
|
17
17
|
REPLACEMENTS = {
|
|
18
18
|
map: "each", each: "map",
|
|
@@ -50,13 +50,6 @@ module Mutineer
|
|
|
50
50
|
super
|
|
51
51
|
end
|
|
52
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
53
|
private
|
|
61
54
|
|
|
62
55
|
# Emits the forced condition unless the condition is a literal or holds a
|
|
@@ -33,13 +33,6 @@ module Mutineer
|
|
|
33
33
|
super
|
|
34
34
|
end
|
|
35
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
36
|
private
|
|
44
37
|
|
|
45
38
|
# Emits one mutation that keeps the left operand, then one that keeps
|
|
@@ -90,13 +90,6 @@ module Mutineer
|
|
|
90
90
|
super
|
|
91
91
|
end
|
|
92
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
93
|
private
|
|
101
94
|
|
|
102
95
|
# Emits a mutation when the node's operator has a replacement.
|
|
@@ -16,11 +16,12 @@ module Mutineer
|
|
|
16
16
|
def mutations_for(subject, source)
|
|
17
17
|
@source = source
|
|
18
18
|
@mutations = []
|
|
19
|
-
body = subject.def_node.body
|
|
20
|
-
if body
|
|
21
|
-
body.accept(self) # rule 1 (explicit return nodes in this body)
|
|
22
|
-
final_expression_nil(body) # rule 2 (method's final expression)
|
|
19
|
+
@body = subject.def_node.body
|
|
20
|
+
if @body
|
|
21
|
+
@body.accept(self) # rule 1 (explicit return nodes in this body)
|
|
22
|
+
final_expression_nil(@body) # rule 2 (method's final expression)
|
|
23
23
|
end
|
|
24
|
+
drop_dangling_heredocs
|
|
24
25
|
@mutations
|
|
25
26
|
end
|
|
26
27
|
|
|
@@ -39,13 +40,6 @@ module Mutineer
|
|
|
39
40
|
super
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Nested method definitions are discovered as their own subjects; do not
|
|
43
|
-
# recurse into them (prevents double-counting their statements).
|
|
44
|
-
#
|
|
45
|
-
# @param node [Prism::DefNode] nested definition node.
|
|
46
|
-
# @return [void]
|
|
47
|
-
def visit_def_node(node); end
|
|
48
|
-
|
|
49
43
|
private
|
|
50
44
|
|
|
51
45
|
# Mutates a method's final expression to nil when eligible.
|
|
@@ -15,7 +15,7 @@ module Mutineer
|
|
|
15
15
|
# @param node [Prism::StringNode] node to inspect.
|
|
16
16
|
# @return [void]
|
|
17
17
|
def visit_string_node(node)
|
|
18
|
-
#
|
|
18
|
+
# Only plain "..." / '...' quotes. opening is nil for
|
|
19
19
|
# interpolation parts and %w[] elements; heredocs/%-literals use a
|
|
20
20
|
# different opening token. Skipping them keeps mutants re-parseable.
|
|
21
21
|
if %w[" '].include?(node.opening)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mutineer
|
|
4
|
+
# Ends a forked child's process group once the process that forked it is
|
|
5
|
+
# gone (#101). A mutant or capture child leads its own process group, so a
|
|
6
|
+
# parent that dies, or is killed by a client that gave up on it, does not
|
|
7
|
+
# take the child's tests with it; without this, a hung test would run on and
|
|
8
|
+
# keep its worker database open. Needs no other part of Mutineer, so the
|
|
9
|
+
# app-side daemon can load it.
|
|
10
|
+
module OrphanGuard
|
|
11
|
+
# Seconds between checks. A forked child gets no signal on parent death.
|
|
12
|
+
POLL = 0.5
|
|
13
|
+
|
|
14
|
+
# Starts the watchdog in the current (child) process.
|
|
15
|
+
#
|
|
16
|
+
# @param parent [Integer] the parent's pid, read in the parent before
|
|
17
|
+
# `fork`, so a parent that dies before this call is still noticed.
|
|
18
|
+
# @return [Thread]
|
|
19
|
+
def self.start(parent)
|
|
20
|
+
Thread.new do
|
|
21
|
+
sleep(POLL) while Process.ppid == parent
|
|
22
|
+
# Group 0 only when this child leads its own group: a failed setpgid
|
|
23
|
+
# leaves it in the parent's group, which must not be killed.
|
|
24
|
+
Process.kill(:KILL, Process.getpgrp == Process.pid ? 0 : Process.pid)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/mutineer/pairing.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "project_path"
|
|
4
|
+
|
|
3
5
|
module Mutineer
|
|
4
6
|
# Source -> test pairing by path convention (#11). Pure stdlib path logic:
|
|
5
7
|
# no Rails, no class loading, no process. Two jobs:
|
|
6
|
-
# * expand_sources — a directory argument becomes its sorted **/*.rb files
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# * expand_sources — a directory argument becomes its sorted **/*.rb files;
|
|
9
|
+
# a file inside the root becomes its root-relative path.
|
|
10
|
+
# * infer_tests — a source's test files by convention (app/ and lib/
|
|
11
|
+
# sources map to test/.../_test.rb, test/.../<name>_*_test.rb,
|
|
12
|
+
# test/.../test_*.rb or spec/.../_spec.rb), preserving
|
|
13
|
+
# namespaced subdirectories. The first EXISTING exact
|
|
14
|
+
# candidate stays first. Split Minitest files join a
|
|
15
|
+
# Minitest match. A matched spec is left as that one file.
|
|
11
16
|
#
|
|
12
17
|
# Independently unit-testable: every method is pure in/out over the
|
|
13
18
|
# filesystem, so the pairing contract is exercised with plain fixtures, no
|
|
@@ -17,37 +22,88 @@ module Mutineer
|
|
|
17
22
|
|
|
18
23
|
# Expand each positional source: a directory -> its sorted **/*.rb files
|
|
19
24
|
# (relative to project_root); a file (or glob, or anything non-directory)
|
|
20
|
-
# ->
|
|
25
|
+
# -> its path relative to project_root, so `./lib/x.rb` and an absolute
|
|
26
|
+
# path pair like `lib/x.rb` (#104). A missing file, or one outside the
|
|
27
|
+
# root, stays as typed. Flattened, deduped, order-stable.
|
|
21
28
|
#
|
|
22
29
|
# @param args [Array<String>] source paths or directories.
|
|
23
30
|
# @param project_root [String] repository root for relative expansion.
|
|
24
31
|
# @return [Array<String>] flattened, deduped source file list.
|
|
25
32
|
def expand_sources(args, project_root:)
|
|
26
33
|
root = File.expand_path(project_root)
|
|
34
|
+
# The root as typed and as its real path (macOS `/var` vs `/private/var`),
|
|
35
|
+
# resolved once for every file below.
|
|
36
|
+
roots = [root, ProjectPath.root_real(root)].uniq
|
|
27
37
|
Array(args).flat_map do |arg|
|
|
28
38
|
abs = File.expand_path(arg, root)
|
|
29
39
|
if File.directory?(abs)
|
|
30
|
-
Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f
|
|
40
|
+
Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| existing_relative(f, roots) || f }
|
|
31
41
|
else
|
|
32
|
-
|
|
42
|
+
# `..` resolves as text, as FileSwap and the daemon already read it.
|
|
43
|
+
[(existing_relative(abs, roots) if File.exist?(abs)) || arg]
|
|
33
44
|
end
|
|
34
45
|
end.uniq
|
|
35
46
|
end
|
|
36
47
|
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
48
|
+
# An existing file's root-relative path, or nil outside the root. Tries the
|
|
49
|
+
# path as given, then with its directory resolved, so a path typed through
|
|
50
|
+
# a symlinked parent (or macOS `/var` under a `/private/var` root) matches.
|
|
51
|
+
# The file's own name is kept, so a symlinked file is not renamed.
|
|
52
|
+
#
|
|
53
|
+
# @param abs [String] expanded absolute path of an existing file.
|
|
54
|
+
# @param roots [Array<String>] the project root, expanded and real.
|
|
55
|
+
# @return [String, nil] root-relative path, or nil outside the root.
|
|
56
|
+
def existing_relative(abs, roots)
|
|
57
|
+
root_relative(abs, roots) ||
|
|
58
|
+
root_relative(File.join(File.realpath(File.dirname(abs)), File.basename(abs)), roots)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `abs` relative to the first of `roots` it is under, or nil. Text only: a
|
|
62
|
+
# symlinked source keeps its own name (realpath would rename it to its
|
|
63
|
+
# target and pair the wrong test). `roots` holds the root's real path too,
|
|
64
|
+
# so the macOS `/var` vs `/private/var` alias still matches.
|
|
65
|
+
#
|
|
66
|
+
# @param abs [String] expanded absolute path.
|
|
67
|
+
# @param roots [Array<String>] the project root, expanded and real.
|
|
68
|
+
# @return [String, nil] root-relative path, or nil outside the root.
|
|
69
|
+
def root_relative(abs, roots)
|
|
70
|
+
roots.each do |r|
|
|
71
|
+
prefix = r.end_with?("/") ? r : "#{r}/"
|
|
72
|
+
return abs.delete_prefix(prefix) if abs.start_with?(prefix)
|
|
73
|
+
end
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The first test path {#infer_tests} would run for a source, or nil.
|
|
78
|
+
# `prefer` is the resolved framework ("minitest" | "rspec"): its candidates
|
|
79
|
+
# are tried first, the other framework's as fallback, so a minitest default
|
|
80
|
+
# still finds a spec and vice-versa.
|
|
41
81
|
#
|
|
42
82
|
# @param source_rel [String] relative source path.
|
|
43
83
|
# @param project_root [String] repository root for existence checks.
|
|
44
84
|
# @param prefer [String] preferred framework name.
|
|
45
85
|
# @return [String, nil] existing test path or nil.
|
|
46
86
|
def infer_test(source_rel, project_root:, prefer: "minitest")
|
|
87
|
+
infer_tests(source_rel, project_root: project_root, prefer: prefer).first
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Every test file convention pairs with a source. The first existing exact
|
|
91
|
+
# candidate from {#candidates} stays first. Each `<basename>_*_test.rb` in
|
|
92
|
+
# the mirrored test directory is added after it (#87). A spec that the exact
|
|
93
|
+
# rules already found is left alone: split Minitest names do not join it
|
|
94
|
+
# and do not replace it.
|
|
95
|
+
#
|
|
96
|
+
# @param source_rel [String] relative source path.
|
|
97
|
+
# @param project_root [String] repository root for existence checks.
|
|
98
|
+
# @param prefer [String] preferred framework name.
|
|
99
|
+
# @return [Array<String>] existing test paths, exact candidate first.
|
|
100
|
+
def infer_tests(source_rel, project_root:, prefer: "minitest")
|
|
47
101
|
base, lib = logical_path(source_rel)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
102
|
+
root = File.expand_path(project_root)
|
|
103
|
+
exact = candidates(base, lib, prefer).find { |rel| File.exist?(File.expand_path(rel, root)) }
|
|
104
|
+
return [exact] if exact&.end_with?("_spec.rb")
|
|
105
|
+
|
|
106
|
+
([exact] + split_test_files(base, lib, root)).compact.uniq
|
|
51
107
|
end
|
|
52
108
|
|
|
53
109
|
# Strip the source root to a logical path (no ".rb") and flag lib/
|
|
@@ -87,5 +143,156 @@ module Mutineer
|
|
|
87
143
|
rspec << "spec/lib/#{base}_spec.rb" if lib
|
|
88
144
|
prefer == "rspec" ? rspec + minitest : minitest + rspec
|
|
89
145
|
end
|
|
146
|
+
|
|
147
|
+
# `<basename>_*_test.rb` files in each mirrored test directory (#87). The
|
|
148
|
+
# exact `<basename>_test.rb` name is not included. `lib/` sources also look
|
|
149
|
+
# under `test/lib/`. Names are matched as text, not as a glob, so a basename
|
|
150
|
+
# that contains `*` stays literal. `test/` comes before `test/lib/`, and
|
|
151
|
+
# each directory is sorted.
|
|
152
|
+
#
|
|
153
|
+
# @param base [String] logical source path without extension.
|
|
154
|
+
# @param lib [Boolean] whether the source originated from lib/.
|
|
155
|
+
# @param root [String] expanded project root.
|
|
156
|
+
# @return [Array<String>] relative split-test paths.
|
|
157
|
+
def split_test_files(base, lib, root)
|
|
158
|
+
name = File.basename(base)
|
|
159
|
+
dirs = [mirror_dir("test", base)]
|
|
160
|
+
dirs << mirror_dir("test/lib", base) if lib
|
|
161
|
+
dirs.flat_map { |dir| split_tests_in(root, dir, name, base) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Relative test directory for a logical source path. A source with no
|
|
165
|
+
# subdirectory (`calc`) maps to `prefix` itself, not `prefix/.`.
|
|
166
|
+
#
|
|
167
|
+
# @param prefix [String] `test` or `test/lib`.
|
|
168
|
+
# @param base [String] logical source path without extension.
|
|
169
|
+
# @return [String] relative directory.
|
|
170
|
+
def mirror_dir(prefix, base)
|
|
171
|
+
dir = File.dirname(base)
|
|
172
|
+
dir == "." ? prefix : File.join(prefix, dir)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Split test files directly inside `dir_rel` (not in subdirectories).
|
|
176
|
+
#
|
|
177
|
+
# @param root [String] expanded project root.
|
|
178
|
+
# @param dir_rel [String] relative directory.
|
|
179
|
+
# @param name [String] source basename without extension.
|
|
180
|
+
# @param base [String] logical source path without extension.
|
|
181
|
+
# @return [Array<String>] sorted relative paths.
|
|
182
|
+
def split_tests_in(root, dir_rel, name, base)
|
|
183
|
+
root_abs = File.expand_path(root)
|
|
184
|
+
dir_abs = File.expand_path(dir_rel, root_abs)
|
|
185
|
+
return [] unless inside_project?(dir_abs, root_abs)
|
|
186
|
+
return [] unless File.directory?(dir_abs)
|
|
187
|
+
|
|
188
|
+
Dir.children(dir_abs).filter_map do |entry|
|
|
189
|
+
next unless split_entry?(entry, name)
|
|
190
|
+
next unless File.file?(File.join(dir_abs, entry))
|
|
191
|
+
next if claimed_by_longer_source?(root, base, entry, dir_rel)
|
|
192
|
+
|
|
193
|
+
File.join(dir_rel, entry)
|
|
194
|
+
end.sort
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# True when `dir_abs` is the project root or a directory inside it.
|
|
198
|
+
# A source path such as `../../evil.rb` must not list tests outside the project.
|
|
199
|
+
#
|
|
200
|
+
# @param dir_abs [String] expanded directory.
|
|
201
|
+
# @param root_abs [String] expanded project root.
|
|
202
|
+
# @return [Boolean]
|
|
203
|
+
def inside_project?(dir_abs, root_abs)
|
|
204
|
+
prefix = root_abs == File::SEPARATOR ? File::SEPARATOR : "#{root_abs}#{File::SEPARATOR}"
|
|
205
|
+
dir_abs == root_abs || dir_abs.start_with?(prefix)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# True when `entry` is `<name>_<piece>_test.rb` and not the exact
|
|
209
|
+
# `<name>_test.rb` file.
|
|
210
|
+
#
|
|
211
|
+
# @param entry [String] test file basename.
|
|
212
|
+
# @param name [String] source basename without extension.
|
|
213
|
+
# @return [Boolean]
|
|
214
|
+
def split_entry?(entry, name)
|
|
215
|
+
return false unless entry.end_with?("_test.rb")
|
|
216
|
+
|
|
217
|
+
rest = entry.delete_prefix("#{name}_")
|
|
218
|
+
rest != entry && rest.match?(/\A.+_test\.rb\z/)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# True when a longer source file on disk owns this split test. `user.rb`
|
|
222
|
+
# does not take `user_session_test.rb` when `user_session.rb` exists in the
|
|
223
|
+
# same directory, under `app/`, or under `lib/`. An intermediate file
|
|
224
|
+
# counts: `bar_upsert.rb` owns `bar_upsert_guards_test.rb`, so `bar.rb`
|
|
225
|
+
# does not.
|
|
226
|
+
#
|
|
227
|
+
# @param root [String] expanded project root.
|
|
228
|
+
# @param base [String] logical source path without extension.
|
|
229
|
+
# @param entry [String] test file basename.
|
|
230
|
+
# @param scanned_dir [String, nil] relative test directory being scanned.
|
|
231
|
+
# When set, a longer source claims the file only if that source searches
|
|
232
|
+
# this directory. An `app/` source does not take a `test/lib/` file.
|
|
233
|
+
# @return [Boolean]
|
|
234
|
+
def claimed_by_longer_source?(root, base, entry, scanned_dir = nil)
|
|
235
|
+
name = File.basename(base)
|
|
236
|
+
stem = entry.sub(/_test\.rb\z/, "")
|
|
237
|
+
return false unless stem.start_with?("#{name}_")
|
|
238
|
+
|
|
239
|
+
rest = stem.delete_prefix("#{name}_")
|
|
240
|
+
return false if rest.empty?
|
|
241
|
+
|
|
242
|
+
dir = File.dirname(base)
|
|
243
|
+
parts = rest.split("_", -1)
|
|
244
|
+
(1..parts.length).any? do |i|
|
|
245
|
+
longer = "#{name}_#{parts.first(i).join("_")}"
|
|
246
|
+
next false unless longer_owns_entry?(entry, longer)
|
|
247
|
+
|
|
248
|
+
longer_source_exists?(root, dir, longer, scanned_dir)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# True when `entry` is the exact test, or a split test with a non-empty
|
|
253
|
+
# piece, for `stem`. `user_session__test.rb` is neither for
|
|
254
|
+
# `user_session`, so that source does not take it from `user.rb`.
|
|
255
|
+
#
|
|
256
|
+
# @param entry [String] test file basename.
|
|
257
|
+
# @param stem [String] longer source basename without extension.
|
|
258
|
+
# @return [Boolean]
|
|
259
|
+
def longer_owns_entry?(entry, stem)
|
|
260
|
+
entry == "#{stem}_test.rb" || split_entry?(entry, stem)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# True when a longer source file exists beside this logical path.
|
|
264
|
+
# The logical directory itself is checked, so `src/user_session.rb` owns
|
|
265
|
+
# `test/src/user_session_test.rb`. `app/` and `lib/` are checked too, so an
|
|
266
|
+
# `app/` source still yields the name to a `lib/` sibling and the reverse.
|
|
267
|
+
#
|
|
268
|
+
# @param root [String] expanded project root.
|
|
269
|
+
# @param dir [String] logical directory, or `.` when the source has none.
|
|
270
|
+
# @param stem [String] longer source basename without extension.
|
|
271
|
+
# @param scanned_dir [String, nil] relative test directory being scanned.
|
|
272
|
+
# @return [Boolean]
|
|
273
|
+
def longer_source_exists?(root, dir, stem, scanned_dir = nil)
|
|
274
|
+
file = "#{stem}.rb"
|
|
275
|
+
folders = [dir == "." ? nil : dir]
|
|
276
|
+
%w[app lib].each { |prefix| folders << (dir == "." ? prefix : File.join(prefix, dir)) }
|
|
277
|
+
folders.uniq.any? do |folder|
|
|
278
|
+
rel = folder ? File.join(folder, file) : file
|
|
279
|
+
next false unless File.file?(File.join(root, rel))
|
|
280
|
+
next false if scanned_dir && !source_searches_dir?(rel, scanned_dir)
|
|
281
|
+
|
|
282
|
+
true
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# True when pairing this source looks in `scanned_dir`.
|
|
287
|
+
#
|
|
288
|
+
# @param source_rel [String] project-relative source path.
|
|
289
|
+
# @param scanned_dir [String] relative test directory.
|
|
290
|
+
# @return [Boolean]
|
|
291
|
+
def source_searches_dir?(source_rel, scanned_dir)
|
|
292
|
+
base, lib = logical_path(source_rel)
|
|
293
|
+
dirs = [mirror_dir("test", base)]
|
|
294
|
+
dirs << mirror_dir("test/lib", base) if lib
|
|
295
|
+
dirs.include?(scanned_dir)
|
|
296
|
+
end
|
|
90
297
|
end
|
|
91
298
|
end
|