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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +475 -0
  3. data/README.md +224 -28
  4. data/lib/mutineer/changed_lines.rb +24 -1
  5. data/lib/mutineer/cli.rb +117 -20
  6. data/lib/mutineer/config.rb +64 -13
  7. data/lib/mutineer/coverage_map.rb +533 -100
  8. data/lib/mutineer/daemon_backend.rb +51 -33
  9. data/lib/mutineer/daemon_client.rb +212 -22
  10. data/lib/mutineer/daemon_server.rb +74 -27
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/file_swap.rb +1 -1
  13. data/lib/mutineer/isolation.rb +92 -6
  14. data/lib/mutineer/job_plan.rb +352 -0
  15. data/lib/mutineer/kill_channel.rb +284 -0
  16. data/lib/mutineer/kill_matrix.rb +88 -0
  17. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  18. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  19. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  20. data/lib/mutineer/minitest_integration.rb +75 -11
  21. data/lib/mutineer/mutant_id.rb +5 -4
  22. data/lib/mutineer/mutators/array_literal.rb +0 -7
  23. data/lib/mutineer/mutators/base.rb +63 -4
  24. data/lib/mutineer/mutators/chain_link.rb +0 -7
  25. data/lib/mutineer/mutators/collection_method.rb +1 -1
  26. data/lib/mutineer/mutators/condition_forcing.rb +0 -7
  27. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  28. data/lib/mutineer/mutators/operator_assignment.rb +0 -7
  29. data/lib/mutineer/mutators/return_nil.rb +5 -11
  30. data/lib/mutineer/mutators/string_literal.rb +1 -1
  31. data/lib/mutineer/orphan_guard.rb +28 -0
  32. data/lib/mutineer/pairing.rb +222 -15
  33. data/lib/mutineer/parser.rb +12 -3
  34. data/lib/mutineer/project.rb +344 -21
  35. data/lib/mutineer/rails_worker_db.rb +83 -9
  36. data/lib/mutineer/reporter.rb +306 -53
  37. data/lib/mutineer/result.rb +73 -5
  38. data/lib/mutineer/runner.rb +123 -242
  39. data/lib/mutineer/statement_lines.rb +183 -0
  40. data/lib/mutineer/subject.rb +6 -2
  41. data/lib/mutineer/test_runners/minitest.rb +4 -2
  42. data/lib/mutineer/test_runners/rspec.rb +282 -3
  43. data/lib/mutineer/test_runners.rb +5 -3
  44. data/lib/mutineer/version.rb +1 -1
  45. data/lib/mutineer/worker_pool.rb +4 -2
  46. data/lib/mutineer.rb +2 -0
  47. 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, unless `SEED` is set.
20
- # With the stop, the test order can decide `killed` vs `timeout`; a fixed
21
- # order keeps the verdict stable for a `--baseline` gate.
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
- def self.run(test_files, stop_at_first_failure: false)
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
- Array(test_files).each { |f| load f }
56
-
57
- args = []
58
- if stop_at_first_failure && StopAtFirstFailure.arm!(Minitest::Runnable.runnables)
59
- # Pin the seed only when the stop is armed; an unknown Minitest shape
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
@@ -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. 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.
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 across edits outside the subject.
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&.accept(self)
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
- # ponytail: include? -> exclude? was specced but skipped — exclude? is not
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
- # ponytail: only plain "..." / '...' quotes. opening is nil for
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
@@ -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
- # * infer_test — a source's test file by convention (app/ and lib/
8
- # sources map to test/.../_test.rb, test/.../test_*.rb
9
- # or spec/.../_spec.rb), preserving namespaced
10
- # subdirectories. First EXISTING candidate wins.
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
- # -> itself. Flattened, deduped, order-stable.
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.delete_prefix("#{root}/") }
40
+ Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| existing_relative(f, roots) || f }
31
41
  else
32
- [arg]
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
- # 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.
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
- candidates(base, lib, prefer).find do |rel|
49
- File.exist?(File.expand_path(rel, project_root))
50
- end
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