Reduce zombifier

* And add a full specification
This commit is contained in:
Markus Schirp 2015-06-06 22:29:47 +00:00
parent a54e686255
commit c56ecdb51a
14 changed files with 399 additions and 251 deletions

View file

@ -10,7 +10,23 @@ require 'mutant'
namespace =
if ARGV.include?('--zombie')
$stderr.puts('Running mutant zombified!')
Mutant.zombify
Mutant::Zombifier.call(
namespace: :Zombie,
load_path: $LOAD_PATH,
kernel: Kernel,
pathname: Pathname,
require_highjack: Mutant::RequireHighjack.method(:call).to_proc.curry.call(Kernel),
root_require: 'mutant',
includes: %w[
mutant
unparser
morpher
adamantium
equalizer
anima
concord
]
)
Zombie::Mutant
else
Mutant

View file

@ -1,3 +1,3 @@
---
threshold: 18
total_score: 1240
total_score: 1227

View file

@ -46,8 +46,7 @@ NestedIterators:
- Mutant::Mutator::Util::Array::Element#dispatch
- Mutant::Mutator::Node::Resbody#mutate_captures
- Mutant::Mutator::Node::Arguments#emit_argument_mutations
- Mutant::RequireHighjack#infect
- Mutant::RequireHighjack#disinfect
- Mutant::RequireHighjack#self.call
- Mutant::Selector::Expression#call
- Mutant::Parallel::Master#run
- Parser::Lexer#self.new

View file

@ -24,11 +24,8 @@ Thread.abort_on_exception = true
# Library namespace
module Mutant
# The frozen empty string used within mutant
EMPTY_STRING = ''.freeze
# The frozen empty array used within mutant
EMPTY_ARRAY = [].freeze
EMPTY_STRING = ''.freeze
EMPTY_ARRAY = [].freeze
SCOPE_OPERATOR = '::'.freeze
# Test if CI is detected via environment
@ -41,17 +38,6 @@ module Mutant
ENV.key?('CI')
end
# Perform self zombification
#
# @return [self]
#
# @api private
#
def self.zombify
Zombifier.run('mutant', :Zombie)
self
end
# Define instance of subclassed superclass as constant
#
# @param [Class] superclass
@ -216,7 +202,6 @@ require 'mutant/reporter/cli/printer/test_result'
require 'mutant/reporter/cli/tput'
require 'mutant/reporter/cli/format'
require 'mutant/zombifier'
require 'mutant/zombifier/file'
module Mutant
# Reopen class to initialize constant to avoid dep circle

View file

@ -1,62 +1,23 @@
module Mutant
# Require highjack
class RequireHighjack
include Concord.new(:target, :callback)
module RequireHighjack
# Return original method
# Install require callback
#
# @param [Module] target
# @param [#call] callback
#
# @return [#call]
# the original implementation on singleton
#
# @api private
#
attr_reader :original
# Run block with highjacked require
#
# @return [self]
#
# @api private
#
def run
infect
yield
self
ensure
disinfect
end
# Infect kernel with highjack
#
# @return [self]
#
# @api private
#
def infect
callback = @callback
@original = target.method(:require)
target.module_eval do
undef :require
define_method(:require) do |logical_name|
callback.call(logical_name)
def self.call(target, callback)
target.method(:require).tap do
target.module_eval do
define_method(:require, &callback)
public :require
end
module_function :require
end
end
# Imperfectly disinfect kernel from highjack
#
# @return [self]
#
# @api private
#
def disinfect
original = @original
target.module_eval do
undef :require
define_method(:require) do |logical_name|
original.call(logical_name)
end
module_function :require
end
end

View file

