verify that mixins refer to valid ivars

Phillip Davis created

- uses solargraph sourcemap, which requires a patch for Ruby 3.4
that is not available (patched solargraph version is incompatible
with pinned rubocop)

- conditionally checks for two different kinds of ivar nodes
using the same check that solargraph uses internally to decide between
current and legacy parsers

Change summary

.rubocop.yml                      |   1 
Gemfile                           |   1 
Rakefile                          |   2 
lib/solargraph_ruby34_fix.rb      |  34 +++++++
lib/tasks/verify_mixin_ivars.rake | 142 +++++++++++++++++++++++++++++++++
5 files changed, 180 insertions(+)

Detailed changes

.rubocop.yml 🔗

@@ -21,6 +21,7 @@ Metrics/BlockLength:
     - json
   Exclude:
     - test/*
+    - lib/tasks/*
 
 Metrics/AbcSize:
   Exclude:

Gemfile 🔗

@@ -35,6 +35,7 @@ group(:development) do
 	gem "pry-reload"
 	gem "pry-rescue"
 	gem "pry-stack_explorer"
+	gem "solargraph"
 end
 
 group(:test) do

Rakefile 🔗

@@ -20,6 +20,8 @@ rescue LoadError
 	nil
 end
 
+Dir.glob("lib/tasks/**/*.rake").each { |r| load r }
+
 task :entr do
 	sh "sh", "-c", "git ls-files | entr -s 'rake test && rubocop'"
 end

lib/solargraph_ruby34_fix.rb 🔗

@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+# Monkey-patch to fix Solargraph 0.48.0 bug with Ruby 3.4+
+# See: https://github.com/castwide/solargraph/issues/733
+# Fixed in Solargraph PR #735 (merged Jan 7, 2025) but not in 0.48.0
+# https://github.com/castwide/solargraph/pull/735/files
+#
+# Bug: node_range method crashes when passed nil node
+# This happens, for example,
+# with rescue clauses that don't assign exception to a variable
+# Example: "rescue NameError" instead of "rescue NameError => e"
+
+if defined?(RubyVM::AbstractSyntaxTree)
+	require "solargraph"
+
+	module Solargraph
+		module Parser
+			module Rubyvm
+				module ClassMethods
+					# Fix from PR #735: Add nil check to node_range
+					def node_range(node)
+						if node.nil?
+							nil
+						else
+							st = Position.new(node.first_lineno - 1, node.first_column)
+							en = Position.new(node.last_lineno - 1, node.last_column)
+							Range.new(st, en)
+						end
+					end
+				end
+			end
+		end
+	end
+end

lib/tasks/verify_mixin_ivars.rake 🔗

@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+desc "Verify mixin ivar requirements using Solargraph"
+task :verify_mixin_ivars do
+	require "set"
+	require "solargraph"
+	require_relative "../solargraph_ruby34_fix"
+
+	if defined?(RubyVM::AbstractSyntaxTree)
+		def find_ivars(node, ivars=Set.new)
+			return ivars unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
+
+			ivars.add(node.children[0].to_s) if [:IASGN, :IVAR].include?(node.type)
+			node.children.filter { |child|
+				child.is_a?(RubyVM::AbstractSyntaxTree::Node)
+			}.each do |child|
+				find_ivars(child, ivars)
+			end
+
+			ivars
+		end
+	else
+		require "parser/current"
+
+		def find_ivars(node, ivars=Set.new)
+			return ivars unless node.is_a?(Parser::AST::Node)
+
+			ivars.add(node.children[0].to_s) if [:ivasgn, :ivar].include?(node.type)
+
+			node.children.each do |child|
+				find_ivars(child, ivars)
+			end
+
+			ivars
+		end
+	end
+
+	lib_files = Dir.glob("lib/**/*.rb")
+	sources = lib_files.map { |file|
+		Solargraph::Source.load_string(File.read(file), file)
+	}
+	# @type [Array<Solargraph::SourceMap>]
+	source_maps = sources.filter_map { |source|
+		Solargraph::SourceMap.map(source)
+	}
+	violations = []
+
+	modules_with_ivars = {}
+
+	all_pins = source_maps.flat_map(&:pins)
+	# @type [Array<Solargraph::Pin::Namespace>]
+	module_pins = all_pins.select { |p|
+		p.is_a?(Solargraph::Pin::Namespace) && p.type == :module
+	}
+
+	module_pins.each do |mod|
+		source_map = source_maps.find { |sm| sm.filename == mod.location.filename }
+		next unless source_map
+
+		ivars = Set.new
+		source_map.pins.each do |pin|
+			next unless pin.is_a?(Solargraph::Pin::Method)
+			next unless pin.closure && pin.closure.path == mod.path
+
+			find_ivars(pin.node, ivars) if pin.node
+		end
+
+		modules_with_ivars[mod] = ivars.to_a unless ivars.empty?
+	end
+
+	# @type [Array<Solargraph::Pin::Namespace>]
+	class_pins = all_pins.select { |p|
+		p.is_a?(Solargraph::Pin::Namespace) && p.type == :class
+	}
+
+	class_pins.each do |klass|
+		source_map = source_maps.find { |sm|
+			sm.filename == klass.location.filename
+		}
+		next unless source_map
+
+		# @type [Array<Solargraph::Pin::Reference::Include>]
+		included_modules = source_map.pins.select { |p|
+			p.is_a?(Solargraph::Pin::Reference::Include) && p.closure == klass
+		}
+
+		next if included_modules.empty?
+
+		init_method = source_map.pins.find { |p|
+			p.is_a?(Solargraph::Pin::Method) &&
+			 p.name == "initialize" &&
+			 p.closure && p.closure.path == klass.path
+		}
+
+		init_ivars = Set.new
+		if init_method
+			source_map.pins.each do |pin|
+				if pin.is_a?(Solargraph::Pin::InstanceVariable) &&
+				   pin.closure == init_method
+					init_ivars.add(pin.name)
+				end
+			end
+		end
+
+		included_modules.each do |include_pin|
+			mod = module_pins.find { |m| m.path == include_pin.name }
+			next unless mod
+
+			required_ivars = modules_with_ivars[mod]
+			next unless required_ivars
+
+			missing = Set.new(required_ivars) - init_ivars
+
+			missing.each do |ivar|
+				violations << {
+					file: klass.location.filename,
+					line: klass.location.range.start.line + 1,
+					class: klass.path,
+					module: mod.path,
+					ivar: ivar
+				}
+			end
+		end
+	end
+
+	if violations.empty?
+		puts "✓ All classes properly initialize ivars required by their mixins"
+	else
+		puts "✗ Found #{violations.size} missing ivar initialization(s):\n\n"
+
+		violations.each do |v|
+			puts "#{v[:file]}:#{v[:line]}"
+			puts "  Class '#{v[:class]}' includes module '#{v[:module]}'"
+			puts "  which references '#{v[:ivar]}',"
+			puts "  but '#{v[:class]}#initialize' doesn't set it"
+			puts "  Add '#{v[:ivar]} = ...' to the initialize method"
+			puts
+		end
+
+		exit 1
+	end
+end