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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../environment"
|
|
6
|
+
require_relative "../scope"
|
|
7
|
+
require_relative "../inference/scope_indexer"
|
|
8
|
+
require_relative "check_rules"
|
|
9
|
+
require_relative "diagnostic"
|
|
10
|
+
require_relative "result"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
module Analysis
|
|
14
|
+
class Runner
|
|
15
|
+
RUBY_GLOB = "**/*.rb"
|
|
16
|
+
|
|
17
|
+
def initialize(configuration:)
|
|
18
|
+
@configuration = configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Walks every Ruby file under `paths`, parses it, builds a
|
|
22
|
+
# per-node scope index through
|
|
23
|
+
# `Rigor::Inference::ScopeIndexer`, and runs the
|
|
24
|
+
# `Rigor::Analysis::CheckRules` catalogue over it. Returns
|
|
25
|
+
# a `Rigor::Analysis::Result` aggregating every produced
|
|
26
|
+
# diagnostic plus any Prism parse errors. The Environment
|
|
27
|
+
# is built once at run start through `Environment.for_project`
|
|
28
|
+
# so all files share the same RBS load.
|
|
29
|
+
def run(paths = @configuration.paths)
|
|
30
|
+
environment = Environment.for_project
|
|
31
|
+
expansion = expand_paths(paths)
|
|
32
|
+
|
|
33
|
+
diagnostics = expansion.fetch(:errors)
|
|
34
|
+
diagnostics += expansion.fetch(:files).flat_map { |path| analyze_file(path, environment) }
|
|
35
|
+
|
|
36
|
+
Result.new(diagnostics: diagnostics)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Resolves the user-supplied path list into:
|
|
42
|
+
# - `:files` — the concrete `.rb` files to analyze.
|
|
43
|
+
# - `:errors` — `Diagnostic` entries for each path that
|
|
44
|
+
# does not exist or is not a recognisable Ruby source.
|
|
45
|
+
#
|
|
46
|
+
# Surfacing path errors is a first-preview must-have:
|
|
47
|
+
# `rigor check ./does_not_exist.rb` previously exited
|
|
48
|
+
# cleanly with no output, which silently masked typos.
|
|
49
|
+
def expand_paths(paths)
|
|
50
|
+
files = []
|
|
51
|
+
errors = []
|
|
52
|
+
Array(paths).each do |path|
|
|
53
|
+
if File.directory?(path)
|
|
54
|
+
files.concat(Dir.glob(File.join(path, RUBY_GLOB)))
|
|
55
|
+
elsif File.file?(path) && path.end_with?(".rb")
|
|
56
|
+
files << path
|
|
57
|
+
elsif File.exist?(path)
|
|
58
|
+
errors << path_error(path, "not a Ruby file (expected `.rb` or a directory)")
|
|
59
|
+
else
|
|
60
|
+
errors << path_error(path, "no such file or directory")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
{ files: files, errors: errors }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def path_error(path, message)
|
|
67
|
+
Diagnostic.new(
|
|
68
|
+
path: path,
|
|
69
|
+
line: 1,
|
|
70
|
+
column: 1,
|
|
71
|
+
message: message,
|
|
72
|
+
severity: :error
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def analyze_file(path, environment)
|
|
77
|
+
parse_result = Prism.parse_file(path)
|
|
78
|
+
return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
|
|
79
|
+
|
|
80
|
+
scope = Scope.empty(environment: environment)
|
|
81
|
+
index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
|
|
82
|
+
CheckRules.diagnose(path: path, root: parse_result.value, scope_index: index)
|
|
83
|
+
rescue Errno::ENOENT => e
|
|
84
|
+
[
|
|
85
|
+
Diagnostic.new(
|
|
86
|
+
path: path,
|
|
87
|
+
line: 1,
|
|
88
|
+
column: 1,
|
|
89
|
+
message: e.message,
|
|
90
|
+
severity: :error
|
|
91
|
+
)
|
|
92
|
+
]
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
[
|
|
95
|
+
Diagnostic.new(
|
|
96
|
+
path: path,
|
|
97
|
+
line: 1,
|
|
98
|
+
column: 1,
|
|
99
|
+
message: "internal analyzer error: #{e.class}: #{e.message}",
|
|
100
|
+
severity: :error
|
|
101
|
+
)
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_diagnostics(path, parse_result)
|
|
106
|
+
parse_result.errors.map do |error|
|
|
107
|
+
location = error.location
|
|
108
|
+
Diagnostic.new(
|
|
109
|
+
path: path,
|
|
110
|
+
line: location.start_line,
|
|
111
|
+
column: location.start_column + 1,
|
|
112
|
+
message: error.message,
|
|
113
|
+
severity: :error
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module AST
|
|
5
|
+
# A virtual node that wraps a Rigor::Type. Allows callers to ask
|
|
6
|
+
# "what would the analyzer infer at this position if the value's type
|
|
7
|
+
# were T?" without constructing a real Prism expression.
|
|
8
|
+
#
|
|
9
|
+
# Rigor::Scope#type_of(TypeNode.new(t)) MUST return a structurally-
|
|
10
|
+
# equal t. The engine MUST NOT modify or annotate the wrapped type.
|
|
11
|
+
#
|
|
12
|
+
# Inspired by PHPStan's TypeExpr (a synthetic Expr that returns a
|
|
13
|
+
# specific Type from $scope->getType). The Rigor counterpart is
|
|
14
|
+
# spelled "TypeNode" to align with Prism's "Node" suffix convention.
|
|
15
|
+
class TypeNode
|
|
16
|
+
include Node
|
|
17
|
+
|
|
18
|
+
attr_reader :type
|
|
19
|
+
|
|
20
|
+
def initialize(type)
|
|
21
|
+
raise ArgumentError, "TypeNode requires a non-nil Rigor::Type" if type.nil?
|
|
22
|
+
|
|
23
|
+
@type = type
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def ==(other)
|
|
28
|
+
other.is_a?(TypeNode) && type == other.type
|
|
29
|
+
end
|
|
30
|
+
alias eql? ==
|
|
31
|
+
|
|
32
|
+
def hash
|
|
33
|
+
[TypeNode, type].hash
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inspect
|
|
37
|
+
"#<Rigor::AST::TypeNode #{type.describe(:short)}>"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/rigor/ast.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# Synthetic AST nodes accepted by Rigor::Scope#type_of alongside real
|
|
5
|
+
# Prism nodes. Rigor::AST::Node is a documentation-only marker module
|
|
6
|
+
# that production code uses to detect virtual-node arguments. Concrete
|
|
7
|
+
# virtual node classes include Rigor::AST::Node and provide whatever
|
|
8
|
+
# node-specific data the engine needs to translate them into a
|
|
9
|
+
# Rigor::Type.
|
|
10
|
+
#
|
|
11
|
+
# The contract for virtual nodes lives in
|
|
12
|
+
# docs/internal-spec/inference-engine.md; the rationale and the rejected
|
|
13
|
+
# alternative of specialising type classes for operator-method dispatch
|
|
14
|
+
# live in docs/adr/4-type-inference-engine.md.
|
|
15
|
+
module AST
|
|
16
|
+
# Marker module included by every synthetic node. Carries no behaviour.
|
|
17
|
+
module Node
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
require_relative "ast/type_node"
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../environment"
|
|
7
|
+
require_relative "../scope"
|
|
8
|
+
require_relative "../source/node_locator"
|
|
9
|
+
require_relative "../inference/fallback_tracer"
|
|
10
|
+
require_relative "../inference/scope_indexer"
|
|
11
|
+
require_relative "type_of_renderer"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
class CLI
|
|
15
|
+
# Executes the `rigor type-of` command.
|
|
16
|
+
#
|
|
17
|
+
# The command is a thin probe over `Rigor::Scope#type_of`: it locates the
|
|
18
|
+
# deepest expression at a `(file, line, column)` triple and prints the
|
|
19
|
+
# inferred type, RBS erasure, and (optionally) the recorded fail-soft
|
|
20
|
+
# fallbacks.
|
|
21
|
+
#
|
|
22
|
+
# Encapsulating the command in its own class keeps `Rigor::CLI` focused on
|
|
23
|
+
# dispatching and lets us evolve the type-of UX (extra flags, watch mode,
|
|
24
|
+
# streaming output) without bloating the CLI shell. Output formatting is
|
|
25
|
+
# delegated to {TypeOfRenderer}.
|
|
26
|
+
class TypeOfCommand
|
|
27
|
+
USAGE = "Usage: rigor type-of [options] FILE:LINE:COL"
|
|
28
|
+
|
|
29
|
+
Result = Data.define(:file, :line, :column, :node, :type, :tracer)
|
|
30
|
+
|
|
31
|
+
def initialize(argv:, out:, err:)
|
|
32
|
+
@argv = argv
|
|
33
|
+
@out = out
|
|
34
|
+
@err = err
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Integer] CLI exit status.
|
|
38
|
+
def run
|
|
39
|
+
options = parse_options
|
|
40
|
+
|
|
41
|
+
target = parse_position_argument(@argv)
|
|
42
|
+
return CLI::EXIT_USAGE if target.nil?
|
|
43
|
+
|
|
44
|
+
execute(target: target, options: options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_options
|
|
50
|
+
options = { format: "text", trace: false }
|
|
51
|
+
|
|
52
|
+
parser = OptionParser.new do |opts|
|
|
53
|
+
opts.banner = USAGE
|
|
54
|
+
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
55
|
+
opts.on("--trace", "Record fail-soft fallbacks via FallbackTracer") { options[:trace] = true }
|
|
56
|
+
end
|
|
57
|
+
parser.parse!(@argv)
|
|
58
|
+
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def execute(target:, options:)
|
|
63
|
+
file, line, column = target
|
|
64
|
+
return 1 unless file_exists?(file)
|
|
65
|
+
|
|
66
|
+
source = File.read(file)
|
|
67
|
+
parse_result = Prism.parse(source, filepath: file)
|
|
68
|
+
return 1 if parse_errors?(parse_result, file)
|
|
69
|
+
|
|
70
|
+
node = locate_node(source: source, root: parse_result.value, file: file, line: line, column: column)
|
|
71
|
+
return CLI::EXIT_USAGE if node == :out_of_range
|
|
72
|
+
return 1 if node.nil?
|
|
73
|
+
|
|
74
|
+
tracer = options[:trace] ? Inference::FallbackTracer.new : nil
|
|
75
|
+
base_scope = Scope.empty(environment: project_environment(file))
|
|
76
|
+
|
|
77
|
+
# Build a per-node scope index so locals bound earlier in the
|
|
78
|
+
# file flow into the scope used to type the queried node. We
|
|
79
|
+
# build the index with no tracer attached (it would otherwise
|
|
80
|
+
# double-record fallback events with the second-pass type_of
|
|
81
|
+
# call below), then look up the scope visible at the queried
|
|
82
|
+
# node and run the actual probe under it.
|
|
83
|
+
scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
|
|
84
|
+
node_scope = scope_index[node]
|
|
85
|
+
|
|
86
|
+
type = node_scope.type_of(node, tracer: tracer)
|
|
87
|
+
result = Result.new(file: file, line: line, column: column, node: node, type: type, tracer: tracer)
|
|
88
|
+
|
|
89
|
+
TypeOfRenderer.new(out: @out).render(result, format: options.fetch(:format))
|
|
90
|
+
0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Builds a project-aware environment relative to the probed file.
|
|
94
|
+
# Project-RBS auto-detection roots at CWD today; future work will
|
|
95
|
+
# walk parent directories to find the enclosing `Gemfile`/`*.gemspec`
|
|
96
|
+
# so probes against files outside the current process's CWD still
|
|
97
|
+
# see the right `sig/` tree.
|
|
98
|
+
def project_environment(_file)
|
|
99
|
+
Environment.for_project
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def file_exists?(file)
|
|
103
|
+
return true if File.file?(file)
|
|
104
|
+
|
|
105
|
+
@err.puts("type-of: file not found: #{file}")
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_errors?(result, file)
|
|
110
|
+
return false if result.errors.empty?
|
|
111
|
+
|
|
112
|
+
result.errors.each { |error| @err.puts("#{file}:#{error.location.start_line}: #{error.message}") }
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def locate_node(source:, root:, file:, line:, column:)
|
|
117
|
+
node = Source::NodeLocator.at_position(source: source, root: root, line: line, column: column)
|
|
118
|
+
@err.puts("type-of: no expression found at #{file}:#{line}:#{column}") if node.nil?
|
|
119
|
+
node
|
|
120
|
+
rescue Source::NodeLocator::OutOfRangeError => e
|
|
121
|
+
@err.puts("type-of: #{e.message}")
|
|
122
|
+
:out_of_range
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_position_argument(argv)
|
|
126
|
+
case argv.size
|
|
127
|
+
when 1
|
|
128
|
+
parse_colon_form(argv[0])
|
|
129
|
+
when 3
|
|
130
|
+
decode_position(*argv)
|
|
131
|
+
else
|
|
132
|
+
@err.puts("type-of: expected FILE:LINE:COL or FILE LINE COL")
|
|
133
|
+
@err.puts(USAGE)
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_colon_form(arg)
|
|
139
|
+
parts = arg.split(":")
|
|
140
|
+
if parts.size < 3
|
|
141
|
+
@err.puts("type-of: expected FILE:LINE:COL, got #{arg.inspect}")
|
|
142
|
+
@err.puts(USAGE)
|
|
143
|
+
return nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
column = parts.pop
|
|
147
|
+
line = parts.pop
|
|
148
|
+
file = parts.join(":")
|
|
149
|
+
decode_position(file, line, column)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def decode_position(file, line, column)
|
|
153
|
+
[file, Integer(line, 10), Integer(column, 10)]
|
|
154
|
+
rescue ArgumentError
|
|
155
|
+
@err.puts("type-of: line and column must be integers")
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optionparser"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
class CLI
|
|
8
|
+
# Renders a `TypeOfCommand::Result` as either human-readable text or a
|
|
9
|
+
# machine-readable JSON document.
|
|
10
|
+
#
|
|
11
|
+
# The renderer is a separate concern from the command itself so that future
|
|
12
|
+
# output formats (sexp, lsp-style hover payloads, color decoration) can
|
|
13
|
+
# plug in without disturbing argument parsing or the inference call site.
|
|
14
|
+
class TypeOfRenderer
|
|
15
|
+
def initialize(out:)
|
|
16
|
+
@out = out
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(result, format:)
|
|
20
|
+
case format
|
|
21
|
+
when "text" then render_text(result)
|
|
22
|
+
when "json" then render_json(result)
|
|
23
|
+
else
|
|
24
|
+
raise OptionParser::InvalidArgument, "unsupported format: #{format}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def render_text(result)
|
|
31
|
+
@out.puts("#{result.file}:#{result.line}:#{result.column}")
|
|
32
|
+
@out.puts("node: #{result.node.class}")
|
|
33
|
+
@out.puts("type: #{result.type.describe}")
|
|
34
|
+
@out.puts("erased: #{result.type.erase_to_rbs}")
|
|
35
|
+
render_text_fallbacks(result)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_text_fallbacks(result)
|
|
39
|
+
tracer = result.tracer
|
|
40
|
+
return if tracer.nil?
|
|
41
|
+
|
|
42
|
+
if tracer.empty?
|
|
43
|
+
@out.puts("fallbacks: none")
|
|
44
|
+
else
|
|
45
|
+
@out.puts("fallbacks (#{tracer.size}):")
|
|
46
|
+
tracer.each { |event| @out.puts(" - #{format_fallback_text(event, result.file)}") }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_json(result)
|
|
51
|
+
payload = {
|
|
52
|
+
file: result.file,
|
|
53
|
+
line: result.line,
|
|
54
|
+
column: result.column,
|
|
55
|
+
node: result.node.class.name,
|
|
56
|
+
type: result.type.describe,
|
|
57
|
+
erased: result.type.erase_to_rbs
|
|
58
|
+
}
|
|
59
|
+
payload[:fallbacks] = result.tracer.map { |event| fallback_to_h(event) } if result.tracer
|
|
60
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def format_fallback_text(event, file)
|
|
64
|
+
"#{event.node_class} (#{event.family}) @ #{location_text(event.location, file)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def location_text(location, file)
|
|
68
|
+
return "<no location>" unless location.respond_to?(:start_line)
|
|
69
|
+
|
|
70
|
+
"#{file}:#{location.start_line}:#{location.start_column + 1}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fallback_to_h(event)
|
|
74
|
+
hash = {
|
|
75
|
+
node_class: event.node_class.name,
|
|
76
|
+
family: event.family,
|
|
77
|
+
inner_type: event.inner_type.describe
|
|
78
|
+
}
|
|
79
|
+
location = event.location
|
|
80
|
+
if location.respond_to?(:start_line)
|
|
81
|
+
hash[:line] = location.start_line
|
|
82
|
+
hash[:column] = location.start_column + 1
|
|
83
|
+
end
|
|
84
|
+
hash
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optionparser"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
require_relative "../environment"
|
|
7
|
+
require_relative "../inference/coverage_scanner"
|
|
8
|
+
require_relative "../scope"
|
|
9
|
+
require_relative "type_scan_renderer"
|
|
10
|
+
require_relative "type_scan_report"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
class CLI
|
|
14
|
+
# Executes the `rigor type-scan` command.
|
|
15
|
+
#
|
|
16
|
+
# The command walks every Prism node in one or more files, runs
|
|
17
|
+
# `Rigor::Scope#type_of` on each, and reports per-node-class coverage of
|
|
18
|
+
# the inference engine's directly recognized classes. It is the project's
|
|
19
|
+
# primary CI gate for tracking how much of an input source the engine can
|
|
20
|
+
# name without falling back to `Dynamic[Top]`.
|
|
21
|
+
class TypeScanCommand
|
|
22
|
+
USAGE = "Usage: rigor type-scan [options] PATH..."
|
|
23
|
+
|
|
24
|
+
LocatedEvent = Data.define(:file, :event)
|
|
25
|
+
|
|
26
|
+
def initialize(argv:, out:, err:)
|
|
27
|
+
@argv = argv
|
|
28
|
+
@out = out
|
|
29
|
+
@err = err
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Integer] CLI exit status.
|
|
33
|
+
def run
|
|
34
|
+
options = parse_options
|
|
35
|
+
paths = collect_paths(@argv)
|
|
36
|
+
return CLI::EXIT_USAGE if paths.nil?
|
|
37
|
+
return usage_error if paths.empty?
|
|
38
|
+
|
|
39
|
+
report = scan_paths(paths, options)
|
|
40
|
+
TypeScanRenderer.new(out: @out).render(report, format: options.fetch(:format))
|
|
41
|
+
determine_exit(report, options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def parse_options
|
|
47
|
+
options = { format: "text", limit: 10, show_recognized: false, threshold: nil }
|
|
48
|
+
|
|
49
|
+
parser = OptionParser.new do |opts|
|
|
50
|
+
opts.banner = USAGE
|
|
51
|
+
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
52
|
+
opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
|
|
53
|
+
options[:limit] = value
|
|
54
|
+
end
|
|
55
|
+
opts.on("--show-recognized", "Include classes with 0 unrecognized in the table") do
|
|
56
|
+
options[:show_recognized] = true
|
|
57
|
+
end
|
|
58
|
+
opts.on("--threshold=RATIO", Float, "Exit non-zero when unrecognized/visits > RATIO") do |value|
|
|
59
|
+
options[:threshold] = value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
parser.parse!(@argv)
|
|
63
|
+
|
|
64
|
+
options
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect_paths(args)
|
|
68
|
+
paths = []
|
|
69
|
+
args.each do |arg|
|
|
70
|
+
if File.directory?(arg)
|
|
71
|
+
paths.concat(Dir.glob(File.join(arg, "**/*.rb")))
|
|
72
|
+
elsif File.file?(arg)
|
|
73
|
+
paths << arg
|
|
74
|
+
else
|
|
75
|
+
@err.puts("type-scan: not a file or directory: #{arg}")
|
|
76
|
+
return nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
paths.uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def usage_error
|
|
83
|
+
@err.puts("type-scan: at least one path is required")
|
|
84
|
+
@err.puts(USAGE)
|
|
85
|
+
CLI::EXIT_USAGE
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def scan_paths(paths, options)
|
|
89
|
+
scope = Scope.empty(environment: project_environment)
|
|
90
|
+
scanner = Inference::CoverageScanner.new(scope: scope)
|
|
91
|
+
accumulator = ScanAccumulator.new
|
|
92
|
+
paths.each { |path| scan_one(path, scanner, accumulator) }
|
|
93
|
+
accumulator.to_report(paths, options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Builds a project-aware environment that auto-detects `<cwd>/sig`
|
|
97
|
+
# so calls scoped to the current project resolve through the
|
|
98
|
+
# local RBS tree. Phase 2a does not yet wire stdlib opt-in here;
|
|
99
|
+
# that lands when the configuration layer (`.rigor.yml`) gains an
|
|
100
|
+
# `rbs:` section.
|
|
101
|
+
def project_environment
|
|
102
|
+
Environment.for_project
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def scan_one(path, scanner, accumulator)
|
|
106
|
+
source = File.read(path)
|
|
107
|
+
parse_result = Prism.parse(source, filepath: path)
|
|
108
|
+
if parse_result.errors.any?
|
|
109
|
+
accumulator.record_parse_error(path, parse_result.errors)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
accumulator.absorb(path, scanner.scan(parse_result.value))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def determine_exit(report, options)
|
|
117
|
+
return 1 unless report.parse_errors.empty?
|
|
118
|
+
|
|
119
|
+
threshold = options[:threshold]
|
|
120
|
+
return 0 if threshold.nil?
|
|
121
|
+
|
|
122
|
+
report.unrecognized_ratio > threshold ? 1 : 0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Internal helper that accumulates per-file scan results into the
|
|
126
|
+
# totals carried by `Report`.
|
|
127
|
+
class ScanAccumulator
|
|
128
|
+
def initialize
|
|
129
|
+
@visits = Hash.new(0)
|
|
130
|
+
@unrecognized = Hash.new(0)
|
|
131
|
+
@events = []
|
|
132
|
+
@parse_errors = []
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def absorb(path, file_result)
|
|
136
|
+
file_result.visits.each { |klass, count| @visits[klass] += count }
|
|
137
|
+
file_result.unrecognized.each { |klass, count| @unrecognized[klass] += count }
|
|
138
|
+
file_result.events.each do |event|
|
|
139
|
+
@events << LocatedEvent.new(file: path, event: event)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def record_parse_error(path, errors)
|
|
144
|
+
@parse_errors << { file: path, errors: errors.map(&:message) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_report(paths, options)
|
|
148
|
+
Report.new(
|
|
149
|
+
files: paths,
|
|
150
|
+
parse_errors: @parse_errors,
|
|
151
|
+
visits: @visits,
|
|
152
|
+
unrecognized: @unrecognized,
|
|
153
|
+
events: @events,
|
|
154
|
+
options: options
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|