@ -1,20 +1,19 @@
module Mutant
# Zombifier namespace
class Zombifier
include Adamantium::Flat, Concord.new(:namespace)
include Anima.new(
:includes,
:namespace,
:load_path,
:kernel,
:require_highjack,
:root_require,
:pathname
)
# Excluded into zombification
includes = %w[
mutant
unparser
morpher
adamantium
equalizer
anima
concord
]
include AST::Sexp
INCLUDES = %r{\A#{Regexp.union(includes)}(?:/.*)?\z}.freeze
LoadError = Class.new(::LoadError)
# Initialize object
#
@ -24,37 +23,28 @@ module Mutant
#
# @api private
#
def initialize(namespace)
def initialize(*)
super
@includes = %r{\A#{Regexp.union(includes)}(?:/.*)?\z}
@zombified = Set.new
@highjack = RequireHighjack.new(Kernel, method(:require))
super(namespace)
end
# Perform zombification of target library
#
# @param [String] logical_name
# @param [Symbol] namespace
#
# @return [self]
#
# @api private
#
def self.run(logical_name, namespace)
new(namespace).run(logical_name)
def self.call(*args)
new(*args).__send__(:call)
self
end
private
# Run zombifier
#
# @param [String] logical_name
#
# @return [undefined]
#
# @api private
#
def run(logical_name)
@highjack.infect
require(logical_name)
def call
@original = require_highjack.call(method(:require))
require(root_require)
end
# Test if logical name is subjected to zombification
@ -64,25 +54,77 @@ module Mutant
# @api private
#
def include?(logical_name)
!@zombified.include?(logical_name) && INCLUDES =~ logical_name
!@zombified.include?(logical_name) && @includes =~ logical_name
end
# Require file in zombie namespace
#
# @param [#to_s] logical_name
#
# @return [self]
# @return [undefined]
#
# @api private
#
def require(logical_name)
logical_name = logical_name.to_s
@highjack.original.call(logical_name)
@original.call(logical_name)
return unless include?(logical_name)
@zombified << logical_name
file = File.find(logical_name)
file.zombify(namespace) if file
self
zombify(find(logical_name))
end
# Find file by logical path
#
# @param [String] logical_name
#
# @return [File]
#
# @raise [LoadError]
# otherwise
#
# @api private
#
def find(logical_name)
file_name = "#{logical_name}.rb"
load_path.each do |path|
path = pathname.new(path).join(file_name)
return path if path.file?
end
fail LoadError, "Cannot find file #{file_name.inspect} in load path"
end
# Zombify contents of file
#
# Probably the 2nd valid use of eval ever. (First one is inserting mutants!).
#
# @param [Pathname] source_path
#
# @return [undefined]
#
# @api private
#
# rubocop:disable Lint/Eval
#
def zombify(source_path)
kernel.eval(
Unparser.unparse(namespaced_node(source_path)),
TOPLEVEL_BINDING,
source_path.to_s
)
end
# Return namespaced root
#
# @param [Symbol] namespace
#
# @return [Parser::AST::Node]
#
# @api private
#
def namespaced_node(source_path)
s(:module, s(:const, nil, namespace), Parser::CurrentRuby.parse(source_path.read))
end
end # Zombifier

View file

@ -1,100 +0,0 @@
module Mutant
class Zombifier
# File containing source being zombified
class File
include Adamantium::Flat, Concord::Public.new(:path), AST::Sexp
# Zombify contents of file
#
# @return [self]
#
# @api private
#
# Probably one of the only valid uses of eval.
#
# rubocop:disable Lint/Eval
#
def zombify(namespace)
$stderr.puts("Zombifying #{path}")
eval(
Unparser.unparse(namespaced_node(namespace)),
TOPLEVEL_BINDING,
path.to_s
)
self
end
# Find file by logical path
#
# @param [String] logical_name
#
# @return [File]
# if found
#
# @return [nil]
# otherwise
#
# @api private
#
def self.find(logical_name)
file_name = expand_file_name(logical_name)
$LOAD_PATH.each do |path|
path = Pathname.new(path).join(file_name)
return new(path) if path.file?
end
$stderr.puts "Cannot find file #{file_name} in $LOAD_PATH"
nil
end
# Return expanded file name
#
# @param [String] logical_name
#
# @return [nil]
# if no expansion is possible
#
# @return [String]
#
# @api private
#
def self.expand_file_name(logical_name)
case ::File.extname(logical_name)
when '.so'
return
when '.rb'
logical_name
else
"#{logical_name}.rb"
end
end
private_class_method :expand_file_name
private
# Return node
#
# @return [Parser::AST::Node]
#
# @api private
#
def node
Parser::CurrentRuby.parse(path.read, path.to_s)
end
# Return namespaced root
#
# @param [Symbol] namespace
#
# @return [Parser::AST::Node]
#
# @api private
#
def namespaced_node(namespace)
s(:module, s(:const, nil, namespace), node)
end
end # File
end # Zombifier
end # Mutant

View file

@ -1,6 +0,0 @@
RSpec.describe 'as a zombie', mutant: false do
specify 'it allows to create zombie from mutant' do
expect { Mutant.zombify }.to change { defined?(Zombie) }.from(nil).to('constant')
expect(Zombie.constants).to include(:Mutant)
end
end

View file

@ -20,6 +20,7 @@ end
require 'tempfile'
require 'concord'
require 'anima'
require 'adamantium'
require 'devtools/spec_helper'
require 'unparser/cli'

View file

@ -0,0 +1,60 @@
module MutantSpec
class FileState
DEFAULTS = IceNine.deep_freeze(
file: false,
contents: nil,
requires: []
)
include Adamantium, Anima.new(*DEFAULTS.keys)
def self.new(attributes = DEFAULTS)
super(DEFAULTS.merge(attributes))
end
DOES_NOT_EXIST = new
alias_method :file?, :file
end # FileState
class FakePathname
include Adamantium, Concord.new(:file_system, :pathname)
def join(*arguments)
self.class.new(
file_system,
pathname.join(*arguments)
)
end
def read
state.contents
end
def to_s
pathname.to_s
end
def file?
state.file?
end
private
def state
file_system.state(pathname.to_s)
end
end # Pathname
class FileSystem
include Adamantium, Concord.new(:file_states)
def state(filename)
file_states.fetch(filename, FileState::DOES_NOT_EXIST)
end
def path(filename)
FakePathname.new(self, Pathname.new(filename))
end
end # FileSystem
end # MutantSpec

82
spec/support/ruby_vm.rb Normal file
View file

@ -0,0 +1,82 @@
module MutantSpec
# Not a real VM, just kidding. It connects the require / eval triggers
# require semantics Zombifier relies on in a way we can avoid having to
# mock around everyhwere to test every detail.
#
# rubocop:disable LineLength
class RubyVM
include Concord.new(:expected_events)
# An event being observed by the VM handlers
class EventObservation
include Concord::Public.new(:type, :payload)
end
# An event being expected, can advance the VM
class EventExpectation
include AbstractType, Anima.new(:expected_payload, :trigger_requires)
DEFAULTS = IceNine.deep_freeze(trigger_requires: [])
def initialize(attributes)
super(DEFAULTS.merge(attributes))
end
def handle(vm, observation)
unless match?(observation)
fail "Unexpected event observation: #{observation.inspect}, expected #{inspect}"
end
trigger_requires.each(&vm.method(:require))
end
private
abstract_method :advance_vm
def match?(observation)
observation.type.eql?(self.class) && observation.payload.eql?(expected_payload)
end
# Expectation and advance on require calls
class Require < self
end
# Expectation and advance on eval calls
class Eval < self
end
end
# A fake implementation of Kernel#require
def require(logical_name)
handle_event(EventObservation.new(EventExpectation::Require, logical_name: logical_name))
self
end
# A fake implementation of Kernel#eval
def eval(source, binding, location)
handle_event(
EventObservation.new(
EventExpectation::Eval,
binding: binding,
source: source,
source_location: location
)
)
self
end
# Test if VM events where fully processed
def done?
expected_events.empty?
end
private
def handle_event(observation)
fail "Unexpected event: #{observation.type} / #{observation.payload}" if expected_events.empty?
expected_events.slice!(0).handle(self, observation)
end
end
end # MutantSpec

View file

@ -1,50 +1,47 @@
RSpec.describe Mutant::RequireHighjack do
let(:object) { described_class.new(target, highjacked_calls.method(:push)) }
let(:highjacked_calls) { [] }
let(:require_calls) { [] }
let(:target) do
let(:target_module) do
acc = require_calls
Module.new do
define_method(:require, &acc.method(:<<))
module_function :require
public :require
end
end
describe '#run' do
let(:block) { -> {} }
def target_require(logical_name)
Object.new.extend(target_module).require(logical_name)
end
describe '.call' do
let(:logical_name) { double('Logical Name') }
subject do
object.run(&block)
def apply
described_class.call(target_module, highjacked_calls.method(:<<))
end
context 'require calls before run' do
it 'does not highjack anything' do
target.require(logical_name)
expect(require_calls).to eql([logical_name])
expect(highjacked_calls).to eql([])
end
it 'returns the original implementation from singleton' do
expect { apply.call(logical_name) }
.to change { require_calls }
.from([])
.to([logical_name])
end
context 'require calls during run' do
let(:block) { -> { target.require(logical_name) } }
it 'does highjack the calls' do
expect { subject }.to change { highjacked_calls }.from([]).to([logical_name])
expect(require_calls).to eql([])
end
it 'does highjack target object #requires calls' do
apply
expect { target_require(logical_name) }
.to change { highjacked_calls }
.from([])
.to([logical_name])
end
context 'require calls after run' do
it 'does not the calls anything' do
subject
target.require(logical_name)
expect(require_calls).to eql([logical_name])
expect(highjacked_calls).to eql([])
end
it 'does not call original require' do
apply
expect { target_require(logical_name) }
.not_to change { require_calls }.from([])
end
end
end

View file

@ -0,0 +1,120 @@
RSpec.describe Mutant::Zombifier do
let(:root_require) { Pathname.new('project') }
let(:pathname) do
instance_double(::Pathname.singleton_class)
end
let(:require_highjack) do
lambda do |block|
original = ruby_vm.method(:require)
allow(ruby_vm).to receive(:require, &block)
original
end
end
let(:options) do
{
load_path: %w[a b],
includes: %w[project bar],
namespace: :Zombie,
require_highjack: require_highjack,
root_require: root_require,
pathname: pathname,
kernel: ruby_vm
}
end
let(:ruby_vm) do
MutantSpec::RubyVM.new(
[
MutantSpec::RubyVM::EventExpectation::Require.new(
expected_payload: {
logical_name: 'project'
}
),
MutantSpec::RubyVM::EventExpectation::Eval.new(
expected_payload: {
binding: TOPLEVEL_BINDING,
source: "module Zombie\n module Project\n end\nend",
source_location: 'a/project.rb'
},
trigger_requires: %w[foo bar]
),
MutantSpec::RubyVM::EventExpectation::Require.new(
expected_payload: {
logical_name: 'foo'
},
trigger_requires: %w[bar]
),
MutantSpec::RubyVM::EventExpectation::Require.new(
expected_payload: {
logical_name: 'bar'
}
),
MutantSpec::RubyVM::EventExpectation::Eval.new(
expected_payload: {
binding: TOPLEVEL_BINDING,
source: "module Zombie\n module Bar\n end\nend",
source_location: 'b/bar.rb'
},
trigger_requires: %w[]
),
MutantSpec::RubyVM::EventExpectation::Require.new(
expected_payload: {
logical_name: 'bar'
}
)
]
)
end
let(:require_effects) do
{
'project' => { requires: [] }
}
end
let(:file_entries) do
{
'a/project.rb' => { file: true, contents: 'module Project; end' },
'b/bar.rb' => { file: true, contents: 'module Bar; end' }
}
end
let(:file_system) do
MutantSpec::FileSystem.new(
Hash[
file_entries.map { |key, attributes| [key, MutantSpec::FileState.new(attributes)] }
]
)
end
describe '.call' do
def apply
described_class.call(options)
end
before do
allow(pathname).to receive(:new, &file_system.method(:path))
end
it 'returns self' do
expect(apply).to be(described_class)
end
it 'consumes walks the VM through expected steps' do
expect { apply }.to change { ruby_vm.done? }.from(false).to(true)
end
context 'when zombifier require fails' do
let(:file_entries) do
{}
end
it 'raises zombifier specific load error' do
expect { apply }.to raise_error(described_class::LoadError, 'Cannot find file "project.rb" in load path')
end
end
end
end

View file

@ -13,15 +13,6 @@ RSpec.describe Mutant do
it { should be(result) }
end
describe '.zombify' do
subject { object.zombify }
it 'calls the zombifier' do
expect(Mutant::Zombifier).to receive(:run).with('mutant', :Zombie)
subject
end
end
describe '.singleton_subclass_instance' do
subject { object.singleton_subclass_instance(name, superclass, &block) }