From 5ccb7c0b9e859604eba58fd6df375bad9ea1851f Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Tue, 16 Sep 2025 13:33:43 -0700 Subject: [PATCH 01/10] Add currently-failing test Merging trees where the rhs refers to a type only defined in the lhs gets confused about module namespaces. --- test/rbi/rewriters/merge_trees_test.rb | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/rbi/rewriters/merge_trees_test.rb b/test/rbi/rewriters/merge_trees_test.rb index 21714ac8..2e21c9d8 100644 --- a/test/rbi/rewriters/merge_trees_test.rb +++ b/test/rbi/rewriters/merge_trees_test.rb @@ -1175,5 +1175,77 @@ def m2; end end RBI end + + def test_merge_rhs_referencing_type_in_lhs + lhs = parse_rbi(<<~RBI) + module Foo; end + class Foo::Bar; end + + class Foo::Baz < Foo::Bar; + def a; end + end + RBI + + rhs = parse_rbi(<<~RBI) + module Foo + class Baz < Bar + sig { returns(T.nilable(Bar)) } + def a; end + def b; end + end + end + RBI + + res = lhs.merge(rhs) + + assert_equal(<<~RBI, res.string) + module Foo; end + class Foo::Bar; end + + class Foo::Baz < Foo::Bar + sig { returns(T.nilable(Foo::Bar)) } + def a; end + + def b; end + end + RBI + end + + def test_merge_lhs_referencing_type_in_rhs + # Note how lhs.merge(rhs) is a different result vs rhs.merge(lhs) + lhs = parse_rbi(<<~RBI) + module Foo + class Baz < Bar + sig { returns(T.nilable(Bar)) } + def a; end + def b; end + end + end + RBI + + rhs = parse_rbi(<<~RBI) + module Foo; end + class Foo::Bar; end + + class Foo::Baz < Foo::Bar; + def a; end + end + RBI + + res = lhs.merge(rhs) + + assert_equal(<<~RBI, res.string) + module Foo + class Baz < Bar + sig { returns(T.nilable(Bar)) } + def a; end + + def b; end + end + end + + class Foo::Bar; end + RBI + end end end From c9921f9948414266df00f562bf2f2ed6159556ea Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 17 Sep 2025 16:30:25 -0700 Subject: [PATCH 02/10] Implement merging type references by looking up scopes --- lib/rbi/index.rb | 4 + lib/rbi/model.rb | 2 +- lib/rbi/rewriters/merge_trees.rb | 249 ++++++++++++++++++++----- test/rbi/rewriters/merge_trees_test.rb | 4 +- 4 files changed, 205 insertions(+), 54 deletions(-) diff --git a/lib/rbi/index.rb b/lib/rbi/index.rb index 0efa18a3..0278ffab 100644 --- a/lib/rbi/index.rb +++ b/lib/rbi/index.rb @@ -49,6 +49,10 @@ def visit(node) end end + def to_s + "#" + end + private #: ((Indexable & Node) node) -> void diff --git a/lib/rbi/model.rb b/lib/rbi/model.rb index 50ca54cc..53c1c3fe 100644 --- a/lib/rbi/model.rb +++ b/lib/rbi/model.rb @@ -297,7 +297,7 @@ class Attr < NodeWithComments attr_accessor :visibility #: Array[Sig] - attr_reader :sigs + attr_accessor :sigs #: (Symbol name, Array[Symbol] names, ?visibility: Visibility, ?sigs: Array[Sig], ?loc: Loc?, ?comments: Array[Comment]) -> void def initialize(name, names, visibility: Public.new, sigs: [], loc: nil, comments: []) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index f4df2cf6..6ae95ae6 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -37,6 +37,8 @@ module Rewriters # end # ~~~ class Merge + ASSUME_GLOBAL_CLASS = ["String", "Symbol", "Integer", "Float", "NilClass", "TrueClass", "FalseClass"].freeze + class Keep NONE = new #: Keep LEFT = new #: Keep @@ -57,6 +59,90 @@ def merge_trees(left, right, left_name: "left", right_name: "right", keep: Keep: ConflictTreeMerger.new.visit(tree) tree end + + # Returns a node from in_index that corresponds to the given type name + # when referenced from the given referrer Node. The referrer can be + # in a different tree, but its scope chain names will be used to find + # the referent in in_index. + #: (name: String?, referrer: Node, in_index: Index) -> Node? + def lookup_type(name:, referrer:, in_index:) + return unless name + + return in_index[name] if name.start_with?("::") + + referrer_scope = referrer.is_a?(Scope) ? referrer : referrer.parent_scope + loop do + scoped_name = "#{referrer_scope&.fully_qualified_name}::#{name}" + referent = in_index[scoped_name].last + break referent if referent + break unless referrer_scope + + referrer_scope = referrer_scope.parent_scope + end + end + + #: ((Type | String) type, referrer: Node, in_index: Index) -> Type + def fully_qualify_type(type, referrer:, in_index:) + case type + when String + fully_qualify_type(Type.parse_string(type), referrer:, in_index:) + when Type::Simple + # Heuristic perf optimization: assume some common Ruby global classes like + # Symbol, String, Integer, Float, etc, are global to skip the namespace lookup. + if ASSUME_GLOBAL_CLASS.include?(type.name) + type + else + referent = lookup_type( + name: type.name, + referrer: referrer, + in_index:, + ) + Type.simple(referent&.fully_qualified_name || type.name) + end + when Type::Nilable + Type.nilable(fully_qualify_type(type.type, referrer:, in_index:)) + when Type::Composite, Type::Tuple + type.class.new(type.types.map { fully_qualify_type(_1, referrer:, in_index:) }) + when Type::Generic + Type.generic(type.name, *type.params.map { fully_qualify_type(_1, referrer:, in_index:) }) + when Type::TypeAlias + Type.type_alias(type.name, fully_qualify_type(type.aliased_type, referrer:, in_index:)) + when Type::Shape + Type.shape(type.types.transform_values { fully_qualify_type(_1, referrer:, in_index:) }) + when Type::Proc + Type.proc + .params(type.proc_params.transform_values { fully_qualify_type(_1, referrer:, in_index:) }) + .returns(fully_qualify_type(type.proc_returns, referrer:, in_index:)) + .bind(fully_qualify_type(type.proc_bind, referrer:, in_index:)) + else + type + end + end + + #: (Sig sig, referrer: Node, in_index: Index) -> Sig + def fully_qualify_sig(sig, referrer:, in_index:) + Sig.new( + params: sig.params.map do |param| + SigParam.new( + param.name, + fully_qualify_type(param.type, referrer:, in_index:), + loc: param.loc, + comments: param.comments, + ) + end, + return_type: fully_qualify_type(sig.return_type, referrer:, in_index:), + is_abstract: sig.is_abstract, + is_override: sig.is_override, + is_overridable: sig.is_overridable, + is_final: sig.is_final, + allow_incompatible_override: sig.allow_incompatible_override, + without_runtime: sig.without_runtime, + type_params: sig.type_params, + checked: sig.checked, + loc: sig.loc, + comments: sig.comments, + ) + end end #: MergeTree @@ -126,8 +212,8 @@ def visit(node) prev = previous_definition(node) if prev.is_a?(Scope) - if node.compatible_with?(prev) - prev.merge_with(node) + if node.compatible_with?(prev, in_index: @index) + prev.merge_with(node, in_index: @index) elsif @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT @@ -139,18 +225,19 @@ def visit(node) else copy = node.dup_empty current_scope << copy + @index.index(copy) @scope_stack << copy end visit_all(node.nodes) @scope_stack.pop when Tree - current_scope.merge_with(node) + current_scope.merge_with(node, in_index: @index) visit_all(node.nodes) when Indexable prev = previous_definition(node) if prev - if node.compatible_with?(prev) - prev.merge_with(node) + if node.compatible_with?(prev, in_index: @index) + prev.merge_with(node, in_index: @index) elsif @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT @@ -159,7 +246,9 @@ def visit(node) make_conflict_tree(prev, node) end else - current_scope << node.dup + copy = node.dup + current_scope << copy + @index.index(copy) end end end @@ -284,15 +373,15 @@ def merge_conflict_trees(left, right) end class Node - # Can `self` and `_other` be merged into a single definition? - #: (Node _other) -> bool - def compatible_with?(_other) + # Can `self` be merged into `_prev`? + #: (Node _prev, in_index: Index) -> bool + def compatible_with?(_prev, in_index:) true end # Merge `self` and `other` into a single definition - #: (Node other) -> void - def merge_with(other); end + #: (Node other, in_index: Index) -> void + def merge_with(other, in_index:); end #: -> ConflictTree? def parent_conflict_tree @@ -309,7 +398,7 @@ def parent_conflict_tree class NodeWithComments # @override #: (Node other) -> void - def merge_with(other) + def merge_with(other, **) return unless other.is_a?(NodeWithComments) other.comments.each do |comment| @@ -367,16 +456,28 @@ def dup_empty class Class # @override - #: (Node other) -> bool - def compatible_with?(other) - other.is_a?(Class) && superclass_name == other.superclass_name + #: (Node prev, in_index: Index) -> bool + def compatible_with?(prev, in_index:) + return false unless prev.is_a?(Class) + + self_superclass = Rewriters::Merge.lookup_type( + name: superclass_name, + referrer: self, + in_index:, + ) + prev_superclass = Rewriters::Merge.lookup_type( + name: prev.superclass_name, + referrer: prev, + in_index:, + ) + self_superclass == prev_superclass end end class Module # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Module) end end @@ -384,7 +485,7 @@ def compatible_with?(other) class Struct # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Struct) && members == other.members && keyword_init == other.keyword_init end end @@ -392,37 +493,52 @@ def compatible_with?(other) class Const # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Const) && name == other.name && value == other.value end end class Attr # @override - #: (Node other) -> bool - def compatible_with?(other) - return false unless other.is_a?(Attr) - return false unless names == other.names + #: (Node other, in_index: Index) -> bool + def compatible_with?(prev, in_index:) + return false unless prev.is_a?(Attr) + return false unless names == prev.names - sigs.empty? || other.sigs.empty? || sigs == other.sigs + lhs_sigs = sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) + end + rhs_sigs = prev.sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: prev, in_index:) + end + + lhs_sigs.empty? || rhs_sigs.empty? || lhs_sigs == rhs_sigs end # @override - #: (Node other) -> void - def merge_with(other) + #: (Node other, in_index: Index) -> void + def merge_with(other, in_index:) return unless other.is_a?(Attr) super - other.sigs.each do |sig| - sigs << sig unless sigs.include?(sig) + + merged_sigs = sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) end + rhs_sigs = other.sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: other, in_index:) + end + rhs_sigs.each do |sig| + merged_sigs << sig unless merged_sigs.include?(sig) + end + self.sigs = merged_sigs end end class AttrReader # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(AttrReader) && super end end @@ -430,7 +546,7 @@ def compatible_with?(other) class AttrWriter # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(AttrWriter) && super end end @@ -438,46 +554,77 @@ def compatible_with?(other) class AttrAccessor # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(AttrAccessor) && super end end class Method # @override - #: (Node other) -> bool - def compatible_with?(other) - return false unless other.is_a?(Method) - return false unless name == other.name - return false unless params == other.params + #: (Node prev, in_index: Index) -> bool + def compatible_with?(prev, in_index:) + return false unless prev.is_a?(Method) + return false unless name == prev.name + return false unless params == prev.params + + lhs_sigs = sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) + end + rhs_sigs = prev.sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: prev, in_index:) + end - sigs.empty? || other.sigs.empty? || sigs == other.sigs + lhs_sigs.empty? || rhs_sigs.empty? || lhs_sigs == rhs_sigs end # @override - #: (Node other) -> void - def merge_with(other) + #: (Node other, in_index: Index) -> void + def merge_with(other, in_index:) return unless other.is_a?(Method) super - other.sigs.each do |sig| - sigs << sig unless sigs.include?(sig) + + merged_sigs = sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) + end + rhs_sigs = other.sigs.map do |sig| + Rewriters::Merge.fully_qualify_sig(sig, referrer: other, in_index:) end + rhs_sigs.each do |sig| + merged_sigs << sig unless merged_sigs.include?(sig) + end + self.sigs = merged_sigs end end class Mixin # @override - #: (Node other) -> bool - def compatible_with?(other) - other.is_a?(Mixin) && names == other.names + #: (Node other, in_index: Index) -> bool + def compatible_with?(other, in_index:) + return false unless other.is_a?(Mixin) + + lhs_mixins = names.map do |name| + Rewriters::Merge.lookup_type( + name:, + referrer: self, + in_index:, + ) + end + rhs_mixins = other.names.map do |name| + Rewriters::Merge.lookup_type( + name:, + referrer: other, + in_index:, + ) + end + lhs_mixins == rhs_mixins end end class Include # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Include) && super end end @@ -485,7 +632,7 @@ def compatible_with?(other) class Extend # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Extend) && super end end @@ -493,7 +640,7 @@ def compatible_with?(other) class MixesInClassMethods # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(MixesInClassMethods) && super end end @@ -501,7 +648,7 @@ def compatible_with?(other) class Helper # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Helper) && name == other.name end end @@ -509,7 +656,7 @@ def compatible_with?(other) class Send # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(Send) && method == other.method && args == other.args end end @@ -517,7 +664,7 @@ def compatible_with?(other) class TStructField # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(TStructField) && name == other.name && type == other.type && default == other.default end end @@ -525,7 +672,7 @@ def compatible_with?(other) class TStructConst # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(TStructConst) && super end end @@ -533,7 +680,7 @@ def compatible_with?(other) class TStructProp # @override #: (Node other) -> bool - def compatible_with?(other) + def compatible_with?(other, **) other.is_a?(TStructProp) && super end end diff --git a/test/rbi/rewriters/merge_trees_test.rb b/test/rbi/rewriters/merge_trees_test.rb index 2e21c9d8..dd2d548a 100644 --- a/test/rbi/rewriters/merge_trees_test.rb +++ b/test/rbi/rewriters/merge_trees_test.rb @@ -1203,7 +1203,7 @@ module Foo; end class Foo::Bar; end class Foo::Baz < Foo::Bar - sig { returns(T.nilable(Foo::Bar)) } + sig { returns(T.nilable(::Foo::Bar)) } def a; end def b; end @@ -1237,7 +1237,7 @@ def a; end assert_equal(<<~RBI, res.string) module Foo class Baz < Bar - sig { returns(T.nilable(Bar)) } + sig { returns(T.nilable(::Foo::Bar)) } def a; end def b; end From 13199206194ad75c319eeef36f5048276ee208d4 Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 17 Sep 2025 17:15:43 -0700 Subject: [PATCH 03/10] Refactor merge logic Move monkeypatched compatible_with? and merg_with methods out of Node subclasses to TreeMerger. This makes more sense because those methods now need a reference to the merge tree index. --- lib/rbi/index.rb | 1 + lib/rbi/rewriters/merge_trees.rb | 486 +++++++++---------------------- 2 files changed, 143 insertions(+), 344 deletions(-) diff --git a/lib/rbi/index.rb b/lib/rbi/index.rb index 0278ffab..2c664f92 100644 --- a/lib/rbi/index.rb +++ b/lib/rbi/index.rb @@ -49,6 +49,7 @@ def visit(node) end end + #: -> String def to_s "#" end diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index 6ae95ae6..5260a7d3 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -59,90 +59,6 @@ def merge_trees(left, right, left_name: "left", right_name: "right", keep: Keep: ConflictTreeMerger.new.visit(tree) tree end - - # Returns a node from in_index that corresponds to the given type name - # when referenced from the given referrer Node. The referrer can be - # in a different tree, but its scope chain names will be used to find - # the referent in in_index. - #: (name: String?, referrer: Node, in_index: Index) -> Node? - def lookup_type(name:, referrer:, in_index:) - return unless name - - return in_index[name] if name.start_with?("::") - - referrer_scope = referrer.is_a?(Scope) ? referrer : referrer.parent_scope - loop do - scoped_name = "#{referrer_scope&.fully_qualified_name}::#{name}" - referent = in_index[scoped_name].last - break referent if referent - break unless referrer_scope - - referrer_scope = referrer_scope.parent_scope - end - end - - #: ((Type | String) type, referrer: Node, in_index: Index) -> Type - def fully_qualify_type(type, referrer:, in_index:) - case type - when String - fully_qualify_type(Type.parse_string(type), referrer:, in_index:) - when Type::Simple - # Heuristic perf optimization: assume some common Ruby global classes like - # Symbol, String, Integer, Float, etc, are global to skip the namespace lookup. - if ASSUME_GLOBAL_CLASS.include?(type.name) - type - else - referent = lookup_type( - name: type.name, - referrer: referrer, - in_index:, - ) - Type.simple(referent&.fully_qualified_name || type.name) - end - when Type::Nilable - Type.nilable(fully_qualify_type(type.type, referrer:, in_index:)) - when Type::Composite, Type::Tuple - type.class.new(type.types.map { fully_qualify_type(_1, referrer:, in_index:) }) - when Type::Generic - Type.generic(type.name, *type.params.map { fully_qualify_type(_1, referrer:, in_index:) }) - when Type::TypeAlias - Type.type_alias(type.name, fully_qualify_type(type.aliased_type, referrer:, in_index:)) - when Type::Shape - Type.shape(type.types.transform_values { fully_qualify_type(_1, referrer:, in_index:) }) - when Type::Proc - Type.proc - .params(type.proc_params.transform_values { fully_qualify_type(_1, referrer:, in_index:) }) - .returns(fully_qualify_type(type.proc_returns, referrer:, in_index:)) - .bind(fully_qualify_type(type.proc_bind, referrer:, in_index:)) - else - type - end - end - - #: (Sig sig, referrer: Node, in_index: Index) -> Sig - def fully_qualify_sig(sig, referrer:, in_index:) - Sig.new( - params: sig.params.map do |param| - SigParam.new( - param.name, - fully_qualify_type(param.type, referrer:, in_index:), - loc: param.loc, - comments: param.comments, - ) - end, - return_type: fully_qualify_type(sig.return_type, referrer:, in_index:), - is_abstract: sig.is_abstract, - is_override: sig.is_override, - is_overridable: sig.is_overridable, - is_final: sig.is_final, - allow_incompatible_override: sig.allow_incompatible_override, - without_runtime: sig.without_runtime, - type_params: sig.type_params, - checked: sig.checked, - loc: sig.loc, - comments: sig.comments, - ) - end end #: MergeTree @@ -212,9 +128,7 @@ def visit(node) prev = previous_definition(node) if prev.is_a?(Scope) - if node.compatible_with?(prev, in_index: @index) - prev.merge_with(node, in_index: @index) - elsif @keep == Keep::LEFT + if merge_nodes?(prev, node) || @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT prev = replace_scope_header(prev, node) @@ -231,14 +145,12 @@ def visit(node) visit_all(node.nodes) @scope_stack.pop when Tree - current_scope.merge_with(node, in_index: @index) + merge_comments(current_scope, node) visit_all(node.nodes) when Indexable prev = previous_definition(node) if prev - if node.compatible_with?(prev, in_index: @index) - prev.merge_with(node, in_index: @index) - elsif @keep == Keep::LEFT + if merge_nodes?(prev, node) || @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT prev.replace(node) @@ -301,6 +213,145 @@ def replace_scope_header(left, right) @index.index(right_copy) right_copy end + + #: (NodeWithComments left, NodeWithComments right) -> void + def merge_comments(left, right) + right.comments.each do |comment| + left.comments << comment unless left.comments.include?(comment) + end + end + + # Returns false if nodes are incompatible. This method merges any + # type references in the node, but the caller is responsible for + # merging children if the return value is true. + #: (Node left, Node right) -> bool + def merge_nodes?(left, right) + return false unless left.class == right.class + + merge_comments(left, right) if left.is_a?(NodeWithComments) + + case left + when Class + left_superclass = lookup_type(name: left.superclass_name, referrer: left) + right_superclass = lookup_type(name: right.superclass_name, referrer: right) + left_superclass == right_superclass + when Struct + left.members == right.members && left.keyword_init == right.keyword_init + when Const + left.name == right.name && left.value == right.value + when Attr, Method + return false if left.is_a?(Method) && left.params != right.params + return false if left.is_a?(Attr) && left.names != right.names + + left_sigs = left.sigs.map { fully_qualify_sig(_1, referrer: left) } + right_sigs = right.sigs.map { fully_qualify_sig(_1, referrer: right) } + if left_sigs.empty? || right_sigs.empty? || left_sigs == right_sigs + right_sigs.each do |sig| + left_sigs << sig unless left_sigs.include?(sig) + end + left.sigs = left_sigs + true + else + false + end + when Mixin + left_mixins = left.names.map { lookup_type(name: _1, referrer: left) } + right_mixins = right.names.map { lookup_type(name: _1, referrer: right) } + left_mixins == right_mixins + when Helper + # Do Helper names need to be resolved to types? + left.name == right.name + when Send + left.method == right.method && left.args == right.args + when TStructField + left.name == right.name && left.type == right.type && left.default == right.default + else + true + end + end + + # Returns a node from the merge tree that corresponds to the given type name + # when referenced from the given referrer Node. The referrer can be + # in a different tree, but its scope chain names will be used to find + # the referent in this merge tree. + #: (name: String?, referrer: Node) -> Node? + def lookup_type(name:, referrer:) + return unless name + + return @index[name] if name.start_with?("::") + + referrer_scope = referrer.is_a?(Scope) ? referrer : referrer.parent_scope + loop do + scoped_name = "#{referrer_scope&.fully_qualified_name}::#{name}" + referent = @index[scoped_name].last + break referent if referent + break unless referrer_scope + + referrer_scope = referrer_scope.parent_scope + end + end + + #: ((Type | String) type, referrer: Node) -> Type + def fully_qualify_type(type, referrer:) + case type + when String + fully_qualify_type(Type.parse_string(type), referrer:) + when Type::Simple + # Heuristic perf optimization: assume some common Ruby global classes like + # Symbol, String, Integer, Float, etc, are global to skip the namespace lookup. + if ASSUME_GLOBAL_CLASS.include?(type.name) + type + else + referent = lookup_type( + name: type.name, + referrer: referrer, + ) + Type.simple(referent&.fully_qualified_name || type.name) + end + when Type::Nilable + Type.nilable(fully_qualify_type(type.type, referrer:)) + when Type::Composite, Type::Tuple + type.class.new(type.types.map { fully_qualify_type(_1, referrer:) }) + when Type::Generic + Type.generic(type.name, *type.params.map { fully_qualify_type(_1, referrer:) }) + when Type::TypeAlias + Type.type_alias(type.name, fully_qualify_type(type.aliased_type, referrer:)) + when Type::Shape + Type.shape(type.types.transform_values { fully_qualify_type(_1, referrer:) }) + when Type::Proc + Type.proc + .params(type.proc_params.transform_values { fully_qualify_type(_1, referrer:) }) + .returns(fully_qualify_type(type.proc_returns, referrer:)) + .bind(fully_qualify_type(type.proc_bind, referrer:)) + else + type + end + end + + #: (Sig sig, referrer: Node) -> Sig + def fully_qualify_sig(sig, referrer:) + Sig.new( + params: sig.params.map do |param| + SigParam.new( + param.name, + fully_qualify_type(param.type, referrer:), + loc: param.loc, + comments: param.comments, + ) + end, + return_type: fully_qualify_type(sig.return_type, referrer:), + is_abstract: sig.is_abstract, + is_override: sig.is_override, + is_overridable: sig.is_overridable, + is_final: sig.is_final, + allow_incompatible_override: sig.allow_incompatible_override, + without_runtime: sig.without_runtime, + type_params: sig.type_params, + checked: sig.checked, + loc: sig.loc, + comments: sig.comments, + ) + end end # Merge adjacent conflict trees @@ -373,16 +424,6 @@ def merge_conflict_trees(left, right) end class Node - # Can `self` be merged into `_prev`? - #: (Node _prev, in_index: Index) -> bool - def compatible_with?(_prev, in_index:) - true - end - - # Merge `self` and `other` into a single definition - #: (Node other, in_index: Index) -> void - def merge_with(other, in_index:); end - #: -> ConflictTree? def parent_conflict_tree parent = parent_tree #: Node? @@ -395,18 +436,6 @@ def parent_conflict_tree end end - class NodeWithComments - # @override - #: (Node other) -> void - def merge_with(other, **) - return unless other.is_a?(NodeWithComments) - - other.comments.each do |comment| - comments << comment unless comments.include?(comment) - end - end - end - class Tree #: (Tree other, ?left_name: String, ?right_name: String, ?keep: Rewriters::Merge::Keep) -> MergeTree def merge(other, left_name: "left", right_name: "right", keep: Rewriters::Merge::Keep::NONE) @@ -454,237 +483,6 @@ def dup_empty end end - class Class - # @override - #: (Node prev, in_index: Index) -> bool - def compatible_with?(prev, in_index:) - return false unless prev.is_a?(Class) - - self_superclass = Rewriters::Merge.lookup_type( - name: superclass_name, - referrer: self, - in_index:, - ) - prev_superclass = Rewriters::Merge.lookup_type( - name: prev.superclass_name, - referrer: prev, - in_index:, - ) - self_superclass == prev_superclass - end - end - - class Module - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Module) - end - end - - class Struct - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Struct) && members == other.members && keyword_init == other.keyword_init - end - end - - class Const - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Const) && name == other.name && value == other.value - end - end - - class Attr - # @override - #: (Node other, in_index: Index) -> bool - def compatible_with?(prev, in_index:) - return false unless prev.is_a?(Attr) - return false unless names == prev.names - - lhs_sigs = sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) - end - rhs_sigs = prev.sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: prev, in_index:) - end - - lhs_sigs.empty? || rhs_sigs.empty? || lhs_sigs == rhs_sigs - end - - # @override - #: (Node other, in_index: Index) -> void - def merge_with(other, in_index:) - return unless other.is_a?(Attr) - - super - - merged_sigs = sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) - end - rhs_sigs = other.sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: other, in_index:) - end - rhs_sigs.each do |sig| - merged_sigs << sig unless merged_sigs.include?(sig) - end - self.sigs = merged_sigs - end - end - - class AttrReader - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(AttrReader) && super - end - end - - class AttrWriter - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(AttrWriter) && super - end - end - - class AttrAccessor - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(AttrAccessor) && super - end - end - - class Method - # @override - #: (Node prev, in_index: Index) -> bool - def compatible_with?(prev, in_index:) - return false unless prev.is_a?(Method) - return false unless name == prev.name - return false unless params == prev.params - - lhs_sigs = sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) - end - rhs_sigs = prev.sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: prev, in_index:) - end - - lhs_sigs.empty? || rhs_sigs.empty? || lhs_sigs == rhs_sigs - end - - # @override - #: (Node other, in_index: Index) -> void - def merge_with(other, in_index:) - return unless other.is_a?(Method) - - super - - merged_sigs = sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: self, in_index:) - end - rhs_sigs = other.sigs.map do |sig| - Rewriters::Merge.fully_qualify_sig(sig, referrer: other, in_index:) - end - rhs_sigs.each do |sig| - merged_sigs << sig unless merged_sigs.include?(sig) - end - self.sigs = merged_sigs - end - end - - class Mixin - # @override - #: (Node other, in_index: Index) -> bool - def compatible_with?(other, in_index:) - return false unless other.is_a?(Mixin) - - lhs_mixins = names.map do |name| - Rewriters::Merge.lookup_type( - name:, - referrer: self, - in_index:, - ) - end - rhs_mixins = other.names.map do |name| - Rewriters::Merge.lookup_type( - name:, - referrer: other, - in_index:, - ) - end - lhs_mixins == rhs_mixins - end - end - - class Include - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Include) && super - end - end - - class Extend - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Extend) && super - end - end - - class MixesInClassMethods - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(MixesInClassMethods) && super - end - end - - class Helper - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Helper) && name == other.name - end - end - - class Send - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(Send) && method == other.method && args == other.args - end - end - - class TStructField - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(TStructField) && name == other.name && type == other.type && default == other.default - end - end - - class TStructConst - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(TStructConst) && super - end - end - - class TStructProp - # @override - #: (Node other) -> bool - def compatible_with?(other, **) - other.is_a?(TStructProp) && super - end - end - # A tree showing incompatibles nodes # # Is rendered as a merge conflict between `left` and` right`: From ea324cadb712c7ea2f2565c4b42081003fce631a Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Mon, 22 Sep 2025 13:15:04 -0700 Subject: [PATCH 04/10] Fix typing issues --- lib/rbi/rewriters/merge_trees.rb | 46 +++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index 5260a7d3..c60e94f8 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -228,20 +228,24 @@ def merge_comments(left, right) def merge_nodes?(left, right) return false unless left.class == right.class - merge_comments(left, right) if left.is_a?(NodeWithComments) + merge_comments(left, right) if left.is_a?(NodeWithComments) && right.is_a?(NodeWithComments) case left when Class + right = right #: as Class left_superclass = lookup_type(name: left.superclass_name, referrer: left) right_superclass = lookup_type(name: right.superclass_name, referrer: right) left_superclass == right_superclass when Struct + right = right #: as Struct left.members == right.members && left.keyword_init == right.keyword_init when Const + right = right #: as Const left.name == right.name && left.value == right.value when Attr, Method - return false if left.is_a?(Method) && left.params != right.params - return false if left.is_a?(Attr) && left.names != right.names + right = right #: as Attr | Method + return false if left.is_a?(Method) && right.is_a?(Method) && left.params != right.params + return false if left.is_a?(Attr) && right.is_a?(Attr) && left.names != right.names left_sigs = left.sigs.map { fully_qualify_sig(_1, referrer: left) } right_sigs = right.sigs.map { fully_qualify_sig(_1, referrer: right) } @@ -255,15 +259,19 @@ def merge_nodes?(left, right) false end when Mixin + right = right #: as Mixin left_mixins = left.names.map { lookup_type(name: _1, referrer: left) } right_mixins = right.names.map { lookup_type(name: _1, referrer: right) } left_mixins == right_mixins when Helper # Do Helper names need to be resolved to types? + right = right #: as Helper left.name == right.name when Send + right = right #: as Send left.method == right.method && left.args == right.args when TStructField + right = right #: as TStructField left.name == right.name && left.type == right.type && left.default == right.default else true @@ -274,17 +282,30 @@ def merge_nodes?(left, right) # when referenced from the given referrer Node. The referrer can be # in a different tree, but its scope chain names will be used to find # the referent in this merge tree. - #: (name: String?, referrer: Node) -> Node? + #: (name: String?, referrer: Node) -> (Scope | Const)? def lookup_type(name:, referrer:) return unless name - return @index[name] if name.start_with?("::") + if name.start_with?("::") + referent = @index[name].last #: Node? + if referent.is_a?(Scope) || referent.is_a?(Const) + return referent + elsif referent + raise "Unexpected type #{referent} for #{name} with referrer #{referrer}" + else + return + end + end - referrer_scope = referrer.is_a?(Scope) ? referrer : referrer.parent_scope + referrer_scope = referrer.is_a?(Scope) ? referrer : referrer.parent_scope #: Scope? loop do scoped_name = "#{referrer_scope&.fully_qualified_name}::#{name}" - referent = @index[scoped_name].last - break referent if referent + referent = @index[scoped_name].last #: Node? + if referent.is_a?(Scope) || referent.is_a?(Const) + return referent + elsif referent + raise "Unexpected type #{referent} for #{name} with referrer #{referrer}" + end break unless referrer_scope referrer_scope = referrer_scope.parent_scope @@ -319,10 +340,11 @@ def fully_qualify_type(type, referrer:) when Type::Shape Type.shape(type.types.transform_values { fully_qualify_type(_1, referrer:) }) when Type::Proc - Type.proc - .params(type.proc_params.transform_values { fully_qualify_type(_1, referrer:) }) - .returns(fully_qualify_type(type.proc_returns, referrer:)) - .bind(fully_qualify_type(type.proc_bind, referrer:)) + copy = Type.proc + params = type.proc_params.transform_values { fully_qualify_type(_1, referrer:) } + copy.params(*params) # This should be **params but sorbet seems to get tripped up? + copy.returns(fully_qualify_type(type.proc_returns, referrer:)) + copy.bind(fully_qualify_type(T.must(type.proc_bind), referrer:)) if type.proc_bind else type end From 3f5fc2194c80e46a350a36097a5e9795068e3b4d Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Tue, 23 Sep 2025 12:41:01 -0700 Subject: [PATCH 05/10] Fix bug with Proc merging --- lib/rbi/rewriters/merge_trees.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index c60e94f8..e31e16e1 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -342,9 +342,10 @@ def fully_qualify_type(type, referrer:) when Type::Proc copy = Type.proc params = type.proc_params.transform_values { fully_qualify_type(_1, referrer:) } - copy.params(*params) # This should be **params but sorbet seems to get tripped up? + copy.params(**params) # This should be **params but sorbet seems to get tripped up? copy.returns(fully_qualify_type(type.proc_returns, referrer:)) copy.bind(fully_qualify_type(T.must(type.proc_bind), referrer:)) if type.proc_bind + copy else type end From f64296cd4e9c9e8f3f2ee109cfc7af900226a200 Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Mon, 29 Sep 2025 12:54:18 -0700 Subject: [PATCH 06/10] Work around type checking issue with kwarg splatting --- lib/rbi/rewriters/merge_trees.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index e31e16e1..652949ee 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -342,7 +342,8 @@ def fully_qualify_type(type, referrer:) when Type::Proc copy = Type.proc params = type.proc_params.transform_values { fully_qualify_type(_1, referrer:) } - copy.params(**params) # This should be **params but sorbet seems to get tripped up? + # Using unsafe below becauses splatting kwargs appears to trip up type checking + T.unsafe(copy).params(**params) copy.returns(fully_qualify_type(type.proc_returns, referrer:)) copy.bind(fully_qualify_type(T.must(type.proc_bind), referrer:)) if type.proc_bind copy From 3d2efed71361c51cd4f65641319c4de89710b22e Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 1 Oct 2025 10:04:34 -0700 Subject: [PATCH 07/10] Change return type of merge_nodes so it's not a ? method --- lib/rbi/rewriters/merge_trees.rb | 48 +++++++++++++++++++------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index 652949ee..141facb7 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -128,7 +128,7 @@ def visit(node) prev = previous_definition(node) if prev.is_a?(Scope) - if merge_nodes?(prev, node) || @keep == Keep::LEFT + if merge_nodes(prev, node) == :compatible || @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT prev = replace_scope_header(prev, node) @@ -150,7 +150,7 @@ def visit(node) when Indexable prev = previous_definition(node) if prev - if merge_nodes?(prev, node) || @keep == Keep::LEFT + if merge_nodes(prev, node) == :compatible || @keep == Keep::LEFT # do nothing it's already merged elsif @keep == Keep::RIGHT prev.replace(node) @@ -221,12 +221,13 @@ def merge_comments(left, right) end end - # Returns false if nodes are incompatible. This method merges any + # Returns `:incompatible` or `:compatible` depending on whether the two nodes + # should be merged together. This method merges any # type references in the node, but the caller is responsible for - # merging children if the return value is true. - #: (Node left, Node right) -> bool - def merge_nodes?(left, right) - return false unless left.class == right.class + # merging children if the return value is :compatible. + #: (Node left, Node right) -> Symbol + def merge_nodes(left, right) + return :incompatible unless left.class == right.class merge_comments(left, right) if left.is_a?(NodeWithComments) && right.is_a?(NodeWithComments) @@ -235,17 +236,21 @@ def merge_nodes?(left, right) right = right #: as Class left_superclass = lookup_type(name: left.superclass_name, referrer: left) right_superclass = lookup_type(name: right.superclass_name, referrer: right) - left_superclass == right_superclass + left_superclass == right_superclass ? :compatible : :incompatible + when Struct right = right #: as Struct - left.members == right.members && left.keyword_init == right.keyword_init + left.members == right.members && left.keyword_init == right.keyword_init ? :compatible : :incompatible + when Const right = right #: as Const - left.name == right.name && left.value == right.value + left.name == right.name && left.value == right.value ? :compatible : :incompatible + when Attr, Method right = right #: as Attr | Method - return false if left.is_a?(Method) && right.is_a?(Method) && left.params != right.params - return false if left.is_a?(Attr) && right.is_a?(Attr) && left.names != right.names + + return :incompatible if left.is_a?(Method) && right.is_a?(Method) && left.params != right.params + return :incompatible if left.is_a?(Attr) && right.is_a?(Attr) && left.names != right.names left_sigs = left.sigs.map { fully_qualify_sig(_1, referrer: left) } right_sigs = right.sigs.map { fully_qualify_sig(_1, referrer: right) } @@ -254,27 +259,32 @@ def merge_nodes?(left, right) left_sigs << sig unless left_sigs.include?(sig) end left.sigs = left_sigs - true + :compatible else - false + :incompatible end + when Mixin right = right #: as Mixin left_mixins = left.names.map { lookup_type(name: _1, referrer: left) } right_mixins = right.names.map { lookup_type(name: _1, referrer: right) } - left_mixins == right_mixins + left_mixins == right_mixins ? :compatible : :incompatible + when Helper # Do Helper names need to be resolved to types? right = right #: as Helper - left.name == right.name + left.name == right.name ? :compatible : :incompatible + when Send right = right #: as Send - left.method == right.method && left.args == right.args + left.method == right.method && left.args == right.args ? :compatible : :incompatible + when TStructField right = right #: as TStructField - left.name == right.name && left.type == right.type && left.default == right.default + left.class == right.class && left.name == right.name && left.type == right.type && left.default == right.default ? :compatible : :incompatible + else - true + :compatible end end From 9ac7bde057c48f2b362c438bd537f596ba0918bf Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 1 Oct 2025 11:12:26 -0700 Subject: [PATCH 08/10] Add custom error class for invalid type lookup And add a test --- lib/rbi/rewriters/merge_trees.rb | 6 ++++-- test/rbi/rewriters/merge_trees_test.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index 141facb7..9f354c29 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -39,6 +39,8 @@ module Rewriters class Merge ASSUME_GLOBAL_CLASS = ["String", "Symbol", "Integer", "Float", "NilClass", "TrueClass", "FalseClass"].freeze + class Error < RBI::Error; end + class Keep NONE = new #: Keep LEFT = new #: Keep @@ -301,7 +303,7 @@ def lookup_type(name:, referrer:) if referent.is_a?(Scope) || referent.is_a?(Const) return referent elsif referent - raise "Unexpected type #{referent} for #{name} with referrer #{referrer}" + raise Error, "Unexpected type #{referent} for #{name} with referrer #{referrer}" else return end @@ -314,7 +316,7 @@ def lookup_type(name:, referrer:) if referent.is_a?(Scope) || referent.is_a?(Const) return referent elsif referent - raise "Unexpected type #{referent} for #{name} with referrer #{referrer}" + raise Error, "Unexpected type #{referent} for #{name} with referrer #{referrer}" end break unless referrer_scope diff --git a/test/rbi/rewriters/merge_trees_test.rb b/test/rbi/rewriters/merge_trees_test.rb index dd2d548a..8e90b926 100644 --- a/test/rbi/rewriters/merge_trees_test.rb +++ b/test/rbi/rewriters/merge_trees_test.rb @@ -1247,5 +1247,22 @@ def b; end class Foo::Bar; end RBI end + + def test_merge_invalid_type_reference_error + lhs = parse_rbi(<<~RBI) + class Foo + extend T::Sig + extend T::Generic + Bar = type_member + class Baz; end + end + RBI + rhs = parse_rbi(<<~RBI) + class Foo::Baz < ::Foo::Bar; end + RBI + assert_raises(Rewriters::Merge::Error) do + lhs.merge(rhs) + end + end end end From 738f73cfcb7606a995f4fda57feb9ec00c04966b Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 1 Oct 2025 12:29:34 -0700 Subject: [PATCH 09/10] Allow TypeMember for type references --- lib/rbi/rewriters/merge_trees.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rbi/rewriters/merge_trees.rb b/lib/rbi/rewriters/merge_trees.rb index 9f354c29..f1ece22b 100644 --- a/lib/rbi/rewriters/merge_trees.rb +++ b/lib/rbi/rewriters/merge_trees.rb @@ -294,13 +294,13 @@ def merge_nodes(left, right) # when referenced from the given referrer Node. The referrer can be # in a different tree, but its scope chain names will be used to find # the referent in this merge tree. - #: (name: String?, referrer: Node) -> (Scope | Const)? + #: (name: String?, referrer: Node) -> (Scope | Const | TypeMember)? def lookup_type(name:, referrer:) return unless name if name.start_with?("::") referent = @index[name].last #: Node? - if referent.is_a?(Scope) || referent.is_a?(Const) + if referent.is_a?(Scope) || referent.is_a?(Const) || referent.is_a?(TypeMember) return referent elsif referent raise Error, "Unexpected type #{referent} for #{name} with referrer #{referrer}" @@ -313,7 +313,7 @@ def lookup_type(name:, referrer:) loop do scoped_name = "#{referrer_scope&.fully_qualified_name}::#{name}" referent = @index[scoped_name].last #: Node? - if referent.is_a?(Scope) || referent.is_a?(Const) + if referent.is_a?(Scope) || referent.is_a?(Const) || referent.is_a?(TypeMember) return referent elsif referent raise Error, "Unexpected type #{referent} for #{name} with referrer #{referrer}" From ca1b391fe740ad1455e21374a5be5f13f04febb3 Mon Sep 17 00:00:00 2001 From: Daniel Dickison Date: Wed, 1 Oct 2025 12:45:44 -0700 Subject: [PATCH 10/10] =?UTF-8?q?It=E2=80=99s=20not=20actually=20possibe?= =?UTF-8?q?=20to=20raise=20Merge::Error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At least without creating an artificial tree that wasn’t parsed from rbi text. --- test/rbi/rewriters/merge_trees_test.rb | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/rbi/rewriters/merge_trees_test.rb b/test/rbi/rewriters/merge_trees_test.rb index 8e90b926..dd2d548a 100644 --- a/test/rbi/rewriters/merge_trees_test.rb +++ b/test/rbi/rewriters/merge_trees_test.rb @@ -1247,22 +1247,5 @@ def b; end class Foo::Bar; end RBI end - - def test_merge_invalid_type_reference_error - lhs = parse_rbi(<<~RBI) - class Foo - extend T::Sig - extend T::Generic - Bar = type_member - class Baz; end - end - RBI - rhs = parse_rbi(<<~RBI) - class Foo::Baz < ::Foo::Bar; end - RBI - assert_raises(Rewriters::Merge::Error) do - lhs.merge(rhs) - end - end end end