398 lines
11 KiB
Ruby
398 lines
11 KiB
Ruby
# ©AngelaMos | 2026
|
|
# scanner.rb
|
|
# frozen_string_literal: true
|
|
|
|
module Marshalsea
|
|
class ParseRecoveredError < StandardError
|
|
def initialize(errors)
|
|
super("Prism recovered an incomplete tree from #{errors} syntax errors")
|
|
end
|
|
end
|
|
|
|
class Scanner
|
|
GATE_GATED = :gated
|
|
GATE_SOFT = :soft
|
|
GATE_UNGATED = :ungated
|
|
GATE_LINK = :link
|
|
|
|
FORMAT_MARSHAL = :marshal
|
|
FORMAT_PSYCH = :psych
|
|
|
|
VARIADIC = -1
|
|
|
|
ENTRY_POINTS = {
|
|
"marshal_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
"_load_data" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL] },
|
|
"_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL], singleton: true },
|
|
"init_with" => { gate: GATE_SOFT, arity: 1, formats: [FORMAT_PSYCH] },
|
|
"hash" => { gate: GATE_UNGATED, arity: 0, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
"eql?" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
"<=>" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL] },
|
|
"==" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_PSYCH] },
|
|
"[]=" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_PSYCH] },
|
|
"method_missing" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
"respond_to_missing?" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
|
|
"respond_to?" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_PSYCH] },
|
|
"to_s" => { gate: GATE_LINK, arity: 0, formats: [] },
|
|
"coerce" => { gate: GATE_LINK, arity: 1, formats: [] }
|
|
}.freeze
|
|
|
|
GATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_GATED && !spec[:singleton] }
|
|
.keys.freeze
|
|
GATED_SINGLETON_METHODS = ENTRY_POINTS.select { |_, spec| spec[:singleton] }.keys.freeze
|
|
UNGATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_UNGATED }.keys.freeze
|
|
LINK_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_LINK }.keys.freeze
|
|
INSTANCE_METHODS = ENTRY_POINTS.reject { |_, spec| spec[:singleton] }.keys.freeze
|
|
SINGLETON_METHODS = GATED_SINGLETON_METHODS
|
|
|
|
PRISM_AVAILABLE = begin
|
|
require "prism"
|
|
true
|
|
rescue LoadError
|
|
false
|
|
end
|
|
|
|
LOCATION_SEPARATOR = ":"
|
|
UNKNOWN_LOCATION = nil
|
|
|
|
STATE_UNREADABLE = :unreadable
|
|
STATE_UNANALYSABLE = :unanalysable
|
|
STATE_VERDICTS = [true, false].freeze
|
|
|
|
SITE_MODULE_NAME = :module_name
|
|
SITE_OWN_METHODS = :own_methods
|
|
SITE_CANDIDATE = :candidate
|
|
SITE_SOURCE_PARSE = :source_parse
|
|
SITE_STATE_ANALYSIS = :state_analysis
|
|
|
|
SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE,
|
|
SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze
|
|
|
|
LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze
|
|
|
|
SUBJECT_UNNAMED = "(module that cannot report a name)"
|
|
|
|
class Candidate
|
|
attr_reader :class_name, :method_name, :gate, :source_location, :arity, :formats
|
|
|
|
def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:,
|
|
touches_state:, formats:)
|
|
@class_name = class_name
|
|
@method_name = method_name
|
|
@gate = gate
|
|
@source_location = source_location
|
|
@arity = arity
|
|
@singleton = singleton
|
|
@touches_state = touches_state
|
|
@formats = formats
|
|
end
|
|
|
|
def singleton?
|
|
@singleton
|
|
end
|
|
|
|
def gated?
|
|
gate == GATE_GATED
|
|
end
|
|
|
|
def soft_gated?
|
|
gate == GATE_SOFT
|
|
end
|
|
|
|
def link?
|
|
gate == GATE_LINK
|
|
end
|
|
|
|
def entry_point?
|
|
!link?
|
|
end
|
|
|
|
def dispatch_arity
|
|
ENTRY_POINTS.fetch(method_name).fetch(:arity)
|
|
end
|
|
|
|
def accepts_dispatch?
|
|
required = dispatch_arity
|
|
return true if required == VARIADIC
|
|
return arity == required unless arity.negative?
|
|
|
|
required >= (arity.abs - 1)
|
|
end
|
|
|
|
def zero_arity?
|
|
arity.zero?
|
|
end
|
|
|
|
def touches_state?
|
|
@touches_state == true
|
|
end
|
|
|
|
def state_known?
|
|
STATE_VERDICTS.include?(@touches_state)
|
|
end
|
|
|
|
def unanalysable?
|
|
@touches_state == STATE_UNANALYSABLE
|
|
end
|
|
|
|
def unreadable_source?
|
|
@touches_state == STATE_UNREADABLE
|
|
end
|
|
|
|
def reachable?
|
|
return false unless entry_point?
|
|
return false unless accepts_dispatch?
|
|
return true if gated? || soft_gated?
|
|
|
|
touches_state? || unreadable_source?
|
|
end
|
|
|
|
def to_s
|
|
"#{class_name}#{singleton? ? '.' : '#'}#{method_name}"
|
|
end
|
|
end
|
|
|
|
class Suppression
|
|
attr_reader :site, :subject, :error_class
|
|
|
|
def initialize(site:, subject:, error_class:)
|
|
@site = site
|
|
@subject = subject
|
|
@error_class = error_class
|
|
end
|
|
|
|
def lossy?
|
|
LOSSY_SITES.include?(site)
|
|
end
|
|
|
|
def to_s
|
|
"#{site} #{subject} (#{error_class})"
|
|
end
|
|
end
|
|
|
|
class Report
|
|
attr_reader :candidates, :scanned_modules, :suppressions
|
|
|
|
def initialize(candidates, scanned_modules, suppressions)
|
|
@candidates = candidates
|
|
@scanned_modules = scanned_modules
|
|
@suppressions = suppressions
|
|
end
|
|
|
|
def suppressed_count
|
|
suppressions.length
|
|
end
|
|
|
|
def suppressions_by_site
|
|
suppressions.each_with_object({}) do |suppression, counts|
|
|
counts[suppression.site] = counts.fetch(suppression.site, 0) + 1
|
|
end
|
|
end
|
|
|
|
def complete?
|
|
suppressions.empty?
|
|
end
|
|
|
|
def candidates_lost?
|
|
suppressions.any?(&:lossy?)
|
|
end
|
|
|
|
def unanalysable
|
|
candidates.select(&:unanalysable?)
|
|
end
|
|
|
|
def fully_analysed?
|
|
candidates.all?(&:state_known?)
|
|
end
|
|
|
|
def gated
|
|
candidates.select(&:gated?)
|
|
end
|
|
|
|
def ungated
|
|
candidates.select { |candidate| candidate.gate == GATE_UNGATED }
|
|
end
|
|
|
|
def links
|
|
candidates.select(&:link?)
|
|
end
|
|
|
|
def entry_points
|
|
candidates.select(&:entry_point?)
|
|
end
|
|
|
|
def reachable
|
|
candidates.select(&:reachable?)
|
|
end
|
|
|
|
def reachable_in(format)
|
|
reachable.select { |candidate| candidate.formats.include?(format) }
|
|
end
|
|
|
|
def prism_available?
|
|
PRISM_AVAILABLE
|
|
end
|
|
end
|
|
|
|
def initialize(namespace: nil)
|
|
@namespace = namespace
|
|
@candidates = []
|
|
@definition_cache = {}
|
|
@scanned_modules = 0
|
|
@suppressions = []
|
|
end
|
|
|
|
def scan
|
|
each_named_module do |mod, name|
|
|
@scanned_modules += 1
|
|
collect_instance_methods(mod, name)
|
|
collect_singleton_methods(mod, name)
|
|
end
|
|
|
|
Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze)
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :namespace
|
|
|
|
def suppress(site, subject, error)
|
|
@suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name)
|
|
end
|
|
|
|
def each_named_module
|
|
ObjectSpace.each_object(Module) do |mod|
|
|
name = safe_name(mod)
|
|
next unless name
|
|
next unless in_namespace?(name)
|
|
|
|
yield mod, name
|
|
end
|
|
end
|
|
|
|
def safe_name(mod)
|
|
name = mod.name
|
|
name if name.is_a?(String) && !name.empty?
|
|
rescue StandardError => e
|
|
suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e)
|
|
nil
|
|
end
|
|
|
|
def in_namespace?(name)
|
|
namespace.nil? || name == namespace || name.start_with?("#{namespace}::")
|
|
end
|
|
|
|
def collect_instance_methods(mod, name)
|
|
(own_instance_methods(mod, name) & INSTANCE_METHODS).each do |method_name|
|
|
record(mod, name, method_name, singleton: false)
|
|
end
|
|
end
|
|
|
|
def collect_singleton_methods(mod, name)
|
|
(own_singleton_methods(mod, name) & SINGLETON_METHODS).each do |method_name|
|
|
record(mod, name, method_name, singleton: true)
|
|
end
|
|
end
|
|
|
|
def own_instance_methods(mod, name)
|
|
(mod.instance_methods(false) +
|
|
mod.private_instance_methods(false) +
|
|
mod.protected_instance_methods(false)).map(&:to_s)
|
|
rescue StandardError => e
|
|
suppress(SITE_OWN_METHODS, name, e)
|
|
[]
|
|
end
|
|
|
|
def own_singleton_methods(mod, name)
|
|
(mod.singleton_methods(false) +
|
|
mod.singleton_class.private_instance_methods(false)).map(&:to_s)
|
|
rescue StandardError => e
|
|
suppress(SITE_OWN_METHODS, name, e)
|
|
[]
|
|
end
|
|
|
|
def qualified(name, method_name, singleton)
|
|
"#{name}#{singleton ? '.' : '#'}#{method_name}"
|
|
end
|
|
|
|
def record(mod, name, method_name, singleton:)
|
|
spec = ENTRY_POINTS.fetch(method_name)
|
|
handle = singleton ? mod.singleton_class.instance_method(method_name) : mod.instance_method(method_name)
|
|
|
|
@candidates << Candidate.new(
|
|
class_name: name,
|
|
method_name: method_name,
|
|
gate: spec.fetch(:gate),
|
|
source_location: format_location(handle.source_location),
|
|
arity: handle.arity,
|
|
singleton: singleton,
|
|
touches_state: state_reference_in(handle, qualified(name, method_name, singleton)),
|
|
formats: spec.fetch(:formats)
|
|
)
|
|
rescue StandardError, ScriptError => e
|
|
suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
|
|
nil
|
|
end
|
|
|
|
def format_location(location)
|
|
return UNKNOWN_LOCATION unless location
|
|
|
|
location.join(LOCATION_SEPARATOR)
|
|
end
|
|
|
|
def state_reference_in(handle, subject)
|
|
return STATE_UNANALYSABLE unless PRISM_AVAILABLE
|
|
|
|
path, line = handle.source_location
|
|
return STATE_UNANALYSABLE unless path && line
|
|
|
|
definitions = definitions_for(path)
|
|
return STATE_UNREADABLE unless definitions
|
|
|
|
node = definitions[line]
|
|
return STATE_UNREADABLE unless node
|
|
|
|
node.compact_child_nodes.any? { |child| state_reference?(child) }
|
|
rescue StandardError, ScriptError => e
|
|
suppress(SITE_STATE_ANALYSIS, subject, e)
|
|
STATE_UNREADABLE
|
|
end
|
|
|
|
def definitions_for(path)
|
|
return @definition_cache[path] if @definition_cache.key?(path)
|
|
|
|
@definition_cache[path] = parse_definitions(path)
|
|
end
|
|
|
|
def parse_definitions(path)
|
|
parsed = Prism.parse_file(path)
|
|
if parsed.failure?
|
|
suppress(SITE_SOURCE_PARSE, path, ParseRecoveredError.new(parsed.errors.length))
|
|
return nil
|
|
end
|
|
|
|
found = {}
|
|
collect_definitions(parsed.value, found)
|
|
found
|
|
rescue StandardError, ScriptError => e
|
|
suppress(SITE_SOURCE_PARSE, path, e)
|
|
nil
|
|
end
|
|
|
|
def collect_definitions(node, found)
|
|
return unless node.is_a?(Prism::Node)
|
|
|
|
found[node.location.start_line] = node if node.is_a?(Prism::DefNode)
|
|
node.compact_child_nodes.each { |child| collect_definitions(child, found) }
|
|
end
|
|
|
|
def state_reference?(node)
|
|
return false unless node.is_a?(Prism::Node)
|
|
return true if node.is_a?(Prism::InstanceVariableReadNode)
|
|
return true if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
|
|
node.compact_child_nodes.any? { |child| state_reference?(child) }
|
|
end
|
|
end
|
|
end
|