187 lines
5.3 KiB
Ruby
187 lines
5.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_dependency 'gitlab/utils'
|
|
|
|
module Gitlab
|
|
module Utils
|
|
module Override
|
|
class Extension
|
|
def self.verify_class!(klass, method_name, arity)
|
|
extension = new(klass)
|
|
parents = extension.parents_for(klass)
|
|
extension.verify_method!(
|
|
klass: klass, parents: parents, method_name: method_name, sub_method_arity: arity)
|
|
end
|
|
|
|
attr_reader :subject
|
|
|
|
def initialize(subject)
|
|
@subject = subject
|
|
end
|
|
|
|
def parents_for(klass)
|
|
index = klass.ancestors.index(subject)
|
|
klass.ancestors.drop(index + 1)
|
|
end
|
|
|
|
def verify!
|
|
classes.each do |klass|
|
|
parents = parents_for(klass)
|
|
|
|
method_names.each_pair do |method_name, arity|
|
|
verify_method!(
|
|
klass: klass,
|
|
parents: parents,
|
|
method_name: method_name,
|
|
sub_method_arity: arity)
|
|
end
|
|
end
|
|
end
|
|
|
|
def verify_method!(klass:, parents:, method_name:, sub_method_arity:)
|
|
overridden_parent = parents.find do |parent|
|
|
instance_method_defined?(parent, method_name)
|
|
end
|
|
|
|
raise NotImplementedError, "#{klass}\##{method_name} doesn't exist!" unless overridden_parent
|
|
|
|
super_method_arity = find_direct_method(overridden_parent, method_name).arity
|
|
|
|
unless arity_compatible?(sub_method_arity, super_method_arity)
|
|
raise NotImplementedError, "#{subject}\##{method_name} has arity of #{sub_method_arity}, but #{overridden_parent}\##{method_name} has arity of #{super_method_arity}"
|
|
end
|
|
end
|
|
|
|
def add_method_name(method_name, arity = nil)
|
|
method_names[method_name] = arity
|
|
end
|
|
|
|
def add_class(klass)
|
|
classes << klass
|
|
end
|
|
|
|
def verify_override?(method_name)
|
|
method_names.has_key?(method_name)
|
|
end
|
|
|
|
private
|
|
|
|
def instance_method_defined?(klass, name)
|
|
klass.method_defined?(name, false) ||
|
|
klass.private_method_defined?(name, false)
|
|
end
|
|
|
|
def find_direct_method(klass, name)
|
|
method = klass.instance_method(name)
|
|
method = method.super_method until method && klass == method.owner
|
|
method
|
|
end
|
|
|
|
def arity_compatible?(sub_method_arity, super_method_arity)
|
|
if sub_method_arity >= 0 && super_method_arity >= 0
|
|
# Regular arguments
|
|
sub_method_arity == super_method_arity
|
|
else
|
|
# It's too complex to check this case, just allow sub-method having negative arity
|
|
# But we don't allow sub_method_arity > 0 yet super_method_arity < 0
|
|
sub_method_arity < 0
|
|
end
|
|
end
|
|
|
|
def method_names
|
|
@method_names ||= {}
|
|
end
|
|
|
|
def classes
|
|
@classes ||= []
|
|
end
|
|
end
|
|
|
|
# Instead of writing patterns like this:
|
|
#
|
|
# def f
|
|
# raise NotImplementedError unless defined?(super)
|
|
#
|
|
# true
|
|
# end
|
|
#
|
|
# We could write it like:
|
|
#
|
|
# extend ::Gitlab::Utils::Override
|
|
#
|
|
# override :f
|
|
# def f
|
|
# true
|
|
# end
|
|
#
|
|
# This would make sure we're overriding something. See:
|
|
# https://gitlab.com/gitlab-org/gitlab/issues/1819
|
|
def override(method_name)
|
|
return unless ENV['STATIC_VERIFICATION']
|
|
|
|
Override.extensions[self] ||= Extension.new(self)
|
|
Override.extensions[self].add_method_name(method_name)
|
|
end
|
|
|
|
def method_added(method_name)
|
|
super
|
|
|
|
return unless ENV['STATIC_VERIFICATION']
|
|
return unless Override.extensions[self]&.verify_override?(method_name)
|
|
|
|
method_arity = instance_method(method_name).arity
|
|
if is_a?(Class)
|
|
Extension.verify_class!(self, method_name, method_arity)
|
|
else # We delay the check for modules
|
|
Override.extensions[self].add_method_name(method_name, method_arity)
|
|
end
|
|
end
|
|
|
|
def included(base = nil)
|
|
super
|
|
|
|
queue_verification(base) if base
|
|
end
|
|
|
|
def prepended(base = nil)
|
|
super
|
|
|
|
# prepend can override methods, thus we need to verify it like classes
|
|
queue_verification(base, verify: true) if base
|
|
end
|
|
|
|
def extended(mod = nil)
|
|
super
|
|
|
|
# Hack to resolve https://gitlab.com/gitlab-org/gitlab/-/issues/23932
|
|
is_not_concern_hack =
|
|
(mod.is_a?(Class) || !name&.end_with?('::ClassMethods'))
|
|
|
|
if mod && is_not_concern_hack
|
|
queue_verification(mod.singleton_class)
|
|
end
|
|
end
|
|
|
|
def queue_verification(base, verify: false)
|
|
return unless ENV['STATIC_VERIFICATION']
|
|
|
|
# We could check for Class in `override`
|
|
# This could be `nil` if `override` was never called.
|
|
# We also force verification for prepend because it can also override
|
|
# a method like a class, but not the cases for include or extend.
|
|
# This includes Rails helpers but not limited to.
|
|
if base.is_a?(Class) || verify
|
|
Override.extensions[self]&.add_class(base)
|
|
end
|
|
end
|
|
|
|
def self.extensions
|
|
@extensions ||= {}
|
|
end
|
|
|
|
def self.verify!
|
|
extensions.each_value(&:verify!)
|
|
end
|
|
end
|
|
end
|
|
end
|