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.
- checksums.yaml +7 -0
- data/LICENSE +373 -0
- data/README.md +152 -0
- data/exe/rigor +9 -0
- data/lib/rigor/analysis/check_rules.rb +503 -0
- data/lib/rigor/analysis/diagnostic.rb +35 -0
- data/lib/rigor/analysis/fact_store.rb +133 -0
- data/lib/rigor/analysis/result.rb +29 -0
- data/lib/rigor/analysis/runner.rb +119 -0
- data/lib/rigor/ast/type_node.rb +41 -0
- data/lib/rigor/ast.rb +22 -0
- data/lib/rigor/cli/type_of_command.rb +160 -0
- data/lib/rigor/cli/type_of_renderer.rb +88 -0
- data/lib/rigor/cli/type_scan_command.rb +160 -0
- data/lib/rigor/cli/type_scan_renderer.rb +165 -0
- data/lib/rigor/cli/type_scan_report.rb +32 -0
- data/lib/rigor/cli.rb +195 -0
- data/lib/rigor/configuration.rb +49 -0
- data/lib/rigor/environment/class_registry.rb +141 -0
- data/lib/rigor/environment/rbs_hierarchy.rb +64 -0
- data/lib/rigor/environment/rbs_loader.rb +244 -0
- data/lib/rigor/environment.rb +177 -0
- data/lib/rigor/inference/acceptance.rb +444 -0
- data/lib/rigor/inference/block_parameter_binder.rb +198 -0
- data/lib/rigor/inference/closure_escape_analyzer.rb +191 -0
- data/lib/rigor/inference/coverage_scanner.rb +85 -0
- data/lib/rigor/inference/expression_typer.rb +831 -0
- data/lib/rigor/inference/fallback.rb +35 -0
- data/lib/rigor/inference/fallback_tracer.rb +64 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +102 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +169 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +421 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +336 -0
- data/lib/rigor/inference/method_dispatcher.rb +213 -0
- data/lib/rigor/inference/method_parameter_binder.rb +257 -0
- data/lib/rigor/inference/multi_target_binder.rb +143 -0
- data/lib/rigor/inference/narrowing.rb +1008 -0
- data/lib/rigor/inference/rbs_type_translator.rb +219 -0
- data/lib/rigor/inference/scope_indexer.rb +468 -0
- data/lib/rigor/inference/statement_evaluator.rb +1017 -0
- data/lib/rigor/rbs_extended.rb +98 -0
- data/lib/rigor/scope.rb +340 -0
- data/lib/rigor/source/node_locator.rb +104 -0
- data/lib/rigor/source/node_walker.rb +37 -0
- data/lib/rigor/source.rb +15 -0
- data/lib/rigor/testing.rb +65 -0
- data/lib/rigor/trinary.rb +108 -0
- data/lib/rigor/type/accepts_result.rb +109 -0
- data/lib/rigor/type/bot.rb +57 -0
- data/lib/rigor/type/combinator.rb +148 -0
- data/lib/rigor/type/constant.rb +90 -0
- data/lib/rigor/type/dynamic.rb +60 -0
- data/lib/rigor/type/hash_shape.rb +246 -0
- data/lib/rigor/type/nominal.rb +83 -0
- data/lib/rigor/type/singleton.rb +65 -0
- data/lib/rigor/type/top.rb +56 -0
- data/lib/rigor/type/tuple.rb +84 -0
- data/lib/rigor/type/union.rb +65 -0
- data/lib/rigor/type.rb +23 -0
- data/lib/rigor/version.rb +5 -0
- data/lib/rigor.rb +29 -0
- data/sig/rigor/analysis/fact_store.rbs +51 -0
- data/sig/rigor/ast.rbs +11 -0
- data/sig/rigor/environment.rbs +59 -0
- data/sig/rigor/inference.rbs +151 -0
- data/sig/rigor/rbs_extended.rbs +22 -0
- data/sig/rigor/scope.rbs +49 -0
- data/sig/rigor/source.rbs +20 -0
- data/sig/rigor/testing.rbs +9 -0
- data/sig/rigor/trinary.rbs +29 -0
- data/sig/rigor/type.rbs +171 -0
- data/sig/rigor.rbs +70 -0
- 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
|