rigortype 0.1.3 → 0.1.4
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 +125 -31
- data/lib/rigor/analysis/check_rules.rb +10 -18
- data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
- data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
- data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
- data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
- data/lib/rigor/analysis/diagnostic.rb +0 -2
- data/lib/rigor/analysis/fact_store.rb +11 -3
- data/lib/rigor/analysis/rule_catalog.rb +2 -2
- data/lib/rigor/analysis/runner.rb +114 -3
- data/lib/rigor/builtins/imported_refinements.rb +360 -55
- data/lib/rigor/cache/descriptor.rb +1 -1
- data/lib/rigor/cache/store.rb +1 -1
- data/lib/rigor/cli/diff_command.rb +1 -1
- data/lib/rigor/cli/sig_gen_command.rb +173 -0
- data/lib/rigor/cli/type_of_command.rb +1 -1
- data/lib/rigor/cli/type_scan_renderer.rb +1 -1
- data/lib/rigor/cli/type_scan_report.rb +2 -2
- data/lib/rigor/cli.rb +9 -1
- data/lib/rigor/configuration/dependencies.rb +2 -2
- data/lib/rigor/configuration.rb +2 -2
- data/lib/rigor/environment.rb +35 -4
- data/lib/rigor/flow_contribution/conflict.rb +2 -2
- data/lib/rigor/flow_contribution/element.rb +1 -1
- data/lib/rigor/flow_contribution/fact.rb +1 -1
- data/lib/rigor/flow_contribution/merge_result.rb +1 -1
- data/lib/rigor/flow_contribution/merger.rb +3 -3
- data/lib/rigor/flow_contribution.rb +2 -2
- data/lib/rigor/inference/block_parameter_binder.rb +0 -2
- data/lib/rigor/inference/coverage_scanner.rb +1 -1
- data/lib/rigor/inference/expression_typer.rb +67 -11
- data/lib/rigor/inference/fallback.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
- data/lib/rigor/inference/method_dispatcher.rb +146 -2
- data/lib/rigor/inference/method_parameter_binder.rb +1 -3
- data/lib/rigor/inference/narrowing.rb +2 -4
- data/lib/rigor/inference/rbs_type_translator.rb +0 -2
- data/lib/rigor/inference/scope_indexer.rb +14 -9
- data/lib/rigor/inference/statement_evaluator.rb +7 -7
- data/lib/rigor/plugin/io_boundary.rb +0 -2
- data/lib/rigor/plugin/loader.rb +2 -2
- data/lib/rigor/plugin/manifest.rb +30 -9
- data/lib/rigor/plugin/registry.rb +11 -0
- data/lib/rigor/plugin/services.rb +1 -1
- data/lib/rigor/plugin/type_node_resolver.rb +52 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/reporter.rb +91 -0
- data/lib/rigor/rbs_extended.rb +131 -32
- data/lib/rigor/scope.rb +25 -8
- data/lib/rigor/sig_gen/classification.rb +36 -0
- data/lib/rigor/sig_gen/generator.rb +1048 -0
- data/lib/rigor/sig_gen/layout_index.rb +108 -0
- data/lib/rigor/sig_gen/method_candidate.rb +62 -0
- data/lib/rigor/sig_gen/observation_collector.rb +391 -0
- data/lib/rigor/sig_gen/observed_call.rb +62 -0
- data/lib/rigor/sig_gen/path_mapper.rb +116 -0
- data/lib/rigor/sig_gen/renderer.rb +157 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
- data/lib/rigor/sig_gen/write_result.rb +48 -0
- data/lib/rigor/sig_gen/writer.rb +530 -0
- data/lib/rigor/sig_gen.rb +25 -0
- data/lib/rigor/type/bound_method.rb +79 -0
- data/lib/rigor/type/combinator.rb +195 -2
- data/lib/rigor/type/constant.rb +13 -0
- data/lib/rigor/type/hash_shape.rb +0 -2
- data/lib/rigor/type/union.rb +20 -1
- data/lib/rigor/type.rb +1 -0
- data/lib/rigor/type_node/generic.rb +62 -0
- data/lib/rigor/type_node/identifier.rb +30 -0
- data/lib/rigor/type_node/indexed_access.rb +41 -0
- data/lib/rigor/type_node/integer_literal.rb +29 -0
- data/lib/rigor/type_node/name_scope.rb +52 -0
- data/lib/rigor/type_node/resolver_chain.rb +56 -0
- data/lib/rigor/type_node/string_literal.rb +29 -0
- data/lib/rigor/type_node/symbol_literal.rb +28 -0
- data/lib/rigor/type_node/union.rb +42 -0
- data/lib/rigor/type_node.rb +29 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
- data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
- data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
- data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
- data/sig/rigor/cli/diff_command.rbs +4 -0
- data/sig/rigor/cli/explain_command.rbs +4 -0
- data/sig/rigor/cli/sig_gen_command.rbs +4 -0
- data/sig/rigor/cli/type_scan_command.rbs +3 -0
- data/sig/rigor/environment.rbs +5 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
- data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
- data/sig/rigor/inference/builtins.rbs +2 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -0
- data/sig/rigor/plugin/base.rbs +6 -0
- data/sig/rigor/plugin/fact_store.rbs +11 -0
- data/sig/rigor/plugin/io_boundary.rbs +4 -0
- data/sig/rigor/plugin/load_error.rbs +6 -0
- data/sig/rigor/plugin/loader.rbs +20 -0
- data/sig/rigor/plugin/manifest.rbs +9 -0
- data/sig/rigor/plugin/registry.rbs +3 -0
- data/sig/rigor/plugin/services.rbs +3 -0
- data/sig/rigor/plugin/trust_policy.rbs +4 -0
- data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
- data/sig/rigor/plugin.rbs +8 -0
- data/sig/rigor/scope.rbs +4 -2
- data/sig/rigor/type.rbs +28 -6
- metadata +52 -1
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../configuration"
|
|
6
|
+
require_relative "../environment"
|
|
7
|
+
require_relative "../scope"
|
|
8
|
+
require_relative "../reflection"
|
|
9
|
+
require_relative "../type"
|
|
10
|
+
require_relative "../inference/scope_indexer"
|
|
11
|
+
require_relative "../inference/rbs_type_translator"
|
|
12
|
+
|
|
13
|
+
module Rigor
|
|
14
|
+
module SigGen
|
|
15
|
+
# Core generator for `rigor sig-gen` (ADR-14 slice 1 — MVP).
|
|
16
|
+
#
|
|
17
|
+
# Walks every `.rb` file under the input paths, builds a
|
|
18
|
+
# per-node scope index via {Rigor::Inference::ScopeIndexer},
|
|
19
|
+
# finds every `Prism::DefNode` whose enclosing class is
|
|
20
|
+
# nameable, types the body's last expression to derive an
|
|
21
|
+
# inferred return, looks up the project's existing RBS
|
|
22
|
+
# declaration (if any), and emits one {MethodCandidate} per
|
|
23
|
+
# def.
|
|
24
|
+
#
|
|
25
|
+
# The MVP keeps the scope deliberately narrow:
|
|
26
|
+
# - Only instance methods inside a `class` / `module` body
|
|
27
|
+
# are considered. Top-level / DSL-block / singleton defs
|
|
28
|
+
# are skipped (`sig.skipped.complex-shape`).
|
|
29
|
+
# - Parameter signatures are hard-coded to `untyped` per
|
|
30
|
+
# ADR-14 § "Robustness principle compliance" clause 2;
|
|
31
|
+
# `--params=observed` arrives in slice 3.
|
|
32
|
+
# - Optional / rest / keyword / block params disqualify the
|
|
33
|
+
# def (`sig.skipped.complex-shape`).
|
|
34
|
+
# - A `Dynamic[top]` inferred return becomes
|
|
35
|
+
# `sig.skipped.untyped-return` — emitting `untyped` would
|
|
36
|
+
# obscure rather than help.
|
|
37
|
+
# - Tighter-return detection compares the RBS-erased
|
|
38
|
+
# spellings only when the existing declared return
|
|
39
|
+
# strictly accepts the inferred one (acceptance check
|
|
40
|
+
# under the engine's current `:gradual` mode; ADR-14
|
|
41
|
+
# reserves the eventual `:strict` mode).
|
|
42
|
+
class Generator # rubocop:disable Metrics/ClassLength
|
|
43
|
+
# @param configuration [Rigor::Configuration]
|
|
44
|
+
# @param paths [Array<String>] files / directories to scan.
|
|
45
|
+
# @param observations [Hash{[String, Symbol] => Array<Array<Rigor::Type>>}]
|
|
46
|
+
# ADR-14 slice 3 — per-target-method arg-tuple observations
|
|
47
|
+
# produced by {ObservationCollector}. An empty Hash (the default)
|
|
48
|
+
# means "no observations available; emit `untyped` for every
|
|
49
|
+
# parameter position" per ADR-5 clause 2.
|
|
50
|
+
def initialize(configuration:, paths:, observations: {}, include_private: false)
|
|
51
|
+
@configuration = configuration
|
|
52
|
+
@paths = paths
|
|
53
|
+
@observations = normalize_observations(observations)
|
|
54
|
+
@include_private = include_private
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Lifts legacy plain-`Array[Type]` observation entries
|
|
58
|
+
# into {ObservedCall} carriers. Specs from the slice-3
|
|
59
|
+
# generation predate the carrier and pass observations
|
|
60
|
+
# as `{ [class, method] => [[type1, type2], ...] }`;
|
|
61
|
+
# the wrapper keeps those passing while internal code
|
|
62
|
+
# always sees the new shape.
|
|
63
|
+
def normalize_observations(map)
|
|
64
|
+
return map if map.empty?
|
|
65
|
+
|
|
66
|
+
map.transform_values { |entries| entries.map { |entry| ObservedCall.from(entry) } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Array<MethodCandidate>]
|
|
70
|
+
def run
|
|
71
|
+
@environment = build_environment
|
|
72
|
+
resolved = resolve_paths(@paths)
|
|
73
|
+
resolved.flat_map { |path| analyse_file(path, @environment) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def build_environment
|
|
79
|
+
Environment.for_project(
|
|
80
|
+
libraries: @configuration.libraries,
|
|
81
|
+
signature_paths: @configuration.signature_paths
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_paths(args)
|
|
86
|
+
args.flat_map do |arg|
|
|
87
|
+
if File.directory?(arg)
|
|
88
|
+
Dir.glob(File.join(arg, "**/*.rb"), sort: true)
|
|
89
|
+
elsif File.file?(arg) && arg.end_with?(".rb")
|
|
90
|
+
[arg]
|
|
91
|
+
else
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
end.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def analyse_file(path, environment)
|
|
98
|
+
source = File.read(path)
|
|
99
|
+
parse_result = Prism.parse(source, filepath: path, version: @configuration.target_ruby)
|
|
100
|
+
return [] if parse_result.errors.any?
|
|
101
|
+
|
|
102
|
+
base_scope = Scope.empty(environment: environment)
|
|
103
|
+
scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
|
|
104
|
+
|
|
105
|
+
@namespace_kinds = {}
|
|
106
|
+
@module_function_methods = Set.new
|
|
107
|
+
@class_shells = Set.new
|
|
108
|
+
defs = collect_method_definitions(parse_result.value)
|
|
109
|
+
candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
|
|
110
|
+
classify_def(path, def_node, class_name, kind, scope_index)
|
|
111
|
+
end
|
|
112
|
+
candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Walks the AST collecting `(def_node, class_name, kind)`
|
|
116
|
+
# tuples for every `def` Rigor can re-type. Slice 1
|
|
117
|
+
# covered instance `def foo` methods inside a nameable
|
|
118
|
+
# `class` / `module` body. Slice 4 extends this to
|
|
119
|
+
# singleton-side methods via `def self.foo` and
|
|
120
|
+
# `class << self; def foo; end`; top-level / DSL-block
|
|
121
|
+
# defs still degrade silently (no nameable receiver).
|
|
122
|
+
#
|
|
123
|
+
# ADR-14 gap-#3 follow-up tracks two extra pieces during
|
|
124
|
+
# the same walk so the Writer can emit kind-correct RBS
|
|
125
|
+
# without guessing:
|
|
126
|
+
#
|
|
127
|
+
# - `@namespace_kinds[qualified_name]` records whether
|
|
128
|
+
# each segment came from `class Foo` (`:class`) or
|
|
129
|
+
# `module Foo` (`:module`). Used by the writer's
|
|
130
|
+
# `wrap_in_modules` step to emit the right keyword for
|
|
131
|
+
# each intermediate segment AND the leaf.
|
|
132
|
+
# - `@module_function_methods` records `(class_name,
|
|
133
|
+
# method_name)` pairs where a `module_function` (no
|
|
134
|
+
# args) call preceded the `def` inside a module body.
|
|
135
|
+
# The renderer emits `def self?.name` for these, the
|
|
136
|
+
# RBS spelling that matches the dual instance +
|
|
137
|
+
# singleton dispatch the runtime produces.
|
|
138
|
+
def collect_method_definitions(root)
|
|
139
|
+
out = []
|
|
140
|
+
walk_defs(root, [], false, false, out)
|
|
141
|
+
out
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def walk_defs(node, prefix, in_singleton_class, module_function_active, out)
|
|
145
|
+
return unless node.is_a?(Prism::Node)
|
|
146
|
+
|
|
147
|
+
case node
|
|
148
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
149
|
+
return if descend_into_namespace?(node, prefix, out)
|
|
150
|
+
when Prism::SingletonClassNode
|
|
151
|
+
if node.expression.is_a?(Prism::SelfNode) && node.body
|
|
152
|
+
walk_defs(node.body, prefix, true, false, out)
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
when Prism::DefNode
|
|
156
|
+
collect_def_node(node, prefix, in_singleton_class, module_function_active, out)
|
|
157
|
+
return
|
|
158
|
+
when Prism::ConstantWriteNode
|
|
159
|
+
register_data_struct_shell(node, prefix)
|
|
160
|
+
# fall through to recurse into the RHS so a trailing
|
|
161
|
+
# `do ... end` block carrying defs is still walked.
|
|
162
|
+
when Prism::StatementsNode
|
|
163
|
+
walk_statements(node, prefix, in_singleton_class, module_function_active, out)
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
node.compact_child_nodes.each do |child|
|
|
168
|
+
walk_defs(child, prefix, in_singleton_class, module_function_active, out)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def descend_into_namespace?(node, prefix, out)
|
|
173
|
+
name = qualified_constant_path(node.constant_path)
|
|
174
|
+
return false unless name
|
|
175
|
+
|
|
176
|
+
full = (prefix + [name]).join("::")
|
|
177
|
+
@namespace_kinds[full] = node.is_a?(Prism::ClassNode) ? :class : :module
|
|
178
|
+
walk_namespace_body(node, prefix + [name], out)
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ADR-14 gap-#3 (e): recognises
|
|
183
|
+
# `Const = Data.define(...)` and
|
|
184
|
+
# `Const = Struct.new(...)` as class declarations.
|
|
185
|
+
# The runtime side stamps a brand-new anonymous class
|
|
186
|
+
# at the RHS and binds it to `Const`, so the generated
|
|
187
|
+
# RBS needs an explicit `class Const` declaration even
|
|
188
|
+
# though no `class Const ... end` block appears in
|
|
189
|
+
# source. Without it, references to `Const` in return
|
|
190
|
+
# types fail to resolve under Steep (the canonical case
|
|
191
|
+
# is `GemResolver::Resolved | GemResolver::Unresolvable`
|
|
192
|
+
# where `Unresolvable = Data.define(:gem_name, :reason)`).
|
|
193
|
+
#
|
|
194
|
+
# The walker records the fully-qualified constant name
|
|
195
|
+
# in `@class_shells` (carried through to every
|
|
196
|
+
# candidate so the writer's tree-builder picks it up)
|
|
197
|
+
# AND in `@namespace_kinds` so the leaf's `class`
|
|
198
|
+
# keyword wins over the intermediate-segment `module`
|
|
199
|
+
# default.
|
|
200
|
+
def register_data_struct_shell(node, prefix)
|
|
201
|
+
return unless data_or_struct_call?(node.value)
|
|
202
|
+
|
|
203
|
+
full = (prefix + [node.name.to_s]).join("::")
|
|
204
|
+
@class_shells << full
|
|
205
|
+
@namespace_kinds[full] = :class
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
DATA_STRUCT_SHELL_HEADS = {
|
|
209
|
+
"Data" => :define,
|
|
210
|
+
"Struct" => :new
|
|
211
|
+
}.freeze
|
|
212
|
+
private_constant :DATA_STRUCT_SHELL_HEADS
|
|
213
|
+
|
|
214
|
+
def data_or_struct_call?(value)
|
|
215
|
+
return false unless value.is_a?(Prism::CallNode)
|
|
216
|
+
|
|
217
|
+
receiver = value.receiver
|
|
218
|
+
return false unless receiver.is_a?(Prism::ConstantReadNode)
|
|
219
|
+
|
|
220
|
+
DATA_STRUCT_SHELL_HEADS[receiver.name.to_s] == value.name
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Module / class bodies are walked through the
|
|
224
|
+
# `walk_statements` path so `module_function` (no-args)
|
|
225
|
+
# encountered as one statement applies to every
|
|
226
|
+
# subsequent sibling def in the same body. The
|
|
227
|
+
# directive is module-scoped semantically — classes
|
|
228
|
+
# inherit `module_function` via `Module`'s ancestor
|
|
229
|
+
# chain but don't honour it the same way at runtime, so
|
|
230
|
+
# tracking is only meaningful inside `ModuleNode`
|
|
231
|
+
# bodies. Generator emits `def self?.name` for the
|
|
232
|
+
# marked defs.
|
|
233
|
+
def walk_namespace_body(namespace_node, prefix, out)
|
|
234
|
+
return if namespace_node.body.nil?
|
|
235
|
+
|
|
236
|
+
walk_defs(namespace_node.body, prefix, false, false, out)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def walk_statements(stmts_node, prefix, in_singleton_class, module_function_active, out)
|
|
240
|
+
stmts_node.body.each do |stmt|
|
|
241
|
+
if module_function_directive?(stmt)
|
|
242
|
+
module_function_active = true
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
walk_defs(stmt, prefix, in_singleton_class, module_function_active, out)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def module_function_directive?(node)
|
|
250
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
251
|
+
return false unless node.name == :module_function && node.receiver.nil?
|
|
252
|
+
|
|
253
|
+
(node.arguments&.arguments || []).empty?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def collect_def_node(node, prefix, in_singleton_class, module_function_active, out)
|
|
257
|
+
return if prefix.empty?
|
|
258
|
+
|
|
259
|
+
kind = node.receiver.is_a?(Prism::SelfNode) || in_singleton_class ? :singleton : :instance
|
|
260
|
+
class_name = prefix.join("::")
|
|
261
|
+
@module_function_methods << [class_name, node.name] if module_function_active && kind == :instance
|
|
262
|
+
out << [node, class_name, kind]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Wraps `MethodCandidate.new` so every candidate carries
|
|
266
|
+
# the per-file `@namespace_kinds` map AND the
|
|
267
|
+
# `@class_shells` set — the Writer's nested-syntax
|
|
268
|
+
# emission consults both to pick `module` vs `class`
|
|
269
|
+
# for each segment and to emit empty
|
|
270
|
+
# `Const = Data.define(...)` declarations.
|
|
271
|
+
def build_candidate(**)
|
|
272
|
+
MethodCandidate.new(
|
|
273
|
+
namespace_kinds: @namespace_kinds || {},
|
|
274
|
+
class_shells: (@class_shells || Set.new).to_a,
|
|
275
|
+
**
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Returns "def self." (kind: :singleton),
|
|
280
|
+
# "def self?." (instance method declared inside a
|
|
281
|
+
# `module_function` region — both instance + singleton
|
|
282
|
+
# dispatch at runtime), or "def " (plain instance).
|
|
283
|
+
def method_def_prefix(class_name, method_name, kind)
|
|
284
|
+
return "def self." if kind == :singleton
|
|
285
|
+
return "def self?." if @module_function_methods&.include?([class_name, method_name])
|
|
286
|
+
|
|
287
|
+
"def "
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Slice-4 follow-up surfaced by the Rigor self-dogfood:
|
|
291
|
+
# most `lib/rigor/cli/*` files have a small public
|
|
292
|
+
# surface (`run`) and many private helpers. Emitting the
|
|
293
|
+
# private helpers into a `sig/` file is noise — private
|
|
294
|
+
# methods are implementation details, not part of the
|
|
295
|
+
# type contract downstream consumers (Steep, IDE, gem
|
|
296
|
+
# users) read. The default now skips private and
|
|
297
|
+
# protected methods; the `:include_private` flag
|
|
298
|
+
# restores the slice-4 behaviour for callers that want
|
|
299
|
+
# every method.
|
|
300
|
+
def visibility_excludes?(def_node, class_name, kind, scope_index)
|
|
301
|
+
return false if kind == :singleton
|
|
302
|
+
return false if @include_private
|
|
303
|
+
|
|
304
|
+
scope = scope_index[def_node] || scope_index.each_value.first
|
|
305
|
+
return false if scope.nil?
|
|
306
|
+
|
|
307
|
+
visibility = scope.discovered_method_visibility(class_name, def_node.name)
|
|
308
|
+
%i[private protected].include?(visibility)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Ruby's `initialize` return value is never meaningful;
|
|
312
|
+
# the conventional RBS spelling is `() -> void`. The
|
|
313
|
+
# body-typing path types the last expression (often an
|
|
314
|
+
# ivar assignment whose rvalue happens to be `[]` /
|
|
315
|
+
# `{}`), which produces nonsense return types.
|
|
316
|
+
#
|
|
317
|
+
# Skipping `initialize` entirely is correct ONLY for
|
|
318
|
+
# default constructors — the `Object#initialize: () -> void`
|
|
319
|
+
# RBS fallback then covers the lookup. When the class
|
|
320
|
+
# has a non-trivial `initialize(argv:, ...)` (i.e. any
|
|
321
|
+
# parameter), partial-class sigs trip Steep's
|
|
322
|
+
# method-parameter-mismatch check: Steep sees the
|
|
323
|
+
# runtime `def initialize(...)` and compares against
|
|
324
|
+
# the inherited `Object#initialize: () -> void`. The
|
|
325
|
+
# mismatch surfaces a `Ruby::MethodParameterMismatch`
|
|
326
|
+
# warning even when `rigor check` itself is clean.
|
|
327
|
+
#
|
|
328
|
+
# Returning `nil` here causes `classify_def` to skip
|
|
329
|
+
# emission; returning `:emit_stub` causes
|
|
330
|
+
# `initialize_stub_candidate` to emit a permissive
|
|
331
|
+
# `(<param shape>) -> void` stub matching the
|
|
332
|
+
# runtime parameter list.
|
|
333
|
+
def initialize_excludes?(def_node, kind)
|
|
334
|
+
return false unless kind == :instance
|
|
335
|
+
return false unless def_node.name == :initialize
|
|
336
|
+
|
|
337
|
+
# Default constructor with no params — skip; the
|
|
338
|
+
# Object#initialize RBS fallback covers it.
|
|
339
|
+
params = def_node.parameters
|
|
340
|
+
params.nil? || trivial_initialize_params?(params)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def trivial_initialize_params?(params)
|
|
344
|
+
return true unless params.is_a?(Prism::ParametersNode)
|
|
345
|
+
|
|
346
|
+
params.requireds.empty? && params.optionals.empty? &&
|
|
347
|
+
params.rest.nil? && params.keywords.empty? &&
|
|
348
|
+
params.keyword_rest.nil? && params.block.nil?
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def non_trivial_initialize?(def_node, kind)
|
|
352
|
+
kind == :instance && def_node.name == :initialize && !trivial_initialize_params?(def_node.parameters)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Emits `def initialize: (<shape>) -> void`. The return
|
|
356
|
+
# is always `void` because Ruby's `initialize` return
|
|
357
|
+
# value is never meaningful. The parameter list mirrors
|
|
358
|
+
# the runtime shape (required / optional / rest /
|
|
359
|
+
# keyword / keyword-rest / block).
|
|
360
|
+
#
|
|
361
|
+
# When `--params=observed` populates `@observations` for
|
|
362
|
+
# `[class_name, :initialize]` (via the
|
|
363
|
+
# `ObservationCollector`'s `.new` → `:initialize`
|
|
364
|
+
# routing), positional and keyword arg types come from
|
|
365
|
+
# the per-position / per-keyword union of observed
|
|
366
|
+
# types; otherwise every position keeps `untyped` per
|
|
367
|
+
# ADR-5 clause 2.
|
|
368
|
+
def initialize_stub_candidate(path, def_node, class_name)
|
|
369
|
+
rbs = "def initialize: (#{render_initialize_param_list(def_node.parameters, class_name)}) -> void"
|
|
370
|
+
build_candidate(
|
|
371
|
+
path: path, class_name: class_name, method_name: :initialize,
|
|
372
|
+
kind: :instance, classification: Classification::NEW_METHOD,
|
|
373
|
+
inferred_return: Type::Combinator.untyped, rbs: rbs
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def render_initialize_param_list(params, class_name)
|
|
378
|
+
return "" unless params.is_a?(Prism::ParametersNode)
|
|
379
|
+
|
|
380
|
+
observations = initialize_observations(class_name, params)
|
|
381
|
+
offset = 0
|
|
382
|
+
parts = []
|
|
383
|
+
|
|
384
|
+
params.requireds.each_with_index do |_, i|
|
|
385
|
+
parts << initialize_positional_type(observations, offset + i, "")
|
|
386
|
+
end
|
|
387
|
+
offset += params.requireds.size
|
|
388
|
+
|
|
389
|
+
params.optionals.each_with_index do |_, i|
|
|
390
|
+
parts << initialize_positional_type(observations, offset + i, "?")
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
parts << "*untyped" if params.rest
|
|
394
|
+
params.keywords.each { |kw| parts << render_keyword_param(kw, observations) }
|
|
395
|
+
parts << "**untyped" if params.keyword_rest
|
|
396
|
+
parts << "?{ (?) -> void }" if params.block
|
|
397
|
+
parts.join(", ")
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Picks observations under `[class_name, :initialize]`
|
|
401
|
+
# whose positional arity matches the def's accepted
|
|
402
|
+
# range (required..required+optional). Looser arities
|
|
403
|
+
# don't get used because they describe a different
|
|
404
|
+
# overload the stub cannot express.
|
|
405
|
+
def initialize_observations(class_name, params)
|
|
406
|
+
return [] if @observations.empty?
|
|
407
|
+
|
|
408
|
+
list = @observations[[class_name, :initialize]] || []
|
|
409
|
+
min = params.requireds.size
|
|
410
|
+
max = min + params.optionals.size
|
|
411
|
+
list.select { |obs| (min..max).cover?(obs.positional.size) }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def initialize_positional_type(observations, index, prefix)
|
|
415
|
+
types = observations.filter_map { |obs| obs.positional[index] }
|
|
416
|
+
"#{prefix}#{types.empty? ? 'untyped' : paren_wrap_union(union_erase(types))}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def render_keyword_param(keyword, observations)
|
|
420
|
+
optional_marker = keyword.is_a?(Prism::OptionalKeywordParameterNode) ? "?" : ""
|
|
421
|
+
types = observations.filter_map { |obs| obs.keyword[keyword.name] }
|
|
422
|
+
rendered = types.empty? ? "untyped" : paren_wrap_union(union_erase(types))
|
|
423
|
+
"#{optional_marker}#{keyword.name}: #{rendered}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def qualified_constant_path(constant_path)
|
|
427
|
+
case constant_path
|
|
428
|
+
when Prism::ConstantReadNode
|
|
429
|
+
constant_path.name.to_s
|
|
430
|
+
when Prism::ConstantPathNode
|
|
431
|
+
parent = qualified_constant_path(constant_path.parent) if constant_path.parent
|
|
432
|
+
name = constant_path.name&.to_s
|
|
433
|
+
return nil if name.nil?
|
|
434
|
+
|
|
435
|
+
parent ? "#{parent}::#{name}" : name
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def classify_def(path, def_node, class_name, kind, scope_index)
|
|
440
|
+
return nil if visibility_excludes?(def_node, class_name, kind, scope_index)
|
|
441
|
+
return nil if initialize_excludes?(def_node, kind)
|
|
442
|
+
return initialize_stub_candidate(path, def_node, class_name) if non_trivial_initialize?(def_node, kind)
|
|
443
|
+
|
|
444
|
+
unless simple_parameter_shape?(def_node.parameters)
|
|
445
|
+
return skipped(path, def_node, class_name, kind, :complex_shape)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
inferred = infer_return_type(def_node, scope_index)
|
|
449
|
+
return skipped(path, def_node, class_name, kind, :untyped_return) if inferred.nil? || dynamic_top?(inferred)
|
|
450
|
+
|
|
451
|
+
environment = scope_index[def_node]&.environment
|
|
452
|
+
method_def = lookup_existing_method(class_name, def_node.name, kind, environment, scope_index[def_node])
|
|
453
|
+
|
|
454
|
+
if method_def.nil?
|
|
455
|
+
new_method_candidate(path, def_node, class_name, kind, inferred)
|
|
456
|
+
else
|
|
457
|
+
compare_against_declared(path, def_node, class_name, kind, inferred, method_def)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Required positionals only; the MVP's body-typing path
|
|
462
|
+
# gives well-defined returns for that shape. Optional /
|
|
463
|
+
# rest / keyword / block parameters route through the
|
|
464
|
+
# `sig.skipped.complex-shape` reason until slices 3+
|
|
465
|
+
# widen the param policy.
|
|
466
|
+
def simple_parameter_shape?(params)
|
|
467
|
+
return true if params.nil?
|
|
468
|
+
return false unless params.is_a?(Prism::ParametersNode)
|
|
469
|
+
|
|
470
|
+
params.optionals.empty? &&
|
|
471
|
+
params.rest.nil? &&
|
|
472
|
+
params.keywords.empty? &&
|
|
473
|
+
params.keyword_rest.nil? &&
|
|
474
|
+
params.block.nil?
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Mirrors the `def.return-type-mismatch` rule's body-type
|
|
478
|
+
# extraction: type the implicit-return expression under
|
|
479
|
+
# the scope the indexer associated with the body. The
|
|
480
|
+
# parameter bindings (typed `untyped` per the indexer's
|
|
481
|
+
# default) come from `with_local` inside
|
|
482
|
+
# `StatementEvaluator`; the result is the carrier the
|
|
483
|
+
# body proves *given an untyped argument tuple*.
|
|
484
|
+
#
|
|
485
|
+
# Post-dogfood enhancement: walk the body's AST for
|
|
486
|
+
# explicit `return X` statements and union their value
|
|
487
|
+
# types with the implicit-return expression's type. The
|
|
488
|
+
# earlier MVP only typed the implicit-return path, which
|
|
489
|
+
# routinely produced single-branch artefacts like
|
|
490
|
+
# `parse_options: () -> nil` (the actual runtime return
|
|
491
|
+
# is `options | nil`) or `find: () -> V` (actually
|
|
492
|
+
# `V | nil` via `return nil unless ...`). The walk
|
|
493
|
+
# excludes nested `DefNode` / lambda / block scopes
|
|
494
|
+
# whose returns belong to different methods.
|
|
495
|
+
def infer_return_type(def_node, scope_index)
|
|
496
|
+
body = def_node.body
|
|
497
|
+
return nil if body.nil?
|
|
498
|
+
|
|
499
|
+
last = body_last_expression(body)
|
|
500
|
+
return nil if last.nil?
|
|
501
|
+
|
|
502
|
+
inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
|
|
503
|
+
return nil if inner_scope.nil?
|
|
504
|
+
|
|
505
|
+
last_type = inner_scope.type_of(last)
|
|
506
|
+
union_with_explicit_returns(body, last_type, scope_index)
|
|
507
|
+
rescue StandardError
|
|
508
|
+
nil
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def body_last_expression(body)
|
|
512
|
+
case body
|
|
513
|
+
when Prism::StatementsNode then body.body.last
|
|
514
|
+
when Prism::BeginNode then body_last_expression(body.statements)
|
|
515
|
+
else body
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def union_with_explicit_returns(body, last_type, scope_index)
|
|
520
|
+
return_types = []
|
|
521
|
+
collect_return_types(body, scope_index, return_types)
|
|
522
|
+
return last_type if return_types.empty?
|
|
523
|
+
|
|
524
|
+
Type::Combinator.union(last_type, *return_types)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
|
|
528
|
+
private_constant :RETURN_BARRIER_NODES
|
|
529
|
+
|
|
530
|
+
def collect_return_types(node, scope_index, out)
|
|
531
|
+
return unless node.is_a?(Prism::Node)
|
|
532
|
+
return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
533
|
+
|
|
534
|
+
type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
|
|
535
|
+
node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def type_return_node(return_node, scope_index, out)
|
|
539
|
+
args = return_node.arguments&.arguments || []
|
|
540
|
+
if args.empty?
|
|
541
|
+
out << Type::Combinator.constant_of(nil)
|
|
542
|
+
return
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
scope = scope_index[return_node] || scope_index[args.first]
|
|
546
|
+
return if scope.nil?
|
|
547
|
+
|
|
548
|
+
# `return a, b` packs into a Tuple at runtime; the MVP
|
|
549
|
+
# only handles the single-value form. Multi-arg returns
|
|
550
|
+
# contribute no type to keep the implementation
|
|
551
|
+
# focused.
|
|
552
|
+
return unless args.size == 1
|
|
553
|
+
|
|
554
|
+
type = safe_return_type_of(scope, args.first)
|
|
555
|
+
out << type unless type.nil?
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def safe_return_type_of(scope, node)
|
|
559
|
+
scope.type_of(node)
|
|
560
|
+
rescue StandardError
|
|
561
|
+
nil
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def dynamic_top?(type)
|
|
565
|
+
return true if type.is_a?(Type::Dynamic)
|
|
566
|
+
return true if type.respond_to?(:top?) && type.top?.yes?
|
|
567
|
+
|
|
568
|
+
# Post-dogfood: when explicit-return union absorbs
|
|
569
|
+
# Dynamic and the carrier ends up as a Union containing
|
|
570
|
+
# `Dynamic[top]`, the Bug-1 erasure rule renders it as
|
|
571
|
+
# `untyped`. Emitting `def m: () -> untyped` is the
|
|
572
|
+
# `sig.skipped.untyped-return` case — obscures rather
|
|
573
|
+
# than helps — so the skip check considers the erased
|
|
574
|
+
# form too.
|
|
575
|
+
type.respond_to?(:erase_to_rbs) && type.erase_to_rbs == "untyped"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def lookup_existing_method(class_name, method_name, kind, environment, scope)
|
|
579
|
+
return nil if environment.nil?
|
|
580
|
+
|
|
581
|
+
if kind == :singleton
|
|
582
|
+
Reflection.singleton_method_definition(class_name, method_name, scope: scope, environment: environment)
|
|
583
|
+
else
|
|
584
|
+
Reflection.instance_method_definition(class_name, method_name, scope: scope, environment: environment)
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def new_method_candidate(path, def_node, class_name, kind, inferred)
|
|
589
|
+
build_candidate(
|
|
590
|
+
path: path,
|
|
591
|
+
class_name: class_name,
|
|
592
|
+
method_name: def_node.name,
|
|
593
|
+
kind: kind,
|
|
594
|
+
classification: Classification::NEW_METHOD,
|
|
595
|
+
inferred_return: inferred,
|
|
596
|
+
rbs: render_rbs_line(def_node, inferred, class_name, kind)
|
|
597
|
+
)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def compare_against_declared(path, def_node, class_name, kind, inferred, method_def)
|
|
601
|
+
declared = build_declared_return(method_def)
|
|
602
|
+
declared_rbs = declared&.erase_to_rbs
|
|
603
|
+
inferred_rbs = inferred.erase_to_rbs
|
|
604
|
+
|
|
605
|
+
if declared.nil? || declared_rbs == inferred_rbs
|
|
606
|
+
return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
unless tighter?(declared, inferred) && !computed_literal_tightening?(inferred, def_node)
|
|
610
|
+
return equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
build_candidate(
|
|
614
|
+
path: path,
|
|
615
|
+
class_name: class_name,
|
|
616
|
+
method_name: def_node.name,
|
|
617
|
+
kind: kind,
|
|
618
|
+
classification: Classification::TIGHTER_RETURN,
|
|
619
|
+
inferred_return: inferred,
|
|
620
|
+
declared_return_rbs: declared_rbs,
|
|
621
|
+
rbs: render_rbs_line(def_node, inferred, class_name, kind)
|
|
622
|
+
)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def build_declared_return(method_def)
|
|
626
|
+
translated = method_def.method_types.filter_map { |mt| translate_method_type_return(mt) }
|
|
627
|
+
return nil if translated.empty?
|
|
628
|
+
|
|
629
|
+
translated.size == 1 ? translated.first : Type::Combinator.union(*translated)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def translate_method_type_return(method_type)
|
|
633
|
+
Inference::RbsTypeTranslator.translate(
|
|
634
|
+
method_type.type.return_type,
|
|
635
|
+
self_type: nil, instance_type: nil, type_vars: {}
|
|
636
|
+
)
|
|
637
|
+
rescue StandardError
|
|
638
|
+
nil
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# ADR-14 § "What 'more precise' means". The MVP uses the
|
|
642
|
+
# engine's gradual-mode acceptance — `:strict` is
|
|
643
|
+
# reserved by `Inference::Acceptance` and lands in a
|
|
644
|
+
# follow-up. The "different spelling" guard ensures we
|
|
645
|
+
# never classify a same-string round-trip as tighter.
|
|
646
|
+
#
|
|
647
|
+
# The `loses_declared_union_member?` guard added after
|
|
648
|
+
# the Rigor self-dogfood pass refuses to classify as
|
|
649
|
+
# tighter-return when the declared form is a top-level
|
|
650
|
+
# Union and the inferred form collapses one or more of
|
|
651
|
+
# its declared members. The body-typing path in slice 1
|
|
652
|
+
# only inspects the implicit-return expression, so
|
|
653
|
+
# methods with `return nil unless ...` / boolean
|
|
654
|
+
# `false | true` shapes / `Float | Integer` numeric
|
|
655
|
+
# alternates routinely look "tighter" while actually
|
|
656
|
+
# dropping reachable branches. Treating those as
|
|
657
|
+
# equivalent matches the project rule that an
|
|
658
|
+
# inferred tightening contradicting an existing RBS
|
|
659
|
+
# member set is suspected incomplete inference until
|
|
660
|
+
# proven otherwise.
|
|
661
|
+
def tighter?(declared, inferred)
|
|
662
|
+
return false if inferred.is_a?(Type::Dynamic)
|
|
663
|
+
return false if loses_declared_lenience?(declared, inferred)
|
|
664
|
+
|
|
665
|
+
forward = declared.accepts(inferred)
|
|
666
|
+
return false unless forward.yes?
|
|
667
|
+
|
|
668
|
+
backward = inferred.accepts(declared)
|
|
669
|
+
!backward.yes?
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
# Composite guard: refuse to classify as tighter-return
|
|
673
|
+
# when the declared RBS expresses lenience that the
|
|
674
|
+
# inferred form removes. Three cases all signal
|
|
675
|
+
# incomplete inference rather than precision gain:
|
|
676
|
+
#
|
|
677
|
+
# 1. Top-level union losing one or more declared
|
|
678
|
+
# members. `return nil unless ...` paths, two-valued
|
|
679
|
+
# booleans, `Float | Integer` numeric alternates.
|
|
680
|
+
# 2. Generic collection narrowed to a fixed shape.
|
|
681
|
+
# `Array[T]` → `Tuple[T, ...]`, `Hash[K, V]` →
|
|
682
|
+
# HashShape — the body's last expression was a
|
|
683
|
+
# literal whose specific shape is not the method's
|
|
684
|
+
# contract.
|
|
685
|
+
# 3. `untyped` type-arg replaced by a concrete form.
|
|
686
|
+
# Declared `Hash[String, untyped]` carries the
|
|
687
|
+
# author's intentional value-type lenience; the
|
|
688
|
+
# inference's narrower Union should not override
|
|
689
|
+
# it.
|
|
690
|
+
def loses_declared_lenience?(declared, inferred)
|
|
691
|
+
loses_declared_union_member?(declared, inferred) ||
|
|
692
|
+
narrows_collection_to_shape?(declared, inferred) ||
|
|
693
|
+
replaces_untyped_type_arg?(declared, inferred)
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def loses_declared_union_member?(declared, inferred)
|
|
697
|
+
return false unless declared.is_a?(Type::Union)
|
|
698
|
+
|
|
699
|
+
inferred_members = inferred.is_a?(Type::Union) ? inferred.members : [inferred]
|
|
700
|
+
declared.members.any? do |declared_member|
|
|
701
|
+
inferred_members.none? { |im| structurally_covers?(im, declared_member) }
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def structurally_covers?(inferred_member, declared_member)
|
|
706
|
+
return true if inferred_member == declared_member
|
|
707
|
+
|
|
708
|
+
result = inferred_member.accepts(declared_member)
|
|
709
|
+
result.respond_to?(:yes?) && result.yes?
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
GENERIC_COLLECTION_CLASSES = %w[
|
|
713
|
+
Array Hash Set Range Enumerable Enumerator Enumerator::Lazy
|
|
714
|
+
].freeze
|
|
715
|
+
private_constant :GENERIC_COLLECTION_CLASSES
|
|
716
|
+
|
|
717
|
+
def narrows_collection_to_shape?(declared, inferred)
|
|
718
|
+
return false unless declared.is_a?(Type::Nominal)
|
|
719
|
+
return false unless GENERIC_COLLECTION_CLASSES.include?(declared.class_name)
|
|
720
|
+
|
|
721
|
+
inferred.is_a?(Type::Tuple) || inferred.is_a?(Type::HashShape)
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Heuristic added after the third-round self-dogfood:
|
|
725
|
+
# `FallbackTracer#size` body is `@events.size`, where
|
|
726
|
+
# `@events` is initialised to `[]` and never assigned
|
|
727
|
+
# again at the class-ivar pre-pass level. The
|
|
728
|
+
# `Type::Tuple[]` (size 0) folds `.size` to
|
|
729
|
+
# `Constant<0>` — the carrier knows the empty-tuple
|
|
730
|
+
# cardinality exactly. But the runtime contract is
|
|
731
|
+
# `Integer` because callers add events through other
|
|
732
|
+
# methods. The signal is "the body's last expression
|
|
733
|
+
# is NOT a directly-authored literal but the inferred
|
|
734
|
+
# type IS a Constant"; in that case the precision
|
|
735
|
+
# came from inference over an internal computation,
|
|
736
|
+
# not the author's contract, so refuse to tighten.
|
|
737
|
+
def computed_literal_tightening?(inferred, def_node)
|
|
738
|
+
return false unless inferred.is_a?(Type::Constant)
|
|
739
|
+
|
|
740
|
+
last = body_last_expression(def_node.body)
|
|
741
|
+
!direct_literal_node?(last)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
DIRECT_LITERAL_NODE_TYPES = [
|
|
745
|
+
Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode,
|
|
746
|
+
Prism::TrueNode, Prism::FalseNode, Prism::NilNode
|
|
747
|
+
].freeze
|
|
748
|
+
private_constant :DIRECT_LITERAL_NODE_TYPES
|
|
749
|
+
|
|
750
|
+
def direct_literal_node?(node)
|
|
751
|
+
DIRECT_LITERAL_NODE_TYPES.any? { |klass| node.is_a?(klass) }
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def replaces_untyped_type_arg?(declared, inferred)
|
|
755
|
+
return false unless declared.is_a?(Type::Nominal) && inferred.is_a?(Type::Nominal)
|
|
756
|
+
return false unless declared.class_name == inferred.class_name
|
|
757
|
+
return false unless declared.type_args.size == inferred.type_args.size
|
|
758
|
+
|
|
759
|
+
declared.type_args.zip(inferred.type_args).any? do |d_arg, i_arg|
|
|
760
|
+
d_arg.is_a?(Type::Dynamic) && !i_arg.is_a?(Type::Dynamic)
|
|
761
|
+
end
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def equivalent(path, def_node, class_name, kind, inferred, declared_rbs)
|
|
765
|
+
build_candidate(
|
|
766
|
+
path: path,
|
|
767
|
+
class_name: class_name,
|
|
768
|
+
method_name: def_node.name,
|
|
769
|
+
kind: kind,
|
|
770
|
+
classification: Classification::EQUIVALENT,
|
|
771
|
+
inferred_return: inferred,
|
|
772
|
+
declared_return_rbs: declared_rbs
|
|
773
|
+
)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
def skipped(path, def_node, class_name, kind, reason)
|
|
777
|
+
build_candidate(
|
|
778
|
+
path: path,
|
|
779
|
+
class_name: class_name,
|
|
780
|
+
method_name: def_node.name,
|
|
781
|
+
kind: kind,
|
|
782
|
+
classification: Classification::SKIPPED,
|
|
783
|
+
skip_reason: reason
|
|
784
|
+
)
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def render_rbs_line(def_node, inferred, class_name, kind)
|
|
788
|
+
arity = required_arity(def_node)
|
|
789
|
+
head = arity.zero? ? "()" : "(#{render_param_list(class_name, def_node.name, arity)})"
|
|
790
|
+
prefix = method_def_prefix(class_name, def_node.name, kind)
|
|
791
|
+
"#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred))}"
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Routes the inferred carrier through {TypeElaborator}
|
|
795
|
+
# so bare generic nominals (`Array` / `Hash` / `Set`
|
|
796
|
+
# / `Range` / `Enumerable`) get their `untyped` type
|
|
797
|
+
# parameters filled in before erasing to RBS. The
|
|
798
|
+
# elaborator consults the class's RBS-declared
|
|
799
|
+
# type-parameter list via `Reflection.class_type_param_names`.
|
|
800
|
+
def elaborated_rbs(type)
|
|
801
|
+
TypeElaborator.elaborate(type, environment: @environment).erase_to_rbs
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# RBS / Steep require return-position unions to be
|
|
805
|
+
# parenthesised when they appear bare at the top
|
|
806
|
+
# level of a method type — `def m: () -> 0 | 1` fails
|
|
807
|
+
# the parser because the trailing `| 1` isn't a valid
|
|
808
|
+
# method-type start. Wrap when the erased form is a
|
|
809
|
+
# top-level union; single types and already-bracketed
|
|
810
|
+
# forms (e.g. `Array[A | B]`) parse without wrapping.
|
|
811
|
+
def paren_wrap_union(rendered)
|
|
812
|
+
top_level_union?(rendered) ? "(#{rendered})" : rendered
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def top_level_union?(rendered)
|
|
816
|
+
return false unless rendered.include?(" | ")
|
|
817
|
+
|
|
818
|
+
depth = 0
|
|
819
|
+
rendered.each_char.with_index do |ch, i|
|
|
820
|
+
case ch
|
|
821
|
+
when "(", "[", "{" then depth += 1
|
|
822
|
+
when ")", "]", "}" then depth -= 1
|
|
823
|
+
when " "
|
|
824
|
+
return true if depth.zero? && rendered[i + 1] == "|"
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
false
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def required_arity(def_node)
|
|
831
|
+
params = def_node.parameters
|
|
832
|
+
params.is_a?(Prism::ParametersNode) ? params.requireds.size : 0
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Per ADR-5 clause 2 the default is `untyped` for every
|
|
836
|
+
# position. Observed-policy callers (`--params=observed`)
|
|
837
|
+
# pass an `observations:` map at construction time; the
|
|
838
|
+
# generator unions per-position arg types whose tuple
|
|
839
|
+
# arity matches the def's required-positional count.
|
|
840
|
+
# Observations from arities other than the def's count
|
|
841
|
+
# are discarded — they describe a different overload
|
|
842
|
+
# the MVP does not emit.
|
|
843
|
+
def render_param_list(class_name, method_name, arity)
|
|
844
|
+
tuples = matching_observations(class_name, method_name, arity)
|
|
845
|
+
return Array.new(arity, "untyped").join(", ") if tuples.empty?
|
|
846
|
+
|
|
847
|
+
Array.new(arity) { |i| union_erase(tuples.map { |obs| obs.positional[i] }) }.join(", ")
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def matching_observations(class_name, method_name, arity)
|
|
851
|
+
return [] if @observations.empty?
|
|
852
|
+
|
|
853
|
+
list = @observations[[class_name, method_name]] || []
|
|
854
|
+
list.select { |obs| obs.positional.size == arity }
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def union_erase(types)
|
|
858
|
+
return "untyped" if types.empty?
|
|
859
|
+
return elaborated_rbs(types.first) if types.size == 1
|
|
860
|
+
|
|
861
|
+
# `Type::Combinator.union` dedupes by structural type
|
|
862
|
+
# equality. The carrier-level `erase_to_rbs` now
|
|
863
|
+
# absorbs `untyped` members and dedupes the post-erase
|
|
864
|
+
# strings (`String | String` → `String` for distinct
|
|
865
|
+
# `Constant<"Alice">` / `Constant<"Bob">` envelopes),
|
|
866
|
+
# so the sig-gen layer only needs to elaborate bare
|
|
867
|
+
# generics before erasing.
|
|
868
|
+
elaborated_rbs(Type::Combinator.union(*types))
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# ADR-14 slice 4 — `attr_reader` / `attr_writer` /
|
|
872
|
+
# `attr_accessor` recognition. Each Symbol-named entry in
|
|
873
|
+
# the call's argument list yields one or two
|
|
874
|
+
# {MethodCandidate}s whose inferred return type is the
|
|
875
|
+
# corresponding instance-variable's accumulated type from
|
|
876
|
+
# `Scope#class_ivars_for(class_name)`. `attr_reader` adds
|
|
877
|
+
# one reader candidate; `attr_writer` adds one
|
|
878
|
+
# `name=`-method writer candidate; `attr_accessor` adds
|
|
879
|
+
# both.
|
|
880
|
+
ATTR_METHOD_NAMES = %i[attr_reader attr_writer attr_accessor].freeze
|
|
881
|
+
private_constant :ATTR_METHOD_NAMES
|
|
882
|
+
|
|
883
|
+
ATTR_KINDS = {
|
|
884
|
+
attr_reader: [:reader],
|
|
885
|
+
attr_writer: [:writer],
|
|
886
|
+
attr_accessor: %i[reader writer]
|
|
887
|
+
}.freeze
|
|
888
|
+
private_constant :ATTR_KINDS
|
|
889
|
+
|
|
890
|
+
# Per-file context the attr_* walker threads through its
|
|
891
|
+
# recursive descent. Keeps parameter lists in check.
|
|
892
|
+
AttrWalkContext = Struct.new(:path, :scope_index, :out, keyword_init: true)
|
|
893
|
+
private_constant :AttrWalkContext
|
|
894
|
+
|
|
895
|
+
def collect_attr_candidates(root, path, scope_index)
|
|
896
|
+
ctx = AttrWalkContext.new(path: path, scope_index: scope_index, out: [])
|
|
897
|
+
walk_attr_calls(root, [], false, ctx)
|
|
898
|
+
ctx.out
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def walk_attr_calls(node, prefix, in_singleton_class, ctx)
|
|
902
|
+
return unless node.is_a?(Prism::Node)
|
|
903
|
+
|
|
904
|
+
case node
|
|
905
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
906
|
+
name = qualified_constant_path(node.constant_path)
|
|
907
|
+
if name
|
|
908
|
+
walk_attr_calls(node.body, prefix + [name], false, ctx) if node.body
|
|
909
|
+
return
|
|
910
|
+
end
|
|
911
|
+
when Prism::SingletonClassNode
|
|
912
|
+
walk_attr_calls(node.body, prefix, true, ctx) if node.body
|
|
913
|
+
return
|
|
914
|
+
when Prism::DefNode
|
|
915
|
+
# Skip method bodies — attr_* there would refer to
|
|
916
|
+
# whatever the method is doing dynamically, not a
|
|
917
|
+
# class-level declaration.
|
|
918
|
+
return
|
|
919
|
+
when Prism::CallNode
|
|
920
|
+
collect_attr_call(node, prefix, in_singleton_class, ctx)
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
node.compact_child_nodes.each { |child| walk_attr_calls(child, prefix, in_singleton_class, ctx) }
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def collect_attr_call(call_node, prefix, in_singleton_class, ctx)
|
|
927
|
+
return unless ATTR_METHOD_NAMES.include?(call_node.name)
|
|
928
|
+
return if prefix.empty?
|
|
929
|
+
return if in_singleton_class
|
|
930
|
+
|
|
931
|
+
class_name = prefix.join("::")
|
|
932
|
+
symbol_names = extract_symbol_arguments(call_node)
|
|
933
|
+
return if symbol_names.empty?
|
|
934
|
+
|
|
935
|
+
ivar_lookup = ivar_type_lookup(ctx.scope_index, class_name)
|
|
936
|
+
symbol_names.each do |attr_name|
|
|
937
|
+
ivar_type = ivar_lookup.call(attr_name)
|
|
938
|
+
ctx.out.concat(build_attr_candidates(call_node.name, class_name, attr_name, ivar_type, ctx))
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def extract_symbol_arguments(call_node)
|
|
943
|
+
(call_node.arguments&.arguments || []).filter_map do |arg|
|
|
944
|
+
arg.unescaped.to_sym if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode)
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Returns a closure that looks up `:@<attr_name>` in the
|
|
949
|
+
# class-ivar accumulator carried by the first scope the
|
|
950
|
+
# indexer associated with this file. The accumulator is
|
|
951
|
+
# populated by `ScopeIndexer#build_class_ivar_index`
|
|
952
|
+
# before any statement evaluation runs, so the lookup
|
|
953
|
+
# works even when attr_* declarations come before the
|
|
954
|
+
# corresponding ivar writes lexically.
|
|
955
|
+
def ivar_type_lookup(scope_index, class_name)
|
|
956
|
+
any_scope = scope_index.each_value.first
|
|
957
|
+
return ->(_) {} if any_scope.nil?
|
|
958
|
+
|
|
959
|
+
ivars = any_scope.class_ivars_for(class_name)
|
|
960
|
+
->(attr_name) { ivars[:"@#{attr_name}"] }
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
def build_attr_candidates(call_name, class_name, attr_name, ivar_type, ctx)
|
|
964
|
+
ATTR_KINDS.fetch(call_name).flat_map do |variant|
|
|
965
|
+
method_name = variant == :writer ? :"#{attr_name}=" : attr_name
|
|
966
|
+
candidate = build_attr_candidate(class_name, method_name, variant, ivar_type, ctx)
|
|
967
|
+
candidate ? [candidate] : []
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
def build_attr_candidate(class_name, method_name, variant, ivar_type, ctx)
|
|
972
|
+
if ivar_type.nil? || dynamic_top?(ivar_type)
|
|
973
|
+
return attr_skipped(ctx.path, class_name, method_name, :untyped_return)
|
|
974
|
+
end
|
|
975
|
+
|
|
976
|
+
scope = ctx.scope_index.each_value.first
|
|
977
|
+
environment = scope&.environment
|
|
978
|
+
method_def = lookup_existing_method(class_name, method_name, :instance, environment, scope)
|
|
979
|
+
if method_def.nil?
|
|
980
|
+
attr_new_candidate(ctx.path, class_name, method_name, variant, ivar_type)
|
|
981
|
+
else
|
|
982
|
+
attr_compare_against_declared(ctx.path, class_name, method_name, variant, ivar_type, method_def)
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
def attr_new_candidate(path, class_name, method_name, variant, ivar_type)
|
|
987
|
+
build_candidate(
|
|
988
|
+
path: path,
|
|
989
|
+
class_name: class_name,
|
|
990
|
+
method_name: method_name,
|
|
991
|
+
kind: :instance,
|
|
992
|
+
classification: Classification::NEW_METHOD,
|
|
993
|
+
inferred_return: ivar_type,
|
|
994
|
+
rbs: render_attr_rbs_line(method_name, variant, ivar_type)
|
|
995
|
+
)
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def attr_compare_against_declared(path, class_name, method_name, variant, ivar_type, method_def)
|
|
999
|
+
declared = build_declared_return(method_def)
|
|
1000
|
+
declared_rbs = declared&.erase_to_rbs
|
|
1001
|
+
inferred_rbs = ivar_type.erase_to_rbs
|
|
1002
|
+
|
|
1003
|
+
if declared.nil? || declared_rbs == inferred_rbs || !tighter?(declared, ivar_type)
|
|
1004
|
+
return attr_equivalent(path, class_name, method_name, ivar_type, declared_rbs)
|
|
1005
|
+
end
|
|
1006
|
+
|
|
1007
|
+
build_candidate(
|
|
1008
|
+
path: path, class_name: class_name, method_name: method_name,
|
|
1009
|
+
kind: :instance, classification: Classification::TIGHTER_RETURN,
|
|
1010
|
+
inferred_return: ivar_type, declared_return_rbs: declared_rbs,
|
|
1011
|
+
rbs: render_attr_rbs_line(method_name, variant, ivar_type)
|
|
1012
|
+
)
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def attr_equivalent(path, class_name, method_name, ivar_type, declared_rbs)
|
|
1016
|
+
build_candidate(
|
|
1017
|
+
path: path, class_name: class_name, method_name: method_name,
|
|
1018
|
+
kind: :instance, classification: Classification::EQUIVALENT,
|
|
1019
|
+
inferred_return: ivar_type, declared_return_rbs: declared_rbs
|
|
1020
|
+
)
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def attr_skipped(path, class_name, method_name, reason)
|
|
1024
|
+
build_candidate(
|
|
1025
|
+
path: path, class_name: class_name, method_name: method_name,
|
|
1026
|
+
kind: :instance, classification: Classification::SKIPPED, skip_reason: reason
|
|
1027
|
+
)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
# Slice 4 emits attr_* in the long-form `def` spelling so
|
|
1031
|
+
# the existing writer's `MethodDefinition`-based merge
|
|
1032
|
+
# path applies without extra wiring. Users who prefer the
|
|
1033
|
+
# idiomatic `attr_reader name: Type` short form can
|
|
1034
|
+
# normalise post-emit; the writer-side member detection
|
|
1035
|
+
# (slice 2) treats existing `attr_*` declarations as
|
|
1036
|
+
# user-authored so a paired source-side `attr_reader`
|
|
1037
|
+
# never produces a duplicate `def` insertion.
|
|
1038
|
+
def render_attr_rbs_line(method_name, variant, ivar_type)
|
|
1039
|
+
erased = elaborated_rbs(ivar_type)
|
|
1040
|
+
wrapped = paren_wrap_union(erased)
|
|
1041
|
+
case variant
|
|
1042
|
+
when :reader then "def #{method_name}: () -> #{wrapped}"
|
|
1043
|
+
when :writer then "def #{method_name}: (#{erased}) -> #{wrapped}"
|
|
1044
|
+
end
|
|
1045
|
+
end
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
end
|