Skip to content

Commit

Permalink
Merge pull request #1426 from mbj/fix/const-scope
Browse files Browse the repository at this point in the history
Fix const scope
  • Loading branch information
mbj authored Mar 9, 2024
2 parents 33885c8 + 4250ea8 commit fa6d66a
Show file tree
Hide file tree
Showing 42 changed files with 780 additions and 315 deletions.
6 changes: 5 additions & 1 deletion Changelog.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# v0.11.29 [unreleased]
# v0.11.29 2024-03-09

* [#1426](https://github.com/mbj/mutant/pull/1426)
Fix mutations to unintentionally change constant scope.
This fixes: https://github.com/mbj/mutant/issues/1422.

* [#1421](https://github.com/mbj/mutant/pull/1421)
Change to optional warning display via --print-warnings environment option.
Expand Down
17 changes: 8 additions & 9 deletions lib/mutant/ast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ class AST
)

class View
include Adamantium, Anima.new(:node, :path)
include Adamantium, Anima.new(:node, :stack)
end

def on_line(line)
line_map.fetch(line, EMPTY_HASH).map do |node, path|
View.new(node: node, path: path)
line_map.fetch(line, EMPTY_HASH).map do |node, stack|
View.new(node: node, stack: stack)
end
end

Expand All @@ -22,21 +22,20 @@ def on_line(line)
def line_map
line_map = {}

walk_path(node) do |node, path|
walk_path(node, []) do |node, stack|
expression = node.location.expression || next
(line_map[expression.line] ||= []) << [node, path]
(line_map[expression.line] ||= []) << [node, stack]
end

line_map
end
memoize :line_map

def walk_path(node, stack = [node.type], &block)
block.call(node, stack.dup)
def walk_path(node, stack, &block)
block.call(node, stack)
stack = [*stack, node]
node.children.grep(::Parser::AST::Node) do |child|
stack.push(child.type)
walk_path(child, stack, &block)
stack.pop
end
end
end # AST
Expand Down
24 changes: 12 additions & 12 deletions lib/mutant/bootstrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ module Bootstrap
'%<scope_class>s#name from: %<scope>s raised an error: %<exception>s'

CLASS_NAME_TYPE_MISMATCH_FORMAT =
'%<scope_class>s#name from: %<scope>s returned %<name>s'
'%<scope_class>s#name from: %<raw_scope>s returned %<name>s'

private_constant(*constants(false))

Expand Down Expand Up @@ -139,41 +139,41 @@ def self.matchable_scopes(env)
env.record(__method__) do
config = env.config

scopes = env.world.object_space.each_object(Module).with_object([]) do |scope, aggregate|
expression = expression(config.reporter, config.expression_parser, scope) || next
aggregate << Scope.new(raw: scope, expression: expression)
scopes = env.world.object_space.each_object(Module).with_object([]) do |raw_scope, aggregate|
expression = expression(config.reporter, config.expression_parser, raw_scope) || next
aggregate << Scope.new(raw: raw_scope, expression: expression)
end

scopes.sort_by { |scope| scope.expression.syntax }
end
end
private_class_method :matchable_scopes

def self.scope_name(reporter, scope)
scope.name
def self.scope_name(reporter, raw_scope)
raw_scope.name
rescue => exception
semantics_warning(
reporter,
CLASS_NAME_RAISED_EXCEPTION,
exception: exception.inspect,
scope: scope,
scope_class: scope.class
scope: raw_scope,
scope_class: raw_scope.class
)
nil
end
private_class_method :scope_name

# rubocop:disable Metrics/MethodLength
def self.expression(reporter, expression_parser, scope)
name = scope_name(reporter, scope) or return
def self.expression(reporter, expression_parser, raw_scope)
name = scope_name(reporter, raw_scope) or return

unless name.instance_of?(String)
semantics_warning(
reporter,
CLASS_NAME_TYPE_MISMATCH_FORMAT,
name: name,
scope_class: scope.class,
scope: scope
scope_class: raw_scope.class,
raw_scope: raw_scope
)
return
end
Expand Down
92 changes: 31 additions & 61 deletions lib/mutant/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,83 +3,53 @@
module Mutant
# An abstract context where mutations can be applied to.
class Context
include Adamantium, Anima.new(:scope, :source_path)
extend AST::Sexp
include Adamantium, Anima.new(:constant_scope, :scope, :source_path)

NAMESPACE_DELIMITER = '::'
class ConstantScope
include AST::Sexp

# Return root node for mutation
#
# @return [Parser::AST::Node]
def root(node)
nesting.reverse.reduce(node) do |current, scope|
self.class.wrap(scope, current)
class Class < self
include Anima.new(:const, :descendant)

def call(node)
s(:class, const, nil, descendant.call(node))
end
end
end

# Identification string
#
# @return [String]
def identification
scope.name
end
class Module < self
include Anima.new(:const, :descendant)

# Wrap node into ast node
#
# @param [Class, Module] scope
# @param [Parser::AST::Node] node
#
# @return [Parser::AST::Class]
# if scope is of kind Class
#
# @return [Parser::AST::Module]
# if scope is of kind module
def self.wrap(scope, node)
name = s(:const, nil, scope.name.split(NAMESPACE_DELIMITER).last.to_sym)
case scope
when Class
s(:class, name, nil, node)
when Module
s(:module, name, node)
def call(node)
s(:module, const, descendant.call(node))
end
end
end

# Nesting of scope
#
# @return [Enumerable<Class,Module>]
def nesting
const = Object
name_nesting.map do |name|
const = const.const_get(name)
class None < self
include Equalizer.new

def call(node)
node
end
end
end
memoize :nesting

# Unqualified name of scope
#
# @return [String]
def unqualified_name
name_nesting.last
def match_expressions
scope.match_expressions
end

# Match expressions for scope
# Return root node for mutation
#
# @return [Enumerable<Expression>]
def match_expressions
name_nesting.each_index.reverse_each.map do |index|
Expression::Namespace::Recursive.new(
scope_name: name_nesting.take(index.succ).join(NAMESPACE_DELIMITER)
)
end
# @return [Parser::AST::Node]
def root(node)
constant_scope.call(node)
end
memoize :match_expressions

private

def name_nesting
scope.name.split(NAMESPACE_DELIMITER)
# Identification string
#
# @return [String]
def identification
scope.raw.name
end
memoize :name_nesting

end # Context
end # Mutant
5 changes: 4 additions & 1 deletion lib/mutant/expression/method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ def self.valid_method_name?(name)
private

def scope
Object.const_get(scope_name)
Scope.new(
raw: Object.const_get(scope_name),
expression: Namespace::Exact.new(scope_name: scope_name)
)
end

end # Method
Expand Down
5 changes: 4 additions & 1 deletion lib/mutant/expression/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ def match_length(expression)
private

def scope
Object.const_get(scope_name)
Scope.new(
expression: Namespace::Exact.new(scope_name: scope_name),
raw: Object.const_get(scope_name)
)
end

end # Methods
Expand Down
8 changes: 4 additions & 4 deletions lib/mutant/expression/namespace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ class Exact < self
#
# @return [Matcher]
def matcher
scope = find_scope
raw_scope = find_raw_scope

if scope
Matcher::Scope.new(scope: scope)
if raw_scope
Matcher::Scope.new(scope: Scope.new(expression: self, raw: raw_scope))
else
Matcher::Null.new
end
Expand All @@ -83,7 +83,7 @@ def matcher

private

def find_scope
def find_raw_scope
Object.const_get(scope_name)
rescue NameError # rubocop:disable Lint/SuppressedException
end
Expand Down
2 changes: 1 addition & 1 deletion lib/mutant/matcher/descendants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def call(env)
const = env.world.try_const_get(const_name) or return EMPTY_ARRAY

Chain.new(
matchers: matched_scopes(env, const).map { |scope| Scope.new(scope: scope.raw) }
matchers: matched_scopes(env, const).map { |scope| Scope.new(scope: scope) }
).call(env)
end

Expand Down
34 changes: 30 additions & 4 deletions lib/mutant/matcher/method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ class Method < self
CLOSURE_WARNING_FORMAT =
'%s is dynamically defined in a closure, unable to emit subject'

CONSTANT_SCOPES = {
class: Context::ConstantScope::Class,
module: Context::ConstantScope::Module
}.freeze

# Matched subjects
#
# @param [Env] env
Expand All @@ -28,6 +33,8 @@ def call(env)
# Present to avoid passing the env argument around in case the
# logic would be implemented directly on the Matcher::Method
# instance
#
# rubocop:disable Metrics/ClassLength
class Evaluator
include(
AbstractType,
Expand Down Expand Up @@ -57,7 +64,7 @@ def call
def match_view
return EMPTY_ARRAY if matched_view.nil?

if matched_view.path.any?(&:block.public_method(:equal?))
if matched_view.stack.any? { |node| node.type.equal?(:block) }
env.warn(CLOSURE_WARNING_FORMAT % target_method)

return EMPTY_ARRAY
Expand All @@ -80,7 +87,26 @@ def method_name
end

def context
Context.new(scope: scope, source_path: source_path)
Context.new(constant_scope: constant_scope, scope: scope, source_path: source_path)
end

# rubocop:disable Metrics/MethodLength
def constant_scope
matched_view
.stack
.reverse
.reduce(Context::ConstantScope::None.new) do |descendant, node|
klass = CONSTANT_SCOPES[node.type]

if klass
klass.new(
const: node.children.fetch(0),
descendant: descendant
)
else
descendant
end
end
end

def ast
Expand Down Expand Up @@ -151,9 +177,9 @@ def visibility
# end
#
# Change to this once 3.0 is EOL.
if scope.private_methods.include?(method_name)
if scope.raw.private_methods.include?(method_name)
:private
elsif scope.protected_methods.include?(method_name)
elsif scope.raw.protected_methods.include?(method_name)
:protected
else
:public
Expand Down
9 changes: 5 additions & 4 deletions lib/mutant/matcher/method/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class Instance < self

# Dispatching builder, detects memoizable case
#
# @param [Class, Module] scope
# @param [Scope] scope
# @param [UnboundMethod] method
#
# @return [Matcher::Method::Instance]
Expand All @@ -31,7 +31,7 @@ def self.new(scope:, target_method:)
# rubocop:enable Metrics/MethodLength

def self.memoized_method?(scope, method_name)
scope < Adamantium && scope.memoized?(method_name)
scope.raw < Adamantium && scope.raw.memoized?(method_name)
end
private_class_method :memoized_method?

Expand All @@ -48,9 +48,9 @@ def match?(node)
end

def visibility
if scope.private_instance_methods.include?(method_name)
if scope.raw.private_instance_methods.include?(method_name)
:private
elsif scope.protected_instance_methods.include?(method_name)
elsif scope.raw.protected_instance_methods.include?(method_name)
:protected
else
:public
Expand All @@ -65,6 +65,7 @@ class Memoized < self

def source_location
scope
.raw
.unmemoized_instance_method(method_name)
.source_location
end
Expand Down
Loading

0 comments on commit fa6d66a

Please sign in to comment.