rigortype 0.1.8 → 0.1.10
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 +4 -4
- data/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +42 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# ADR-28 declaration: "every instance/singleton method named
|
|
6
|
+
# `method_name`, defined in a source file matching `path_glob`,
|
|
7
|
+
# is implicitly required to satisfy the declared parameter +
|
|
8
|
+
# return-type protocol."
|
|
9
|
+
#
|
|
10
|
+
# Authored on a plugin manifest:
|
|
11
|
+
#
|
|
12
|
+
# manifest(
|
|
13
|
+
# id: "web",
|
|
14
|
+
# version: "0.1.0",
|
|
15
|
+
# protocol_contracts: [
|
|
16
|
+
# Rigor::Plugin::ProtocolContract.new(
|
|
17
|
+
# path_glob: "lib/controller/**/*.rb",
|
|
18
|
+
# method_name: :get,
|
|
19
|
+
# param_types: [{ index: 0, type_name: "Rack::Request" }],
|
|
20
|
+
# return_type_name: "Rack::Response"
|
|
21
|
+
# )
|
|
22
|
+
# ]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# The contract drives two distinct engine behaviours (ADR-28
|
|
26
|
+
# § "provide-and-check"):
|
|
27
|
+
#
|
|
28
|
+
# - **provide** — when the inference engine binds the parameter
|
|
29
|
+
# list of a matching `def`, {Rigor::Inference::MethodParameterBinder}
|
|
30
|
+
# substitutes the declared `param_types` for the usual
|
|
31
|
+
# `Dynamic[Top]` fallback, so the method body is analysed as
|
|
32
|
+
# if the parameter carried its protocol type.
|
|
33
|
+
# - **check** — the contributing plugin's `#diagnostics_for_file`
|
|
34
|
+
# hook confirms the method exists and its inferred return type
|
|
35
|
+
# conforms to `return_type_name`.
|
|
36
|
+
#
|
|
37
|
+
# ## Fields
|
|
38
|
+
#
|
|
39
|
+
# - `path_glob` — `File.fnmatch` glob (String) selecting the
|
|
40
|
+
# source files the contract applies to, relative to the
|
|
41
|
+
# analysed project root (e.g. `"lib/controller/**/*.rb"`).
|
|
42
|
+
# - `method_name` — Symbol; the instance (or singleton) method
|
|
43
|
+
# the contract constrains.
|
|
44
|
+
# - `singleton` — Boolean; `true` constrains `def self.<name>`,
|
|
45
|
+
# `false` (default) constrains instance methods.
|
|
46
|
+
# - `param_types` — Array of `ParamType` (positional index →
|
|
47
|
+
# fully-qualified type name). The type names resolve against
|
|
48
|
+
# the analysed project's environment lazily, at consumption
|
|
49
|
+
# time, so the contract value object stays independent of
|
|
50
|
+
# environment construction order.
|
|
51
|
+
# - `return_type_name` — fully-qualified type name (String) the
|
|
52
|
+
# method's inferred return type must conform to.
|
|
53
|
+
# - `severity` — Symbol diagnostic severity for contract
|
|
54
|
+
# violations (`:error` default).
|
|
55
|
+
#
|
|
56
|
+
# ## Ractor-shareability
|
|
57
|
+
#
|
|
58
|
+
# Every field is frozen at construction (ADR-15 Phase 1); the
|
|
59
|
+
# nested `ParamType` is a frozen `Data`. `Ractor.shareable?`
|
|
60
|
+
# returns true after `#initialize`, so the contract survives
|
|
61
|
+
# `Plugin::Registry.materialize` into a worker Ractor.
|
|
62
|
+
class ProtocolContract
|
|
63
|
+
VALID_SEVERITIES = %i[error warning info].freeze
|
|
64
|
+
|
|
65
|
+
# One positional-parameter provision: the zero-based index of
|
|
66
|
+
# the parameter and the fully-qualified name of the type it
|
|
67
|
+
# carries under the protocol.
|
|
68
|
+
ParamType = Data.define(:index, :type_name)
|
|
69
|
+
|
|
70
|
+
attr_reader :path_glob, :method_name, :singleton, :param_types, :return_type_name, :severity
|
|
71
|
+
|
|
72
|
+
def initialize(path_glob:, method_name:, return_type_name: nil, param_types: [], singleton: false,
|
|
73
|
+
severity: :error)
|
|
74
|
+
validate_path_glob!(path_glob)
|
|
75
|
+
validate_method_name!(method_name)
|
|
76
|
+
validate_return_type_name!(return_type_name)
|
|
77
|
+
validate_severity!(severity)
|
|
78
|
+
|
|
79
|
+
@path_glob = path_glob.dup.freeze
|
|
80
|
+
@method_name = method_name.to_sym
|
|
81
|
+
@singleton = singleton ? true : false
|
|
82
|
+
@param_types = coerce_param_types(param_types)
|
|
83
|
+
@return_type_name = return_type_name.nil? ? nil : return_type_name.dup.freeze
|
|
84
|
+
@severity = severity.to_sym
|
|
85
|
+
freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns a copy with `path_glob` replaced. Plugins use this to
|
|
89
|
+
# honour a per-project config override of the convention path
|
|
90
|
+
# without rebuilding the whole contract by hand.
|
|
91
|
+
def with_path_glob(glob)
|
|
92
|
+
ProtocolContract.new(
|
|
93
|
+
path_glob: glob,
|
|
94
|
+
method_name: method_name,
|
|
95
|
+
return_type_name: return_type_name,
|
|
96
|
+
param_types: param_types.map { |pt| { index: pt.index, type_name: pt.type_name } },
|
|
97
|
+
singleton: singleton,
|
|
98
|
+
severity: severity
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_h
|
|
103
|
+
{
|
|
104
|
+
"path_glob" => path_glob,
|
|
105
|
+
"method_name" => method_name.to_s,
|
|
106
|
+
"singleton" => singleton,
|
|
107
|
+
"param_types" => param_types.map { |pt| { "index" => pt.index, "type_name" => pt.type_name } },
|
|
108
|
+
"return_type_name" => return_type_name,
|
|
109
|
+
"severity" => severity.to_s
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ==(other)
|
|
114
|
+
other.is_a?(ProtocolContract) && to_h == other.to_h
|
|
115
|
+
end
|
|
116
|
+
alias eql? ==
|
|
117
|
+
|
|
118
|
+
def hash
|
|
119
|
+
to_h.hash
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def validate_path_glob!(value)
|
|
125
|
+
return if value.is_a?(String) && !value.empty?
|
|
126
|
+
|
|
127
|
+
raise ArgumentError,
|
|
128
|
+
"Plugin::ProtocolContract#path_glob must be a non-empty String, got #{value.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validate_method_name!(value)
|
|
132
|
+
return if value.is_a?(Symbol) || (value.is_a?(String) && !value.empty?)
|
|
133
|
+
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"Plugin::ProtocolContract#method_name must be a Symbol/non-empty String, got #{value.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_return_type_name!(value)
|
|
139
|
+
return if value.nil?
|
|
140
|
+
return if value.is_a?(String) && !value.empty?
|
|
141
|
+
|
|
142
|
+
raise ArgumentError,
|
|
143
|
+
"Plugin::ProtocolContract#return_type_name must be a non-empty String or nil, got #{value.inspect}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_severity!(value)
|
|
147
|
+
return if VALID_SEVERITIES.include?(value.to_sym)
|
|
148
|
+
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
|
|
151
|
+
rescue NoMethodError
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"Plugin::ProtocolContract#severity must be one of #{VALID_SEVERITIES.inspect}, got #{value.inspect}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def coerce_param_types(param_types)
|
|
157
|
+
unless param_types.is_a?(Array)
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"Plugin::ProtocolContract#param_types must be an Array, got #{param_types.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
param_types.map { |entry| coerce_param_type(entry) }.freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def coerce_param_type(entry)
|
|
166
|
+
return entry if entry.is_a?(ParamType)
|
|
167
|
+
|
|
168
|
+
unless entry.is_a?(Hash)
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"Plugin::ProtocolContract param_types entry must be a Hash or ParamType, got #{entry.inspect}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
index = entry[:index] || entry["index"]
|
|
174
|
+
type_name = entry[:type_name] || entry["type_name"]
|
|
175
|
+
unless index.is_a?(Integer) && index >= 0 && type_name.is_a?(String) && !type_name.empty?
|
|
176
|
+
raise ArgumentError,
|
|
177
|
+
"Plugin::ProtocolContract param_types entry needs an Integer index >= 0 and a " \
|
|
178
|
+
"non-empty String type_name, got #{entry.inspect}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
ParamType.new(index: index, type_name: type_name.dup.freeze)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -104,7 +104,94 @@ module Rigor
|
|
|
104
104
|
Inference::HktRegistry.new(registrations: registrations, definitions: definitions)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# ADR-25 — flat, ordered list of every loaded plugin's
|
|
108
|
+
# resolved RBS signature directories (absolute paths), in
|
|
109
|
+
# plugin registration order. `Environment.for_project`
|
|
110
|
+
# merges these into the signature-path set fed to
|
|
111
|
+
# `RbsLoader`, alongside the configuration's `signature_paths:`
|
|
112
|
+
# and the `bundler:` / `rbs_collection:` discovery output.
|
|
113
|
+
def signature_paths
|
|
114
|
+
plugins.flat_map(&:signature_paths)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ADR-26 — the aggregate set of "open" receiver class names
|
|
118
|
+
# declared across loaded plugins (manifest `open_receivers:`).
|
|
119
|
+
# A class is open when a plugin vouches that it responds
|
|
120
|
+
# beyond its RBS-declared method surface. `open_receiver?`
|
|
121
|
+
# is the membership predicate `Analysis::CheckRules` consults
|
|
122
|
+
# to skip the `call.undefined-method` rule for such a class.
|
|
123
|
+
def open_receivers
|
|
124
|
+
plugins.flat_map { |plugin| plugin.manifest.open_receivers }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def open_receiver?(class_name)
|
|
128
|
+
return false if class_name.nil?
|
|
129
|
+
|
|
130
|
+
open_receivers.include?(class_name.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ADR-28 — flat, ordered list of every loaded plugin's
|
|
134
|
+
# path-scoped method-protocol contracts, in plugin
|
|
135
|
+
# registration order. Read from each plugin's
|
|
136
|
+
# `#protocol_contracts` (which the manifest backs by default
|
|
137
|
+
# but a plugin MAY override to fold in per-project config).
|
|
138
|
+
# Consumed by `Inference::MethodParameterBinder` (the
|
|
139
|
+
# parameter-type provision) and by contributing plugins'
|
|
140
|
+
# `#diagnostics_for_file` hooks (the presence + return-type
|
|
141
|
+
# check).
|
|
142
|
+
def protocol_contracts
|
|
143
|
+
plugins.flat_map(&:protocol_contracts)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ADR-28 — the subset of `protocol_contracts` whose
|
|
147
|
+
# `path_glob` matches `path`. Contract globs are authored
|
|
148
|
+
# project-root-relative (`lib/controller/**/*.rb`); the
|
|
149
|
+
# analyzer may hand this method either a project-relative
|
|
150
|
+
# path (`rigor check` run from the project root) or an
|
|
151
|
+
# absolute one (run from elsewhere, or a spec tmpdir), so the
|
|
152
|
+
# glob is matched both directly and as a `**/`-prefixed path
|
|
153
|
+
# suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
|
|
154
|
+
# `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
|
|
155
|
+
# a nil path so the binder can call this unconditionally.
|
|
156
|
+
def contracts_for_path(path)
|
|
157
|
+
return [] if path.nil?
|
|
158
|
+
|
|
159
|
+
path_s = path.to_s
|
|
160
|
+
protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ADR-32 WD4 + WD5 — flat ordered list of
|
|
164
|
+
# `[plugin, callable]` pairs for every loaded plugin that
|
|
165
|
+
# declares a `source_rbs_synthesizer:` in its manifest. The
|
|
166
|
+
# engine invokes each callable once per analysed Ruby source
|
|
167
|
+
# file at env-build time; non-nil return strings are merged
|
|
168
|
+
# into the RBS environment as virtual signature sources.
|
|
169
|
+
# The full plugin instance is carried alongside the
|
|
170
|
+
# callable so the engine's cache layer (WD5) can compose
|
|
171
|
+
# `plugin.plugin_entry` into its per-file descriptor — a
|
|
172
|
+
# config change to the plugin (e.g. flipping
|
|
173
|
+
# `require_magic_comment:`) invalidates the dependent
|
|
174
|
+
# synthesizer cache without any plugin-side bookkeeping.
|
|
175
|
+
def source_rbs_synthesizers
|
|
176
|
+
plugins.filter_map do |plugin|
|
|
177
|
+
synthesizer = plugin.manifest.source_rbs_synthesizer
|
|
178
|
+
next nil if synthesizer.nil?
|
|
179
|
+
|
|
180
|
+
[plugin, synthesizer]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
185
|
+
private_constant :FNMATCH_FLAGS
|
|
186
|
+
|
|
107
187
|
EMPTY = new.freeze
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def path_matches_glob?(glob, path)
|
|
192
|
+
File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
|
|
193
|
+
File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
|
|
194
|
+
end
|
|
108
195
|
end
|
|
109
196
|
end
|
|
110
197
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# ADR-32 WD6 — per-run accumulator for failures encountered by
|
|
6
|
+
# a plugin's `Manifest#source_rbs_synthesizer` callable. The
|
|
7
|
+
# synthesizer returns `[:error, message]` on parse failure
|
|
8
|
+
# (per its contract); `Environment.for_project` routes the
|
|
9
|
+
# tuple through `#record` here. `Analysis::Runner` queries
|
|
10
|
+
# `#entries` after analysis and emits one
|
|
11
|
+
# `source-rbs-synthesis-failed` `:info` diagnostic per
|
|
12
|
+
# entry so the user sees which files contributed nothing
|
|
13
|
+
# and why.
|
|
14
|
+
#
|
|
15
|
+
# Empty by default. The Runner only emits diagnostics when
|
|
16
|
+
# at least one entry is recorded — projects without
|
|
17
|
+
# synthesizer-emitting plugins pay zero cost.
|
|
18
|
+
#
|
|
19
|
+
# Thread-/Ractor-safety: this reporter is per-`WorkerSession`
|
|
20
|
+
# in pool mode, so concurrent writes from one Ractor's
|
|
21
|
+
# `collect_virtual_rbs` loop are serialised by the worker
|
|
22
|
+
# body itself. The sequential path shares a single reporter
|
|
23
|
+
# across the run; entries are appended one at a time during
|
|
24
|
+
# env build (before any per-file analysis runs), so no
|
|
25
|
+
# locking is needed.
|
|
26
|
+
class SourceRbsSynthesisReporter
|
|
27
|
+
Entry = Data.define(:plugin_id, :path, :message)
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@entries = []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record(plugin_id:, path:, message:)
|
|
34
|
+
@entries << Entry.new(
|
|
35
|
+
plugin_id: plugin_id.to_s.dup.freeze,
|
|
36
|
+
path: path.to_s.dup.freeze,
|
|
37
|
+
message: message.to_s.dup.freeze
|
|
38
|
+
)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def entries
|
|
43
|
+
@entries.dup.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def empty?
|
|
47
|
+
@entries.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "../environment"
|
|
|
7
7
|
require_relative "../scope"
|
|
8
8
|
require_relative "../reflection"
|
|
9
9
|
require_relative "../type"
|
|
10
|
+
require_relative "../inference/def_return_typer"
|
|
10
11
|
require_relative "../inference/scope_indexer"
|
|
11
12
|
require_relative "../inference/rbs_type_translator"
|
|
12
13
|
|
|
@@ -118,7 +119,8 @@ module Rigor
|
|
|
118
119
|
candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
|
|
119
120
|
classify_def(path, def_node, class_name, kind, scope_index)
|
|
120
121
|
end
|
|
121
|
-
|
|
122
|
+
obs_ivar_map = build_observed_ivar_map(parse_result.value)
|
|
123
|
+
candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
|
|
122
124
|
end
|
|
123
125
|
|
|
124
126
|
# Walks the AST collecting `(def_node, class_name, kind)`
|
|
@@ -501,73 +503,11 @@ module Rigor
|
|
|
501
503
|
# `V | nil` via `return nil unless ...`). The walk
|
|
502
504
|
# excludes nested `DefNode` / lambda / block scopes
|
|
503
505
|
# whose returns belong to different methods.
|
|
506
|
+
# Delegates to {Rigor::Inference::DefReturnTyper} — the same
|
|
507
|
+
# body-typing + explicit-return-union the `rigor annotate`
|
|
508
|
+
# def-line annotator uses.
|
|
504
509
|
def infer_return_type(def_node, scope_index)
|
|
505
|
-
|
|
506
|
-
return nil if body.nil?
|
|
507
|
-
|
|
508
|
-
last = body_last_expression(body)
|
|
509
|
-
return nil if last.nil?
|
|
510
|
-
|
|
511
|
-
inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
|
|
512
|
-
return nil if inner_scope.nil?
|
|
513
|
-
|
|
514
|
-
last_type = inner_scope.type_of(last)
|
|
515
|
-
union_with_explicit_returns(body, last_type, scope_index)
|
|
516
|
-
rescue StandardError
|
|
517
|
-
nil
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
def body_last_expression(body)
|
|
521
|
-
case body
|
|
522
|
-
when Prism::StatementsNode then body.body.last
|
|
523
|
-
when Prism::BeginNode then body_last_expression(body.statements)
|
|
524
|
-
else body
|
|
525
|
-
end
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
def union_with_explicit_returns(body, last_type, scope_index)
|
|
529
|
-
return_types = []
|
|
530
|
-
collect_return_types(body, scope_index, return_types)
|
|
531
|
-
return last_type if return_types.empty?
|
|
532
|
-
|
|
533
|
-
Type::Combinator.union(last_type, *return_types)
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
|
|
537
|
-
private_constant :RETURN_BARRIER_NODES
|
|
538
|
-
|
|
539
|
-
def collect_return_types(node, scope_index, out)
|
|
540
|
-
return unless node.is_a?(Prism::Node)
|
|
541
|
-
return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
542
|
-
|
|
543
|
-
type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
|
|
544
|
-
node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
|
|
545
|
-
end
|
|
546
|
-
|
|
547
|
-
def type_return_node(return_node, scope_index, out)
|
|
548
|
-
args = return_node.arguments&.arguments || []
|
|
549
|
-
if args.empty?
|
|
550
|
-
out << Type::Combinator.constant_of(nil)
|
|
551
|
-
return
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
scope = scope_index[return_node] || scope_index[args.first]
|
|
555
|
-
return if scope.nil?
|
|
556
|
-
|
|
557
|
-
# `return a, b` packs into a Tuple at runtime; the MVP
|
|
558
|
-
# only handles the single-value form. Multi-arg returns
|
|
559
|
-
# contribute no type to keep the implementation
|
|
560
|
-
# focused.
|
|
561
|
-
return unless args.size == 1
|
|
562
|
-
|
|
563
|
-
type = safe_return_type_of(scope, args.first)
|
|
564
|
-
out << type unless type.nil?
|
|
565
|
-
end
|
|
566
|
-
|
|
567
|
-
def safe_return_type_of(scope, node)
|
|
568
|
-
scope.type_of(node)
|
|
569
|
-
rescue StandardError
|
|
570
|
-
nil
|
|
510
|
+
Inference::DefReturnTyper.call(def_node, scope_index)
|
|
571
511
|
end
|
|
572
512
|
|
|
573
513
|
def dynamic_top?(type)
|
|
@@ -746,7 +686,7 @@ module Rigor
|
|
|
746
686
|
def computed_literal_tightening?(inferred, def_node)
|
|
747
687
|
return false unless inferred.is_a?(Type::Constant)
|
|
748
688
|
|
|
749
|
-
last = body_last_expression(def_node.body)
|
|
689
|
+
last = Inference::DefReturnTyper.body_last_expression(def_node.body)
|
|
750
690
|
!direct_literal_node?(last)
|
|
751
691
|
end
|
|
752
692
|
|
|
@@ -898,11 +838,15 @@ module Rigor
|
|
|
898
838
|
|
|
899
839
|
# Per-file context the attr_* walker threads through its
|
|
900
840
|
# recursive descent. Keeps parameter lists in check.
|
|
901
|
-
|
|
841
|
+
# `obs_ivar_map` carries the observation-derived fallback types
|
|
842
|
+
# built by {#build_observed_ivar_map}; it is empty when sig-gen
|
|
843
|
+
# is invoked without `--params=observed`.
|
|
844
|
+
AttrWalkContext = Struct.new(:path, :scope_index, :obs_ivar_map, :out, keyword_init: true)
|
|
902
845
|
private_constant :AttrWalkContext
|
|
903
846
|
|
|
904
|
-
def collect_attr_candidates(root, path, scope_index)
|
|
905
|
-
ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
|
|
847
|
+
def collect_attr_candidates(root, path, scope_index, obs_ivar_map = {})
|
|
848
|
+
ctx = AttrWalkContext.new(path: path, scope_index: scope_index,
|
|
849
|
+
obs_ivar_map: obs_ivar_map, out: [])
|
|
906
850
|
walk_attr_calls(root, [], false, ctx)
|
|
907
851
|
ctx.out
|
|
908
852
|
end
|
|
@@ -941,7 +885,7 @@ module Rigor
|
|
|
941
885
|
symbol_names = extract_symbol_arguments(call_node)
|
|
942
886
|
return if symbol_names.empty?
|
|
943
887
|
|
|
944
|
-
ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name)
|
|
888
|
+
ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name, ctx.obs_ivar_map)
|
|
945
889
|
symbol_names.each do |attr_name|
|
|
946
890
|
ivar_type = ivar_lookup.call(attr_name)
|
|
947
891
|
ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
|
|
@@ -961,12 +905,143 @@ module Rigor
|
|
|
961
905
|
# before any statement evaluation runs, so the lookup
|
|
962
906
|
# works even when attr_* declarations come before the
|
|
963
907
|
# corresponding ivar writes lexically.
|
|
964
|
-
|
|
908
|
+
#
|
|
909
|
+
# When `obs_ivar_map` is non-empty (i.e. `--params=observed`
|
|
910
|
+
# was used), it acts as a fallback: if the ivar pre-pass
|
|
911
|
+
# resolved the type to `nil` or `Dynamic[top]` — typically
|
|
912
|
+
# because `@ivar = param` inside `initialize` typed the param
|
|
913
|
+
# as `untyped` — the observation-derived type is substituted.
|
|
914
|
+
# This lets `attr_reader :name` emit a concrete type when
|
|
915
|
+
# `ClassName.new("alice")` call sites are visible to the
|
|
916
|
+
# observation scan.
|
|
917
|
+
def ivar_type_lookup(scope_index, class_name, obs_ivar_map = {})
|
|
965
918
|
any_scope = scope_index.each_value.first
|
|
966
919
|
return ->(_) {} if any_scope.nil?
|
|
967
920
|
|
|
968
|
-
ivars
|
|
969
|
-
|
|
921
|
+
ivars = any_scope.class_ivars_for(class_name)
|
|
922
|
+
obs_ivars = obs_ivar_map[class_name] || {}
|
|
923
|
+
lambda do |attr_name|
|
|
924
|
+
type = ivars[:"@#{attr_name}"]
|
|
925
|
+
type.nil? || dynamic_top?(type) ? obs_ivars[attr_name] : type
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
# Build a { class_name => { attr_name_sym => Type } } map that
|
|
930
|
+
# records observation-derived types for ivars assigned directly
|
|
931
|
+
# from `def initialize` parameters. Only populated when
|
|
932
|
+
# `@observations` is non-empty (i.e. `--params=observed` was
|
|
933
|
+
# supplied). Matches the pattern `@ivar_name = param_name` where
|
|
934
|
+
# `param_name` is a required / optional positional or keyword
|
|
935
|
+
# parameter of `initialize`.
|
|
936
|
+
def build_observed_ivar_map(root)
|
|
937
|
+
return {} if @observations.empty?
|
|
938
|
+
|
|
939
|
+
result = {}
|
|
940
|
+
collect_init_ivar_obs(root, [], result)
|
|
941
|
+
result
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
def collect_init_ivar_obs(node, prefix, result)
|
|
945
|
+
return unless node.is_a?(Prism::Node)
|
|
946
|
+
|
|
947
|
+
case node
|
|
948
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
949
|
+
name = qualified_constant_path(node.constant_path)
|
|
950
|
+
if name
|
|
951
|
+
collect_init_ivar_obs(node.body, prefix + [name], result) if node.body
|
|
952
|
+
return
|
|
953
|
+
end
|
|
954
|
+
when Prism::DefNode
|
|
955
|
+
if node.name == :initialize && !prefix.empty?
|
|
956
|
+
class_name = prefix.join("::")
|
|
957
|
+
map = ivar_obs_from_initialize(class_name, node)
|
|
958
|
+
result[class_name] = (result[class_name] || {}).merge(map) unless map.empty?
|
|
959
|
+
end
|
|
960
|
+
return
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
node.compact_child_nodes.each { |c| collect_init_ivar_obs(c, prefix, result) }
|
|
964
|
+
end
|
|
965
|
+
|
|
966
|
+
# Derive { attr_name_sym => Type } for a single `def initialize`
|
|
967
|
+
# by matching `@ivar = param_name` assignments against the
|
|
968
|
+
# available `[class_name, :initialize]` observations.
|
|
969
|
+
def ivar_obs_from_initialize(class_name, def_node)
|
|
970
|
+
obs_list = @observations[[class_name, :initialize]]
|
|
971
|
+
return {} if obs_list.nil? || obs_list.empty?
|
|
972
|
+
return {} if def_node.body.nil? || def_node.parameters.nil?
|
|
973
|
+
|
|
974
|
+
param_index = build_init_param_index(def_node.parameters)
|
|
975
|
+
return {} if param_index.empty?
|
|
976
|
+
|
|
977
|
+
ivar_to_param = {}
|
|
978
|
+
scan_ivar_param_assignments(def_node.body, param_index.keys.to_set, ivar_to_param)
|
|
979
|
+
build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
# Map `{ ivar_name => param_name }` → `{ attr_name_sym => Type }`
|
|
983
|
+
# by looking up each param's observation types and unioning them.
|
|
984
|
+
def build_ivar_obs_type_map(ivar_to_param, param_index, obs_list)
|
|
985
|
+
ivar_to_param.filter_map do |ivar_name, param_name|
|
|
986
|
+
types = collect_param_obs_types(obs_list, param_name, param_index[param_name])
|
|
987
|
+
next if types.empty?
|
|
988
|
+
|
|
989
|
+
attr_name = ivar_name.to_s.delete_prefix("@").to_sym
|
|
990
|
+
[attr_name, types.reduce { |acc, t| Type::Combinator.union(acc, t) }]
|
|
991
|
+
end.to_h
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
# Collect observed argument types for a single parameter across all
|
|
995
|
+
# call-site observations. Returns an array of Type objects (may be empty).
|
|
996
|
+
def collect_param_obs_types(obs_list, param_name, param_info)
|
|
997
|
+
case param_info[:kind]
|
|
998
|
+
when :positional then obs_list.filter_map { |obs| obs.positional[param_info[:index]] }
|
|
999
|
+
when :keyword then obs_list.filter_map { |obs| obs.keyword[param_name] }
|
|
1000
|
+
else []
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Map param_name_sym → { kind: :positional, index: N } or
|
|
1005
|
+
# { kind: :keyword } for required / optional positionals and
|
|
1006
|
+
# required / optional keywords of a ParametersNode.
|
|
1007
|
+
def build_init_param_index(parameters)
|
|
1008
|
+
index = {}
|
|
1009
|
+
offset = 0
|
|
1010
|
+
|
|
1011
|
+
(parameters.requireds || []).each_with_index do |p, i|
|
|
1012
|
+
index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
|
|
1013
|
+
end
|
|
1014
|
+
offset += parameters.requireds&.size || 0
|
|
1015
|
+
|
|
1016
|
+
(parameters.optionals || []).each_with_index do |p, i|
|
|
1017
|
+
index[p.name] = { kind: :positional, index: offset + i } if p.respond_to?(:name)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
(parameters.keywords || []).each do |kw|
|
|
1021
|
+
index[kw.name] = { kind: :keyword } if kw.respond_to?(:name)
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
index
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# Walk a def body for direct `@ivar = local_var` assignments
|
|
1028
|
+
# where `local_var` is one of the listed parameter names.
|
|
1029
|
+
# Records ivar_name (Symbol with `@` prefix) → param_name.
|
|
1030
|
+
# Does not recurse into nested defs / classes / modules.
|
|
1031
|
+
def scan_ivar_param_assignments(node, param_names, result)
|
|
1032
|
+
return unless node.is_a?(Prism::Node)
|
|
1033
|
+
|
|
1034
|
+
if node.is_a?(Prism::InstanceVariableWriteNode) &&
|
|
1035
|
+
node.value.is_a?(Prism::LocalVariableReadNode) &&
|
|
1036
|
+
param_names.include?(node.value.name)
|
|
1037
|
+
result[node.name] ||= node.value.name
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
return if node.is_a?(Prism::DefNode) ||
|
|
1041
|
+
node.is_a?(Prism::ClassNode) ||
|
|
1042
|
+
node.is_a?(Prism::ModuleNode)
|
|
1043
|
+
|
|
1044
|
+
node.compact_child_nodes.each { |c| scan_ivar_param_assignments(c, param_names, result) }
|
|
970
1045
|
end
|
|
971
1046
|
|
|
972
1047
|
def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
|
|
@@ -122,8 +122,8 @@ module Rigor
|
|
|
122
122
|
diagnostic_count: matched.size,
|
|
123
123
|
summary: "undefined-method on core classes (#{top_methods(matched)}) — " \
|
|
124
124
|
"ActiveSupport monkey-patches these",
|
|
125
|
-
action: "
|
|
126
|
-
"
|
|
125
|
+
action: "Add rigor-activesupport-core-ext to `plugins:` in .rigor.yml " \
|
|
126
|
+
"(it is an RBS-bundle plugin — ADR-25)."
|
|
127
127
|
), matched]
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -256,6 +256,63 @@ module Rigor
|
|
|
256
256
|
refined.base.class_name == "String"
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# Returns true when `type` is statically known to be a
|
|
260
|
+
# non-empty String — i.e. its value can never be `""`.
|
|
261
|
+
# Used at String binary-operator dispatch sites to propagate
|
|
262
|
+
# the non-empty guarantee through `+` and `*`.
|
|
263
|
+
#
|
|
264
|
+
# - `Constant[s]` where `s != ""` — a concrete non-empty literal.
|
|
265
|
+
# - `Difference[Nominal[String], Constant[""]]` — the canonical
|
|
266
|
+
# `non-empty-string` carrier.
|
|
267
|
+
# - `Intersection[…]` — any member suffices (set-theoretic subset).
|
|
268
|
+
# - `Union[…]` — all members must qualify (the join may include "").
|
|
269
|
+
def non_empty_string_compatible?(type)
|
|
270
|
+
case type
|
|
271
|
+
when Constant then type.value.is_a?(String) && !type.value.empty?
|
|
272
|
+
when Difference then non_empty_string_difference?(type)
|
|
273
|
+
when Intersection then type.members.any? { |m| non_empty_string_compatible?(m) }
|
|
274
|
+
when Union then !type.members.empty? && type.members.all? { |m| non_empty_string_compatible?(m) }
|
|
275
|
+
else false
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def non_empty_string_difference?(diff)
|
|
280
|
+
return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "String"
|
|
281
|
+
return false unless diff.removed.is_a?(Constant)
|
|
282
|
+
|
|
283
|
+
diff.removed.value == ""
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Returns true when `type` is statically known to be a
|
|
287
|
+
# non-zero Integer — i.e. its value can never be `0`.
|
|
288
|
+
# Used at Integer arithmetic dispatch sites to propagate
|
|
289
|
+
# the non-zero guarantee through `*` and identity methods.
|
|
290
|
+
#
|
|
291
|
+
# - `Constant[n]` where `n != 0` — a concrete non-zero literal.
|
|
292
|
+
# - `Difference[Nominal[Integer], Constant[0]]` — the canonical
|
|
293
|
+
# `non-zero-int` carrier.
|
|
294
|
+
# - `IntegerRange` that does not cover 0 — both `positive-int`
|
|
295
|
+
# ([1,+∞)) and `negative-int` ([-∞,-1]) qualify.
|
|
296
|
+
# - `Intersection[…]` — any member suffices.
|
|
297
|
+
# - `Union[…]` — all members must qualify.
|
|
298
|
+
def non_zero_int_compatible?(type)
|
|
299
|
+
case type
|
|
300
|
+
when Constant then type.value.is_a?(Integer) && !type.value.zero?
|
|
301
|
+
when Difference then non_zero_int_difference?(type)
|
|
302
|
+
when IntegerRange then !type.covers?(0)
|
|
303
|
+
when Intersection then type.members.any? { |m| non_zero_int_compatible?(m) }
|
|
304
|
+
when Union then !type.members.empty? && type.members.all? { |m| non_zero_int_compatible?(m) }
|
|
305
|
+
else false
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def non_zero_int_difference?(diff)
|
|
310
|
+
return false unless diff.base.is_a?(Nominal) && diff.base.class_name == "Integer"
|
|
311
|
+
return false unless diff.removed.is_a?(Constant)
|
|
312
|
+
|
|
313
|
+
diff.removed.value.zero?
|
|
314
|
+
end
|
|
315
|
+
|
|
259
316
|
# Normalised intersection. Flattens nested Intersections,
|
|
260
317
|
# drops `Top` members, collapses to `Bot` if any member is
|
|
261
318
|
# `Bot`, deduplicates structurally-equal members, sorts the
|