mutineer 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>)] aggregate and source map.
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
@@ -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.namespace)
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>] namespace components.
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
- mod = mod.const_get(name) # const_get resolves a compact "Foo::Bar" too
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
@@ -3,16 +3,18 @@
3
3
  require "digest"
4
4
 
5
5
  module Mutineer
6
- # Content-based stable id for a mutant — NOT byte offsets. Pure function, reused
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 qualified_name (a method, not a
12
- # byte position) + operator + the normalized mutated token + an occurrence
13
- # ordinal among same-(operator, token) twins WITHIN the subject. So it survives
14
- # any edit outside the subject method — where raw start/end offsets shift on
15
- # every edit earlier in the file and would silently stop matching.
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
- Digest::SHA256.hexdigest(
33
- [subject.qualified_name, mutation.operator,
34
- normalized_token(mutation, source), occurrence].join("\x00")
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
- self.for(subject, m, source, occ)
103
+ yield m, occ
53
104
  end
54
105
  end
55
106
 
107
+ # NUL-joins the id parts and returns the first 12 hex chars of their SHA256.
108
+ #
109
+ # @param parts [Array] the values that make up the id.
110
+ # @return [String] a 12-character hex id.
111
+ def digest(parts)
112
+ Digest::SHA256.hexdigest(parts.join("\x00"))[0, 12]
113
+ end
114
+
56
115
  # Extracts the exact code being mutated, whitespace-collapsed — the same
57
116
  # normalization the Reporter's `diff_for` uses for its token label.
58
117
  #
@@ -17,6 +17,8 @@ require_relative "mutators/negation_removal"
17
17
  require_relative "mutators/chain_link"
18
18
  require_relative "mutators/operand_removal"
19
19
  require_relative "mutators/array_literal"
20
+ require_relative "mutators/condition_forcing"
21
+ require_relative "mutators/operator_assignment"
20
22
 
21
23
  module Mutineer
22
24
  # Maps operator names to operator classes.
@@ -45,7 +47,10 @@ module Mutineer
45
47
  "negation_removal" => Mutators::NegationRemoval,
46
48
  "chain_link" => Mutators::ChainLink,
47
49
  "operand_removal" => Mutators::OperandRemoval,
48
- "array_literal" => Mutators::ArrayLiteral
50
+ "array_literal" => Mutators::ArrayLiteral,
51
+ "condition_true" => Mutators::ConditionTrue,
52
+ "condition_false" => Mutators::ConditionFalse,
53
+ "operator_assignment" => Mutators::OperatorAssignment
49
54
  }.freeze
50
55
 
51
56
  # The default Tier-1 operator set.
@@ -53,7 +58,8 @@ module Mutineer
53
58
  # Tier-2 operators that remain opt-in.
54
59
  TIER2_NAMES = %w[return_nil literal_mutation condition_negation string_literal regex collection_method
55
60
  safe_navigation range negation_removal
56
- chain_link operand_removal array_literal].freeze
61
+ chain_link operand_removal array_literal
62
+ condition_true condition_false operator_assignment].freeze
57
63
 
58
64
  # Short human-readable descriptions for each operator.
59
65
  DESCRIPTIONS = {
@@ -73,7 +79,10 @@ module Mutineer
73
79
  "negation_removal" => "!x, not x -> x",
74
80
  "chain_link" => "drop one call from a chain: a.b.c -> a.c",
75
81
  "operand_removal" => "a && b -> a, b",
76
- "array_literal" => "[a, b] -> []"
82
+ "array_literal" => "[a, b] -> []",
83
+ "condition_true" => "if/elsif/unless/ternary/modifier/case-in guard condition -> true",
84
+ "condition_false" => "if/elsif/unless/ternary/modifier/case-in guard condition -> false",
85
+ "operator_assignment" => "+= <-> -=, *= <-> /=, %= -> *=, **= -> *= (not ||=, &&=)"
77
86
  }.freeze
78
87
 
79
88
  # Resolves operator names to classes.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Shared base of the condition-forcing mutators (Tier-2).
