verify_mixin_ivars.rake

  1# frozen_string_literal: true
  2
  3desc "Verify mixin ivar requirements using Solargraph"
  4task :verify_mixin_ivars do
  5	require "set"
  6	require "solargraph"
  7	require_relative "../solargraph_ruby34_fix"
  8
  9	if defined?(RubyVM::AbstractSyntaxTree)
 10		def find_ivars(node, ivars=Set.new)
 11			return ivars unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
 12
 13			ivars.add(node.children[0].to_s) if [:IASGN, :IVAR].include?(node.type)
 14			node.children.filter { |child|
 15				child.is_a?(RubyVM::AbstractSyntaxTree::Node)
 16			}.each do |child|
 17				find_ivars(child, ivars)
 18			end
 19
 20			ivars
 21		end
 22	else
 23		require "parser/current"
 24
 25		def find_ivars(node, ivars=Set.new)
 26			return ivars unless node.is_a?(Parser::AST::Node)
 27
 28			ivars.add(node.children[0].to_s) if [:ivasgn, :ivar].include?(node.type)
 29
 30			node.children.each do |child|
 31				find_ivars(child, ivars)
 32			end
 33
 34			ivars
 35		end
 36	end
 37
 38	lib_files = Dir.glob("lib/**/*.rb")
 39	sources = lib_files.map { |file|
 40		Solargraph::Source.load_string(File.read(file), file)
 41	}
 42	# @type [Array<Solargraph::SourceMap>]
 43	source_maps = sources.filter_map { |source|
 44		Solargraph::SourceMap.map(source)
 45	}
 46	violations = []
 47
 48	modules_with_ivars = {}
 49
 50	all_pins = source_maps.flat_map(&:pins)
 51	# @type [Array<Solargraph::Pin::Namespace>]
 52	module_pins = all_pins.select { |p|
 53		p.is_a?(Solargraph::Pin::Namespace) && p.type == :module
 54	}
 55
 56	module_pins.each do |mod|
 57		source_map = source_maps.find { |sm| sm.filename == mod.location.filename }
 58		next unless source_map
 59
 60		ivars = Set.new
 61		source_map.pins.each do |pin|
 62			next unless pin.is_a?(Solargraph::Pin::Method)
 63			next unless pin.closure && pin.closure.path == mod.path
 64
 65			find_ivars(pin.node, ivars) if pin.node
 66		end
 67
 68		modules_with_ivars[mod] = ivars.to_a unless ivars.empty?
 69	end
 70
 71	# @type [Array<Solargraph::Pin::Namespace>]
 72	class_pins = all_pins.select { |p|
 73		p.is_a?(Solargraph::Pin::Namespace) && p.type == :class
 74	}
 75
 76	class_pins.each do |klass|
 77		source_map = source_maps.find { |sm|
 78			sm.filename == klass.location.filename
 79		}
 80		next unless source_map
 81
 82		# @type [Array<Solargraph::Pin::Reference::Include>]
 83		included_modules = source_map.pins.select { |p|
 84			p.is_a?(Solargraph::Pin::Reference::Include) && p.closure == klass
 85		}
 86
 87		next if included_modules.empty?
 88
 89		init_method = source_map.pins.find { |p|
 90			p.is_a?(Solargraph::Pin::Method) &&
 91			 p.name == "initialize" &&
 92			 p.closure && p.closure.path == klass.path
 93		}
 94
 95		init_ivars = Set.new
 96		if init_method
 97			source_map.pins.each do |pin|
 98				if pin.is_a?(Solargraph::Pin::InstanceVariable) &&
 99				   pin.closure == init_method
100					init_ivars.add(pin.name)
101				end
102			end
103		end
104
105		included_modules.each do |include_pin|
106			mod = module_pins.find { |m| m.path == include_pin.name }
107			next unless mod
108
109			required_ivars = modules_with_ivars[mod]
110			next unless required_ivars
111
112			missing = Set.new(required_ivars) - init_ivars
113
114			missing.each do |ivar|
115				violations << {
116					file: klass.location.filename,
117					line: klass.location.range.start.line + 1,
118					class: klass.path,
119					module: mod.path,
120					ivar: ivar
121				}
122			end
123		end
124	end
125
126	if violations.empty?
127		puts "✓ All classes properly initialize ivars required by their mixins"
128	else
129		puts "✗ Found #{violations.size} missing ivar initialization(s):\n\n"
130
131		violations.each do |v|
132			puts "#{v[:file]}:#{v[:line]}"
133			puts "  Class '#{v[:class]}' includes module '#{v[:module]}'"
134			puts "  which references '#{v[:ivar]}',"
135			puts "  but '#{v[:class]}#initialize' doesn't set it"
136			puts "  Add '#{v[:ivar]} = ...' to the initialize method"
137			puts
138		end
139
140		exit 1
141	end
142end