rigortype 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +373 -0
  3. data/README.md +152 -0
  4. data/exe/rigor +9 -0
  5. data/lib/rigor/analysis/check_rules.rb +503 -0
  6. data/lib/rigor/analysis/diagnostic.rb +35 -0
  7. data/lib/rigor/analysis/fact_store.rb +133 -0
  8. data/lib/rigor/analysis/result.rb +29 -0
  9. data/lib/rigor/analysis/runner.rb +119 -0
  10. data/lib/rigor/ast/type_node.rb +41 -0
  11. data/lib/rigor/ast.rb +22 -0
  12. data/lib/rigor/cli/type_of_command.rb +160 -0
  13. data/lib/rigor/cli/type_of_renderer.rb +88 -0
  14. data/lib/rigor/cli/type_scan_command.rb +160 -0
  15. data/lib/rigor/cli/type_scan_renderer.rb +165 -0
  16. data/lib/rigor/cli/type_scan_report.rb +32 -0
  17. data/lib/rigor/cli.rb +195 -0
  18. data/lib/rigor/configuration.rb +49 -0
  19. data/lib/rigor/environment/class_registry.rb +141 -0
  20. data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
  21. data/lib/rigor/environment/rbs_loader.rb +244 -0
  22. data/lib/rigor/environment.rb +177 -0
  23. data/lib/rigor/inference/acceptance.rb +444 -0
  24. data/lib/rigor/inference/block_parameter_binder.rb +198 -0
  25. data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
  26. data/lib/rigor/inference/coverage_scanner.rb +85 -0
  27. data/lib/rigor/inference/expression_typer.rb +831 -0
  28. data/lib/rigor/inference/fallback.rb +35 -0
  29. data/lib/rigor/inference/fallback_tracer.rb +64 -0
  30. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
  31. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
  32. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
  33. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +213 -0
  35. data/lib/rigor/inference/method_parameter_binder.rb +257 -0
  36. data/lib/rigor/inference/multi_target_binder.rb +143 -0
  37. data/lib/rigor/inference/narrowing.rb +1008 -0
  38. data/lib/rigor/inference/rbs_type_translator.rb +219 -0
  39. data/lib/rigor/inference/scope_indexer.rb +468 -0
  40. data/lib/rigor/inference/statement_evaluator.rb +1017 -0
  41. data/lib/rigor/rbs_extended.rb +98 -0
  42. data/lib/rigor/scope.rb +340 -0
  43. data/lib/rigor/source/node_locator.rb +104 -0
  44. data/lib/rigor/source/node_walker.rb +37 -0
  45. data/lib/rigor/source.rb +15 -0
  46. data/lib/rigor/testing.rb +65 -0
  47. data/lib/rigor/trinary.rb +108 -0
  48. data/lib/rigor/type/accepts_result.rb +109 -0
  49. data/lib/rigor/type/bot.rb +57 -0
  50. data/lib/rigor/type/combinator.rb +148 -0
  51. data/lib/rigor/type/constant.rb +90 -0
  52. data/lib/rigor/type/dynamic.rb +60 -0
  53. data/lib/rigor/type/hash_shape.rb +246 -0
  54. data/lib/rigor/type/nominal.rb +83 -0
  55. data/lib/rigor/type/singleton.rb +65 -0
  56. data/lib/rigor/type/top.rb +56 -0
  57. data/lib/rigor/type/tuple.rb +84 -0
  58. data/lib/rigor/type/union.rb +65 -0
  59. data/lib/rigor/type.rb +23 -0
  60. data/lib/rigor/version.rb +5 -0
  61. data/lib/rigor.rb +29 -0
  62. data/sig/rigor/analysis/fact_store.rbs +51 -0
  63. data/sig/rigor/ast.rbs +11 -0
  64. data/sig/rigor/environment.rbs +59 -0
  65. data/sig/rigor/inference.rbs +151 -0
  66. data/sig/rigor/rbs_extended.rbs +22 -0
  67. data/sig/rigor/scope.rbs +49 -0
  68. data/sig/rigor/source.rbs +20 -0
  69. data/sig/rigor/testing.rbs +9 -0
  70. data/sig/rigor/trinary.rbs +29 -0
  71. data/sig/rigor/type.rbs +171 -0
  72. data/sig/rigor.rbs +70 -0
  73. metadata +260 -0
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+
6
+ module Rigor
7
+ class CLI
8
+ # Renders a `TypeScanCommand::Report` as either a terminal-friendly text
9
+ # summary or a JSON document suitable for CI ingestion. Text and JSON
10
+ # branches share a single source of truth (the `Report` value object) so
11
+ # the two formats stay in lockstep; that pairing is why this class is a
12
+ # bit longer than the default class-length budget.
13
+ class TypeScanRenderer # rubocop:disable Metrics/ClassLength
14
+ def initialize(out:)
15
+ @out = out
16
+ end
17
+
18
+ def render(report, format:)
19
+ case format
20
+ when "text" then render_text(report)
21
+ when "json" then render_json(report)
22
+ else
23
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def render_text(report)
30
+ render_text_header(report)
31
+ render_text_summary(report)
32
+ render_text_class_table(report)
33
+ render_text_events(report)
34
+ render_text_parse_errors(report)
35
+ end
36
+
37
+ def render_text_header(report)
38
+ files = report.files
39
+ suffix = files.size == 1 ? "" : "s"
40
+ @out.puts("Type-of scan: #{files.size} file#{suffix}")
41
+ files.first(5).each { |f| @out.puts(" - #{f}") }
42
+ @out.puts(" ... (#{files.size - 5} more)") if files.size > 5
43
+ @out.puts("")
44
+ end
45
+
46
+ def render_text_summary(report)
47
+ processed = report.files.size - report.parse_errors.size
48
+ visited = report.visited_count
49
+ unrec = report.unrecognized_count
50
+
51
+ @out.puts("Summary:")
52
+ @out.puts(" files processed: #{processed}")
53
+ @out.puts(" parse errors: #{report.parse_errors.size}")
54
+ @out.puts(" AST nodes visited: #{visited}")
55
+ @out.puts(" unrecognized: #{unrec}#{percent_suffix(unrec, visited)}")
56
+ @out.puts("")
57
+ end
58
+
59
+ def render_text_class_table(report)
60
+ rows = build_class_rows(report)
61
+ return if rows.empty?
62
+
63
+ width = rows.map { |row| row[:name].size }.max
64
+ @out.puts("Coverage by node class (unrecognized/visits):")
65
+ rows.each { |row| @out.puts(format_class_row(row, width)) }
66
+ @out.puts("")
67
+ end
68
+
69
+ def format_class_row(row, width)
70
+ suffix = percent_suffix(row[:unrecognized], row[:visits])
71
+ " #{row[:name].ljust(width)} #{row[:unrecognized]}/#{row[:visits]}#{suffix}"
72
+ end
73
+
74
+ def build_class_rows(report)
75
+ rows = report.visits.map do |klass, visits|
76
+ unrec = report.unrecognized[klass] || 0
77
+ { name: klass.name, visits: visits, unrecognized: unrec }
78
+ end
79
+ rows.reject! { |row| row[:unrecognized].zero? } unless report.options[:show_recognized]
80
+ rows.sort_by! do |row|
81
+ ratio = row[:visits].zero? ? 0.0 : row[:unrecognized].fdiv(row[:visits])
82
+ [-ratio, -row[:unrecognized], row[:name]]
83
+ end
84
+ rows
85
+ end
86
+
87
+ def render_text_events(report)
88
+ events = report.events
89
+ return if events.empty?
90
+
91
+ limit = report.options[:limit] || events.size
92
+ shown = events.first(limit)
93
+ @out.puts("Unrecognized examples (showing #{shown.size} of #{events.size}):")
94
+ shown.each do |located|
95
+ @out.puts(" #{located.event.node_class} @ #{location_text(located)}")
96
+ end
97
+ @out.puts("")
98
+ end
99
+
100
+ def render_text_parse_errors(report)
101
+ return if report.parse_errors.empty?
102
+
103
+ @out.puts("Parse errors:")
104
+ report.parse_errors.each do |entry|
105
+ @out.puts(" #{entry[:file]}: #{entry[:errors].join('; ')}")
106
+ end
107
+ end
108
+
109
+ def render_json(report)
110
+ @out.puts(JSON.pretty_generate(json_payload(report)))
111
+ end
112
+
113
+ def json_payload(report)
114
+ {
115
+ files: report.files,
116
+ summary: {
117
+ files_processed: report.files.size - report.parse_errors.size,
118
+ parse_errors: report.parse_errors.size,
119
+ visited: report.visited_count,
120
+ unrecognized: report.unrecognized_count,
121
+ unrecognized_ratio: report.unrecognized_ratio
122
+ },
123
+ by_class: by_class_payload(report),
124
+ events: report.events.map { |located| event_payload(located) },
125
+ parse_errors: report.parse_errors.map do |entry|
126
+ { file: entry[:file], errors: entry[:errors] }
127
+ end
128
+ }
129
+ end
130
+
131
+ def by_class_payload(report)
132
+ report.visits.sort_by { |klass, _| klass.name }.to_h do |klass, visits|
133
+ [klass.name, { visits: visits, unrecognized: report.unrecognized[klass] || 0 }]
134
+ end
135
+ end
136
+
137
+ def event_payload(located)
138
+ location = located.event.location
139
+ payload = {
140
+ file: located.file,
141
+ node_class: located.event.node_class.name,
142
+ family: located.event.family
143
+ }
144
+ if location.respond_to?(:start_line)
145
+ payload[:line] = location.start_line
146
+ payload[:column] = location.start_column + 1
147
+ end
148
+ payload
149
+ end
150
+
151
+ def location_text(located)
152
+ location = located.event.location
153
+ return "<no location>" unless location.respond_to?(:start_line)
154
+
155
+ "#{located.file}:#{location.start_line}:#{location.start_column + 1}"
156
+ end
157
+
158
+ def percent_suffix(numerator, denominator)
159
+ return "" if denominator.zero?
160
+
161
+ " (#{(numerator.fdiv(denominator) * 100).round(1)}%)"
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class CLI
5
+ # Aggregated report assembled by `TypeScanCommand` and consumed by
6
+ # `TypeScanRenderer`. The struct holds per-file paths, accumulated
7
+ # per-class counts, located fallback events, and any parse errors.
8
+ Report = Data.define(
9
+ :files,
10
+ :parse_errors,
11
+ :visits,
12
+ :unrecognized,
13
+ :events,
14
+ :options
15
+ ) do
16
+ def visited_count
17
+ visits.values.sum
18
+ end
19
+
20
+ def unrecognized_count
21
+ unrecognized.values.sum
22
+ end
23
+
24
+ def unrecognized_ratio
25
+ total = visited_count
26
+ return 0.0 if total.zero?
27
+
28
+ unrecognized_count.fdiv(total)
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/rigor/cli.rb ADDED
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optionparser"
5
+ require "yaml"
6
+
7
+ require_relative "configuration"
8
+ require_relative "version"
9
+ require_relative "analysis/diagnostic"
10
+ require_relative "analysis/result"
11
+
12
+ module Rigor
13
+ # The CLI class is a dispatcher: each `run_*` method delegates to a
14
+ # command-specific class once the command grows beyond a few lines (see
15
+ # {CLI::TypeOfCommand}). The class-length budget is intentionally relaxed
16
+ # here so dispatch wiring can live alongside still-inlined commands.
17
+ class CLI # rubocop:disable Metrics/ClassLength
18
+ EXIT_USAGE = 64
19
+
20
+ HANDLERS = {
21
+ "check" => :run_check,
22
+ "init" => :run_init,
23
+ "type-of" => :run_type_of,
24
+ "type-scan" => :run_type_scan
25
+ }.freeze
26
+
27
+ def self.start(argv = ARGV, out: $stdout, err: $stderr)
28
+ new(argv.dup, out: out, err: err).run
29
+ end
30
+
31
+ def initialize(argv, out:, err:)
32
+ @argv = argv
33
+ @out = out
34
+ @err = err
35
+ end
36
+
37
+ def run
38
+ command = @argv.shift
39
+
40
+ case command
41
+ when nil, "help", "-h", "--help"
42
+ @out.puts(help)
43
+ 0
44
+ when "version", "-v", "--version"
45
+ @out.puts("rigor #{Rigor::VERSION}")
46
+ 0
47
+ else
48
+ dispatch(command)
49
+ end
50
+ rescue OptionParser::ParseError => e
51
+ @err.puts(e.message)
52
+ EXIT_USAGE
53
+ end
54
+
55
+ private
56
+
57
+ def dispatch(command)
58
+ handler = HANDLERS[command]
59
+ return send(handler) if handler
60
+
61
+ @err.puts("Unknown command: #{command}")
62
+ @err.puts(help)
63
+ EXIT_USAGE
64
+ end
65
+
66
+ def run_check
67
+ require_relative "analysis/runner"
68
+
69
+ options = {
70
+ config: Configuration::DEFAULT_PATH,
71
+ format: "text"
72
+ }
73
+
74
+ parser = OptionParser.new do |opts|
75
+ opts.banner = "Usage: rigor check [options] [paths]"
76
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
77
+ opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
78
+ end
79
+ parser.parse!(@argv)
80
+
81
+ configuration = Configuration.load(options.fetch(:config))
82
+ paths = @argv.empty? ? configuration.paths : @argv
83
+ result = Analysis::Runner.new(configuration: configuration).run(paths)
84
+
85
+ write_result(result, options.fetch(:format))
86
+ result.success? ? 0 : 1
87
+ end
88
+
89
+ def run_init
90
+ options = {
91
+ force: false,
92
+ path: Configuration::DEFAULT_PATH
93
+ }
94
+
95
+ parser = OptionParser.new do |opts|
96
+ opts.banner = "Usage: rigor init [options]"
97
+ opts.on("--force", "Overwrite an existing configuration file") { options[:force] = true }
98
+ opts.on("--path=PATH", "Configuration file path") { |value| options[:path] = value }
99
+ end
100
+ parser.parse!(@argv)
101
+
102
+ path = options.fetch(:path)
103
+ if File.exist?(path) && !options.fetch(:force)
104
+ @err.puts("#{path} already exists; use --force to overwrite it")
105
+ return 1
106
+ end
107
+
108
+ File.write(path, init_template)
109
+ @out.puts("Created #{path}")
110
+ 0
111
+ end
112
+
113
+ # Renders the starter `.rigor.yml` body. The template
114
+ # serialises `Configuration::DEFAULTS` (so the on-disk file
115
+ # round-trips through `Configuration.load`) and prepends a
116
+ # short header that points the user at the keys they are
117
+ # most likely to want to edit.
118
+ def init_template
119
+ <<~YAML
120
+ # Rigor configuration. See docs/CURRENT_WORK.md for the
121
+ # full set of features the analyzer ships in this preview.
122
+ #
123
+ # Keys you may want to edit:
124
+ # - target_ruby: minimum Ruby version your project targets.
125
+ # - paths: directories scanned by `rigor check` and
126
+ # `rigor type-scan` when no path is given.
127
+ # - plugins: reserved for future plugin contributions
128
+ # (no plugins are loaded today).
129
+ # - cache.path: where Rigor will eventually persist
130
+ # analysis results across runs.
131
+ #
132
+ # `Rigor::Environment.for_project` automatically loads
133
+ # the project's `sig/` directory plus a curated stdlib
134
+ # bundle (pathname, optparse, json, yaml, fileutils,
135
+ # tempfile, uri, logger, date, prism, rbs). Adding a
136
+ # `sig/<gem>.rbs` file under `sig/` is the simplest way
137
+ # to extend type coverage today.
138
+ #{YAML.dump(Configuration::DEFAULTS).sub(/\A---\n/, '')}
139
+ YAML
140
+ end
141
+
142
+ def run_type_of
143
+ require_relative "cli/type_of_command"
144
+
145
+ TypeOfCommand.new(argv: @argv, out: @out, err: @err).run
146
+ end
147
+
148
+ def run_type_scan
149
+ require_relative "cli/type_scan_command"
150
+
151
+ TypeScanCommand.new(argv: @argv, out: @out, err: @err).run
152
+ end
153
+
154
+ def write_result(result, format)
155
+ case format
156
+ when "json"
157
+ @out.puts(JSON.pretty_generate(result.to_h))
158
+ when "text"
159
+ write_text_result(result)
160
+ else
161
+ raise OptionParser::InvalidArgument, "unsupported format: #{format}"
162
+ end
163
+ end
164
+
165
+ # Text output adds a one-line summary so users see the
166
+ # diagnostic-count immediately. The summary distinguishes
167
+ # the success and failure cases and reports the affected
168
+ # file count for failures.
169
+ def write_text_result(result)
170
+ if result.success?
171
+ @out.puts("No diagnostics")
172
+ return
173
+ end
174
+
175
+ result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
176
+ file_count = result.diagnostics.map(&:path).uniq.size
177
+ @out.puts("")
178
+ @out.puts("#{result.error_count} error(s) in #{file_count} file(s)")
179
+ end
180
+
181
+ def help
182
+ <<~HELP
183
+ Usage: rigor <command> [options]
184
+
185
+ Commands:
186
+ check Analyze Ruby source files
187
+ init Create a starter .rigor.yml
188
+ type-of Print the inferred type at FILE:LINE:COL
189
+ type-scan Report Scope#type_of coverage across PATHs
190
+ version Print the Rigor version
191
+ help Print this help
192
+ HELP
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ class Configuration
7
+ DEFAULT_PATH = ".rigor.yml"
8
+ DEFAULTS = {
9
+ "target_ruby" => "4.0",
10
+ "paths" => ["lib"],
11
+ "plugins" => [],
12
+ "cache" => {
13
+ "path" => ".rigor/cache"
14
+ }
15
+ }.freeze
16
+
17
+ attr_reader :target_ruby, :paths, :plugins, :cache_path
18
+
19
+ def self.load(path = DEFAULT_PATH)
20
+ data = if File.exist?(path)
21
+ YAML.safe_load_file(path, aliases: false) || {}
22
+ else
23
+ {}
24
+ end
25
+
26
+ new(DEFAULTS.merge(data))
27
+ end
28
+
29
+ def initialize(data = DEFAULTS)
30
+ cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
31
+
32
+ @target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
33
+ @paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
34
+ @plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
35
+ @cache_path = cache.fetch("path").to_s
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ "target_ruby" => target_ruby,
41
+ "paths" => paths,
42
+ "plugins" => plugins,
43
+ "cache" => {
44
+ "path" => cache_path
45
+ }
46
+ }
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ class Environment
7
+ # Resolves Ruby Class/Module objects to Rigor::Type::Nominal instances.
8
+ # The hardcoded list spans the core classes the literal typer (Slice 1)
9
+ # and the constant-resolution path (Slice 2 strengthening) need.
10
+ # Slice 4 will extend the registry by reading RBS Definitions through
11
+ # Rigor::Environment::RbsLoader.
12
+ #
13
+ # See docs/internal-spec/inference-engine.md for the binding contract
14
+ # (every entry below MUST always be recognised).
15
+ class ClassRegistry
16
+ SLICE_1_BUILT_INS = [
17
+ Integer,
18
+ Float,
19
+ String,
20
+ Symbol,
21
+ NilClass,
22
+ TrueClass,
23
+ FalseClass,
24
+ Object,
25
+ BasicObject
26
+ ].freeze
27
+
28
+ # Common Ruby core classes that user code routinely names by constant
29
+ # reference. Adding them to the registry lets `nominal_for_name`
30
+ # resolve `Array`, `Hash`, etc. without each call site re-listing
31
+ # them; Slice 4's RBS loader will subsume these once it lands.
32
+ SLICE_2_BUILT_INS = [
33
+ Array,
34
+ Hash,
35
+ Range,
36
+ Regexp,
37
+ Proc,
38
+ Method,
39
+ Module,
40
+ Class,
41
+ Numeric,
42
+ Comparable,
43
+ Enumerable,
44
+ Exception,
45
+ StandardError,
46
+ RuntimeError,
47
+ ArgumentError,
48
+ TypeError,
49
+ NameError,
50
+ NoMethodError,
51
+ KeyError,
52
+ IndexError,
53
+ RangeError,
54
+ ZeroDivisionError,
55
+ IO,
56
+ File,
57
+ Dir,
58
+ Encoding
59
+ ].freeze
60
+
61
+ CORE_BUILT_INS = (SLICE_1_BUILT_INS + SLICE_2_BUILT_INS).freeze
62
+
63
+ class << self
64
+ def default
65
+ @default ||= build_default
66
+ end
67
+
68
+ private
69
+
70
+ def build_default
71
+ new.tap do |registry|
72
+ CORE_BUILT_INS.each { |klass| registry.register(klass) }
73
+ end.freeze
74
+ end
75
+ end
76
+
77
+ def initialize
78
+ @nominals = {}
79
+ @class_objects = {}
80
+ end
81
+
82
+ def register(class_object)
83
+ raise ArgumentError, "expected Class or Module, got #{class_object.class}" unless class_object.is_a?(Module)
84
+ raise ArgumentError, "anonymous class has no name" if class_object.name.nil?
85
+
86
+ @nominals[class_object.name] ||= Type::Combinator.nominal_of(class_object)
87
+ @class_objects[class_object.name] ||= class_object
88
+ self
89
+ end
90
+
91
+ def registered?(class_object)
92
+ return false unless class_object.is_a?(Module) && class_object.name
93
+
94
+ @nominals.key?(class_object.name)
95
+ end
96
+
97
+ def nominal_for(class_object)
98
+ unless registered?(class_object)
99
+ raise KeyError, "Rigor::Environment::ClassRegistry has no entry for #{class_object.inspect}"
100
+ end
101
+
102
+ @nominals.fetch(class_object.name)
103
+ end
104
+
105
+ # Nil-safe lookup by class name. Accepts Symbol or String. Returns the
106
+ # registered Rigor::Type::Nominal, or nil when the name is unknown.
107
+ # Used by ExpressionTyper to resolve Prism::ConstantReadNode and
108
+ # Prism::ConstantPathNode under the fail-soft policy: unknown names
109
+ # MUST NOT raise and MUST flow through the engine's tracer.
110
+ def nominal_for_name(name)
111
+ return nil if name.nil?
112
+
113
+ @nominals[name.to_s]
114
+ end
115
+
116
+ def class_ordering(lhs, rhs)
117
+ lhs = normalize_name(lhs)
118
+ rhs = normalize_name(rhs)
119
+ return :equal if lhs == rhs
120
+
121
+ lhs_class = @class_objects[lhs]
122
+ rhs_class = @class_objects[rhs]
123
+ return :unknown if lhs_class.nil? || rhs_class.nil?
124
+
125
+ if lhs_class <= rhs_class
126
+ :subclass
127
+ elsif rhs_class <= lhs_class
128
+ :superclass
129
+ else
130
+ :disjoint
131
+ end
132
+ end
133
+
134
+ private
135
+
136
+ def normalize_name(name)
137
+ name.to_s.delete_prefix("::")
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ class Environment
5
+ # Small hierarchy oracle backed by RBS instance definitions.
6
+ class RbsHierarchy
7
+ def initialize(loader)
8
+ @loader = loader
9
+ @ancestor_names_cache = {}
10
+ @class_ordering_cache = {}
11
+ end
12
+
13
+ def class_ordering(lhs, rhs)
14
+ lhs = normalize_name(lhs)
15
+ rhs = normalize_name(rhs)
16
+ return :equal if lhs == rhs
17
+
18
+ key = [lhs, rhs]
19
+ return @class_ordering_cache[key] if @class_ordering_cache.key?(key)
20
+
21
+ @class_ordering_cache[key] = compute_class_ordering(lhs, rhs)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :loader
27
+
28
+ def compute_class_ordering(lhs, rhs)
29
+ return :unknown unless loader.class_known?(lhs) && loader.class_known?(rhs)
30
+
31
+ lhs_ancestors = ancestor_names(lhs)
32
+ rhs_ancestors = ancestor_names(rhs)
33
+ return :unknown if lhs_ancestors.empty? || rhs_ancestors.empty?
34
+
35
+ if lhs_ancestors.include?(rhs)
36
+ :subclass
37
+ elsif rhs_ancestors.include?(lhs)
38
+ :superclass
39
+ else
40
+ :disjoint
41
+ end
42
+ end
43
+
44
+ def ancestor_names(class_name)
45
+ key = normalize_name(class_name)
46
+ return @ancestor_names_cache[key] if @ancestor_names_cache.key?(key)
47
+
48
+ definition = loader.instance_definition(key)
49
+ @ancestor_names_cache[key] =
50
+ if definition
51
+ definition.ancestors.ancestors.map { |ancestor| normalize_name(ancestor.name.to_s) }.uniq.freeze
52
+ else
53
+ [].freeze
54
+ end
55
+ rescue StandardError
56
+ @ancestor_names_cache[key] = [].freeze
57
+ end
58
+
59
+ def normalize_name(name)
60
+ name.to_s.delete_prefix("::")
61
+ end
62
+ end
63
+ end
64
+ end