8
+ #
9
+ # Replaces the condition of an `if`, `elsif`, `unless`, ternary, modifier
10
+ # guard (`x if y`) or `case`/`in` guard with a literal, so its branch always
11
+ # runs or never runs. {ConditionTrue} forces `true` and {ConditionFalse}
12
+ # forces `false`; one mutation per condition each.
13
+ #
14
+ # A condition that is already `true`, `false` or `nil`, even in
15
+ # parentheses, is left alone: forcing it changes nothing or repeats the
16
+ # `boolean_literal` flip. This holds even when `boolean_literal` does not run.
17
+ # A condition that holds a heredoc is left alone too: the heredoc body lies
18
+ # outside the condition, so it would stay behind as code. A condition that
19
+ # assigns a variable keeps its code and only its value is forced.
20
+ #
21
+ # The never-runs side of an else-less conditional can be the same program
22
+ # as the `nil` that `statement_removal` or `return_nil` puts in place of the
23
+ # whole conditional. It is still made: dropping it would make the mutants
24
+ # of one operator depend on which other operators run, and on what the user
25
+ # suppressed for them. The two then get the same verdict.
26
+ #
27
+ # `condition_negation` wraps the condition in `!( ... )`, which neither
28
+ # forced value is.
29
+ class ConditionForcing < Base
30
+ # Condition nodes that `boolean_literal` already mutates.
31
+ LITERALS = [Prism::TrueNode, Prism::FalseNode, Prism::NilNode].freeze
32
+
33
+ # Visits if nodes: `if`, `elsif`, a ternary, a modifier `if` and an
34
+ # `in ... if` guard.
35
+ #
36
+ # @param node [Prism::IfNode] node to inspect.
37
+ # @return [void]
38
+ def visit_if_node(node)
39
+ force(node)
40
+ super
41
+ end
42
+
43
+ # Visits unless nodes: `unless`, a modifier `unless` and an
44
+ # `in ... unless` guard.
45
+ #
46
+ # @param node [Prism::UnlessNode] node to inspect.
47
+ # @return [void]
48
+ def visit_unless_node(node)
49
+ force(node)
50
+ super
51
+ end
52
+
53
+ # Nested method definitions are discovered as their own subjects; do not
54
+ # recurse into them (prevents double-counting their conditions).
55
+ #
56
+ # @param node [Prism::DefNode] nested definition node.
57
+ # @return [void]
58
+ def visit_def_node(node); end
59
+
60
+ private
61
+
62
+ # Emits the forced condition unless the condition is a literal or holds a
63
+ # heredoc, whose body would stay behind as code.
64
+ #
65
+ # @param node [Prism::IfNode, Prism::UnlessNode] the conditional.
66
+ # @return [void]
67
+ def force(node)
68
+ predicate = node.predicate
69
+ return if LITERALS.include?(unwrap(predicate).class) || heredoc?(predicate)
70
+
71
+ loc = predicate.location
72
+ @mutations << Mutation.new(
73
+ start_offset: loc.start_offset,
74
+ end_offset: loc.end_offset,
75
+ replacement: replacement(predicate),
76
+ operator: self.class::OPERATOR
77
+ )
78
+ end
79
+
80
+ # Returns the forced value for the condition, always in parentheses, so it
81
+ # cannot fuse with a keyword or a `?` next to it: `x if@a` becomes
82
+ # `x if(true)` and `@a?1:2` becomes `(true)?1:2`. A condition that assigns
83
+ # a variable keeps its code, so later reads still see the variable:
84
+ # `(m = x; true)`.
85
+ #
86
+ # @param predicate [Prism::Node] the condition.
87
+ # @return [String] the replacement source.
88
+ def replacement(predicate)
89
+ value = self.class::VALUE.to_s
90
+ writes?(predicate) ? "(#{predicate.slice}; #{value})" : "(#{value})"
91
+ end
92
+
93
+ # Returns whether the node writes a variable or constant anywhere inside
94
+ # it: a local, instance, class or global variable, or a constant, in a
95
+ # plain, compound, multiple or pattern write, or a named regex capture.
96
+ #
97
+ # @param node [Prism::Node] the node to inspect.
98
+ # @return [Boolean] true when a write or target node is inside.
99
+ def writes?(node)
100
+ return true if node.type.name.end_with?("_write_node", "_target_node")
101
+
102
+ node.compact_child_nodes.any? { |child| writes?(child) }
103
+ end
104
+
105
+ # Returns the node inside parentheses that hold exactly one expression,
106
+ # so `(true)` and `((true))` read as `true`.
107
+ #
108
+ # @param node [Prism::Node] the condition.
109
+ # @return [Prism::Node] the innermost wrapped node, or `node` itself.
110
+ def unwrap(node)
111
+ body = node.body if node.is_a?(Prism::ParenthesesNode)
112
+ body = body.body.first if body.is_a?(Prism::StatementsNode) && body.body.size == 1
113
+ body && !body.is_a?(Prism::StatementsNode) ? unwrap(body) : node
114
+ end
115
+ end
116
+
117
+ # Condition-forcing mutator: the condition becomes `true` (Tier-2).
118
+ #
119
+ # An `if` branch always runs; an `unless` branch never runs. The mutant
120
+ # survives when no test runs the code with the condition false (for an
121
+ # `if`) or true (for an `unless`) and checks what changes.
122
+ class ConditionTrue < ConditionForcing
123
+ # The literal the condition is replaced with.
124
+ VALUE = true
125
+ # The operator name mutations carry.
126
+ OPERATOR = :condition_true
127
+ end
128
+
129
+ # Condition-forcing mutator: the condition becomes `false` (Tier-2).
130
+ #
131
+ # An `if` branch never runs; an `unless` branch always runs. The mutant
132
+ # survives when no test needs the branch the condition guards (for an
133
+ # `if`) or skips it (for an `unless`).
134
+ class ConditionFalse < ConditionForcing
135
+ # The literal the condition is replaced with.
136
+ VALUE = false
137
+ # The operator name mutations carry.
138
+ OPERATOR = :condition_false
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Mutineer
6
+ module Mutators
7
+ # Operator-assignment mutator (Tier-2).
8
+ #
9
+ # Swaps the operator of a compound assignment, one mutation per
10
+ # occurrence: `t += x` -> `t -= x`. Prism parses `t += x` as its own
11
+ # node, not as a CallNode, so the arithmetic mutator never sees it.
12
+ #
13
+ # The mapping is the same as for arithmetic. Other compound operators
14
+ # (`||=`, `&&=`, `|=`, `<<=` and the rest) are not changed.
15
+ class OperatorAssignment < Base
16
+ # Token replacements for compound arithmetic operators.
17
+ REPLACEMENTS = {
18
+ :+ => "-=", :- => "+=", :* => "/=", :/ => "*=", :% => "*=", :** => "*="
19
+ }.freeze
20
+
21
+ # Visits `t += x`.
22
+ #
23
+ # @param node [Prism::LocalVariableOperatorWriteNode] node to inspect.
24
+ # @return [void]
25
+ def visit_local_variable_operator_write_node(node)
26
+ emit(node)
27
+ super # the value can hold its own compound assignment
28
+ end
29
+
30
+ # Visits `@t += x`.
31
+ #
32
+ # @param node [Prism::InstanceVariableOperatorWriteNode] node to inspect.
33
+ # @return [void]
34
+ def visit_instance_variable_operator_write_node(node)
35
+ emit(node)
36
+ super
37
+ end
38
+
39
+ # Visits `@@t += x`.
40
+ #
41
+ # @param node [Prism::ClassVariableOperatorWriteNode] node to inspect.
42
+ # @return [void]
43
+ def visit_class_variable_operator_write_node(node)
44
+ emit(node)
45
+ super
46
+ end
47
+
48
+ # Visits `$t += x`.
49
+ #
50
+ # @param node [Prism::GlobalVariableOperatorWriteNode] node to inspect.
51
+ # @return [void]
52
+ def visit_global_variable_operator_write_node(node)
53
+ emit(node)
54
+ super
55
+ end
56
+
57
+ # Visits `T += x`.
58
+ #
59
+ # @param node [Prism::ConstantOperatorWriteNode] node to inspect.
60
+ # @return [void]
61
+ def visit_constant_operator_write_node(node)
62
+ emit(node)
63
+ super
64
+ end
65
+
66
+ # Visits `A::T += x` and `::T += x`.
67
+ #
68
+ # @param node [Prism::ConstantPathOperatorWriteNode] node to inspect.
69
+ # @return [void]
70
+ def visit_constant_path_operator_write_node(node)
71
+ emit(node)
72
+ super
73
+ end
74
+
75
+ # Visits `a.b += x` and `a&.b += x`.
76
+ #
77
+ # @param node [Prism::CallOperatorWriteNode] node to inspect.
78
+ # @return [void]
79
+ def visit_call_operator_write_node(node)
80
+ emit(node)
81
+ super
82
+ end
83
+
84
+ # Visits `a[i] += x`.
85
+ #
86
+ # @param node [Prism::IndexOperatorWriteNode] node to inspect.
87
+ # @return [void]
88
+ def visit_index_operator_write_node(node)
89
+ emit(node)
90
+ super
91
+ end
92
+
93
+ # Skips a nested method definition. The project finds it as a subject of
94
+ # its own, so a visit here counts its compound assignments twice.
95
+ #
96
+ # @param node [Prism::DefNode] nested definition node.
97
+ # @return [void]
98
+ def visit_def_node(node); end
99
+
100
+ private
101
+
102
+ # Emits a mutation when the node's operator has a replacement.
103
+ #
104
+ # @param node [Prism::Node] a node with `binary_operator` and `binary_operator_loc`.
105
+ # @return [void]
106
+ def emit(node)
107
+ replacement = REPLACEMENTS[node.binary_operator]
108
+ return unless replacement
109
+
110
+ loc = node.binary_operator_loc
111
+ @mutations << Mutation.new(
112
+ start_offset: loc.start_offset,
113
+ end_offset: loc.end_offset,
114
+ replacement: replacement,
115
+ operator: :operator_assignment
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
@@ -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 = [] # names from `module_function :a, :b` / `module_function def`
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
- names = @module_function_names.to_set
55
- @subjects.each { |s| s.singleton = true if names.include?(s.name) }
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
- @namespace_stack.push(extract_constant_name(node.constant_path))
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
- @namespace_stack.push(extract_constant_name(node.constant_path))
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