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,530 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "rbs"
|
|
5
|
+
|
|
6
|
+
require_relative "classification"
|
|
7
|
+
require_relative "write_result"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module SigGen
|
|
11
|
+
# Applies a per-source-file group of {MethodCandidate}s to
|
|
12
|
+
# the target `.rbs` file under the project signature tree.
|
|
13
|
+
#
|
|
14
|
+
# ADR-14 slice 2: the writer parses the target with
|
|
15
|
+
# `RBS::Parser` to find the matching class declaration and
|
|
16
|
+
# inserts new method declarations just before the class's
|
|
17
|
+
# closing `end` keyword. Existing declarations are NEVER
|
|
18
|
+
# touched unless `--overwrite` is set AND the candidate's
|
|
19
|
+
# classification is `tighter-return`.
|
|
20
|
+
#
|
|
21
|
+
# The slice does NOT re-render the whole file through
|
|
22
|
+
# `RBS::Writer`. That would lose comments / blank-line
|
|
23
|
+
# formatting per upstream design; the byte-range insertion
|
|
24
|
+
# approach taken here preserves untouched declarations
|
|
25
|
+
# verbatim. Mixed hand-written + generated output inside
|
|
26
|
+
# the *same* class declaration may still lose trailing
|
|
27
|
+
# blank lines on the touched ranges; the `--diff` review
|
|
28
|
+
# surface from slice 1 is the user's escape hatch.
|
|
29
|
+
#
|
|
30
|
+
# Safety boundary: the writer ASSERTS the target lives
|
|
31
|
+
# inside the configured signature tree before touching the
|
|
32
|
+
# disk. Files outside that tree route through
|
|
33
|
+
# `WriteResult(action: :skipped_outside_sig_root)`; the
|
|
34
|
+
# caller decides whether to warn or fail.
|
|
35
|
+
class Writer # rubocop:disable Metrics/ClassLength
|
|
36
|
+
INDENT = " "
|
|
37
|
+
private_constant :INDENT
|
|
38
|
+
|
|
39
|
+
# Per-`update_existing` accumulator. The merge_class
|
|
40
|
+
# helper mutates `source` / `decls` / `applied` /
|
|
41
|
+
# `skipped` in place as each class is processed so the
|
|
42
|
+
# next class sees the latest byte positions.
|
|
43
|
+
MergeState = Struct.new(:source, :decls, :applied, :skipped, keyword_init: true)
|
|
44
|
+
private_constant :MergeState
|
|
45
|
+
|
|
46
|
+
def initialize(path_mapper:, overwrite: false)
|
|
47
|
+
@path_mapper = path_mapper
|
|
48
|
+
@overwrite = overwrite
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Process the full candidate list by resolving each
|
|
52
|
+
# candidate's target sig file via the path mapper (which
|
|
53
|
+
# may route consolidated-layout classes to existing
|
|
54
|
+
# files) and grouping candidates that share a target
|
|
55
|
+
# before writing.
|
|
56
|
+
#
|
|
57
|
+
# ADR-14 follow-up: this is the consolidated-layout
|
|
58
|
+
# entry point. The legacy `write(source_path, candidates)`
|
|
59
|
+
# below assumes all candidates share a target and
|
|
60
|
+
# remains for spec convenience.
|
|
61
|
+
#
|
|
62
|
+
# @param candidates [Array<MethodCandidate>]
|
|
63
|
+
# @return [Array<WriteResult>] one per target sig file.
|
|
64
|
+
def write_all(candidates)
|
|
65
|
+
emittable = candidates.select { |c| EMITTABLE.include?(c.classification) }
|
|
66
|
+
return [] if emittable.empty?
|
|
67
|
+
|
|
68
|
+
emittable.group_by { |c| @path_mapper.target_for(c.path, class_name: c.class_name) }
|
|
69
|
+
.map { |target, group| write_target(target, group) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param source_path [String]
|
|
73
|
+
# @param candidates [Array<MethodCandidate>] only
|
|
74
|
+
# emittable classifications (new-method /
|
|
75
|
+
# tighter-return) are honoured; the caller is
|
|
76
|
+
# responsible for filtering.
|
|
77
|
+
# @return [WriteResult]
|
|
78
|
+
def write(source_path, candidates)
|
|
79
|
+
emittable = candidates.select { |c| EMITTABLE.include?(c.classification) }
|
|
80
|
+
return WriteResult.new(source_path: source_path, target_path: nil, action: :noop) if emittable.empty?
|
|
81
|
+
|
|
82
|
+
target = @path_mapper.target_for(source_path, class_name: emittable.first.class_name)
|
|
83
|
+
write_target(target, emittable, source_path: source_path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Shared per-target write path used by both `#write` and
|
|
89
|
+
# `#write_all`. Picks a representative `source_path` for
|
|
90
|
+
# the {WriteResult} when multiple candidates merge into
|
|
91
|
+
# one target.
|
|
92
|
+
def write_target(target, candidates, source_path: nil)
|
|
93
|
+
source_path ||= candidates.first&.path
|
|
94
|
+
unless inside_sig_root?(target)
|
|
95
|
+
return WriteResult.new(source_path: source_path, target_path: target,
|
|
96
|
+
action: :skipped_outside_sig_root)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
target.exist? ? update_existing(source_path, target, candidates) : create_new(source_path, target, candidates)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
EMITTABLE = [Classification::NEW_METHOD, Classification::TIGHTER_RETURN].freeze
|
|
103
|
+
private_constant :EMITTABLE
|
|
104
|
+
|
|
105
|
+
def inside_sig_root?(target)
|
|
106
|
+
root = @path_mapper.sig_root_dir.realpath
|
|
107
|
+
target.expand_path.ascend.any? { |ancestor| realpath_or_nil(ancestor) == root }
|
|
108
|
+
rescue Errno::ENOENT
|
|
109
|
+
# The sig root doesn't exist yet; we'll create it
|
|
110
|
+
# alongside the target file. Allow this case.
|
|
111
|
+
target.expand_path.to_s.start_with?(@path_mapper.sig_root_dir.expand_path.to_s)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def realpath_or_nil(path)
|
|
115
|
+
path.realpath
|
|
116
|
+
rescue Errno::ENOENT
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def create_new(source_path, target, candidates)
|
|
121
|
+
FileUtils.mkdir_p(target.dirname)
|
|
122
|
+
target.write(render_new_file(candidates))
|
|
123
|
+
WriteResult.new(source_path: source_path, target_path: target,
|
|
124
|
+
action: :created, applied: candidates)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ADR-14 gap-#3 follow-up (c): when one candidate's
|
|
128
|
+
# `class_name` is a strict prefix of another's, emit a
|
|
129
|
+
# single nested tree instead of two flat sibling
|
|
130
|
+
# blocks. The third-round self-dogfood surfaced
|
|
131
|
+
# `Analysis::DependencySourceInference::GemResolver`
|
|
132
|
+
# containing `class Resolved < Data.define(...)` — the
|
|
133
|
+
# earlier `group_by(&:class_name)` flattened that into
|
|
134
|
+
# two top-level wraps (one for GemResolver's own
|
|
135
|
+
# methods, one for GemResolver::Resolved's), which
|
|
136
|
+
# Steep accepted but is not the canonical layout in
|
|
137
|
+
# this project's `sig/`.
|
|
138
|
+
#
|
|
139
|
+
# The writer now builds a tree keyed by qualified-name
|
|
140
|
+
# segments. Each tree node carries (qualified_name,
|
|
141
|
+
# methods, shells, children); rendering walks the tree
|
|
142
|
+
# so every nested class appears inside its parent's
|
|
143
|
+
# block. Empty class shells (gap-#3 follow-up (e), e.g.
|
|
144
|
+
# `Unresolvable = Data.define(...)`) participate as
|
|
145
|
+
# zero-method tree nodes — they emit `class Foo\nend`
|
|
146
|
+
# at their position.
|
|
147
|
+
def render_new_file(candidates)
|
|
148
|
+
shells = collect_class_shells(candidates)
|
|
149
|
+
tree = build_namespace_tree(candidates, shells)
|
|
150
|
+
kinds = merged_namespace_kinds(candidates)
|
|
151
|
+
render_tree_nodes(tree, kinds, 0)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Drains `class_shells` from every candidate; the
|
|
155
|
+
# generator's walker populates the same set on every
|
|
156
|
+
# candidate produced from a given file (gap-#3 (e)).
|
|
157
|
+
def collect_class_shells(candidates)
|
|
158
|
+
shells = Set.new
|
|
159
|
+
candidates.each { |c| shells.merge(c.class_shells) if c.respond_to?(:class_shells) }
|
|
160
|
+
shells
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def merged_namespace_kinds(candidates)
|
|
164
|
+
merged = {}
|
|
165
|
+
candidates.each do |c|
|
|
166
|
+
(c.namespace_kinds || {}).each { |k, v| merged[k] = v }
|
|
167
|
+
end
|
|
168
|
+
merged
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Tree node: { name:, children: Hash{String => node},
|
|
172
|
+
# methods: Array<MethodCandidate>, shell: Boolean }.
|
|
173
|
+
# `shell` flags nodes that came in via `class_shells`
|
|
174
|
+
# only (no methods of their own); rendering uses it to
|
|
175
|
+
# default the keyword to `:class` for the `class Const`
|
|
176
|
+
# = `Data.define(...)` case.
|
|
177
|
+
def build_namespace_tree(candidates, shells)
|
|
178
|
+
root = { name: nil, children: {}, methods: [], shell: false }
|
|
179
|
+
candidates.group_by(&:class_name).each do |class_name, methods|
|
|
180
|
+
insert_into_tree(root, class_name.split("::"), methods: methods)
|
|
181
|
+
end
|
|
182
|
+
shells.each { |name| insert_into_tree(root, name.split("::"), shell: true) }
|
|
183
|
+
root
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def insert_into_tree(node, segments, methods: nil, shell: false)
|
|
187
|
+
return if segments.empty?
|
|
188
|
+
|
|
189
|
+
head, *rest = segments
|
|
190
|
+
child = node[:children][head] ||= { name: head, children: {}, methods: [], shell: false }
|
|
191
|
+
if rest.empty?
|
|
192
|
+
child[:methods].concat(methods) if methods
|
|
193
|
+
child[:shell] ||= shell
|
|
194
|
+
else
|
|
195
|
+
insert_into_tree(child, rest, methods: methods, shell: shell)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def render_tree_nodes(node, kinds, depth)
|
|
200
|
+
node[:children].values.map { |child| render_tree_node(child, kinds, depth, [node[:name]].compact) }.join("\n")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def render_tree_node(node, kinds, depth, prefix)
|
|
204
|
+
indent = INDENT * depth
|
|
205
|
+
qualified = (prefix + [node[:name]]).join("::")
|
|
206
|
+
keyword = node_keyword(node, kinds, qualified)
|
|
207
|
+
body = render_tree_node_body(node, kinds, depth, prefix)
|
|
208
|
+
"#{indent}#{keyword} #{node[:name]}\n#{body}#{indent}end\n"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def render_tree_node_body(node, kinds, depth, prefix)
|
|
212
|
+
inner_indent = INDENT * (depth + 1)
|
|
213
|
+
method_lines = node[:methods].map { |c| "#{inner_indent}#{c.rbs}\n" }.join
|
|
214
|
+
child_blocks = node[:children].values.map do |child|
|
|
215
|
+
render_tree_node(child, kinds, depth + 1, prefix + [node[:name]])
|
|
216
|
+
end.join
|
|
217
|
+
method_lines + child_blocks
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Per ADR-14 gap-#3 (a) the keyword for a segment comes
|
|
221
|
+
# from `namespace_kinds` when known. The default for an
|
|
222
|
+
# explicit class shell (gap-#3 (e), `Const =
|
|
223
|
+
# Data.define(...)`) is `:class`; otherwise default to
|
|
224
|
+
# `:class` for a method-bearing leaf node and `:module`
|
|
225
|
+
# for an intermediate (children-only) segment. Defaulting
|
|
226
|
+
# intermediates to `:module` matches RBS's "multiple
|
|
227
|
+
# `module Foo` declarations merge" rule.
|
|
228
|
+
def node_keyword(node, kinds, qualified)
|
|
229
|
+
return kinds.fetch(qualified) if kinds.key?(qualified)
|
|
230
|
+
return :class if node[:shell]
|
|
231
|
+
return :class if node[:methods].any? && node[:children].empty?
|
|
232
|
+
|
|
233
|
+
:module
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def update_existing(source_path, target, candidates)
|
|
237
|
+
source = target.read
|
|
238
|
+
decls = parse_signature(source)
|
|
239
|
+
return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
|
|
240
|
+
|
|
241
|
+
state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
|
|
242
|
+
candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods) }
|
|
243
|
+
merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
|
|
244
|
+
|
|
245
|
+
action = state.applied.empty? ? :noop : :updated
|
|
246
|
+
target.write(state.source) if action == :updated
|
|
247
|
+
WriteResult.new(source_path: source_path, target_path: target,
|
|
248
|
+
action: action, applied: state.applied, skipped: state.skipped)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ADR-14 gap-#3 (e): for every requested class shell
|
|
252
|
+
# that isn't already declared in the target file,
|
|
253
|
+
# insert an empty `class Const\nend` block inside the
|
|
254
|
+
# nearest existing ancestor. Shells already covered by
|
|
255
|
+
# an existing declaration are silently a no-op. The
|
|
256
|
+
# `applied` accumulator does NOT grow — shells are
|
|
257
|
+
# structural declarations, not methods, so the
|
|
258
|
+
# action-count surface (`updated +N`) keeps
|
|
259
|
+
# reflecting method changes only.
|
|
260
|
+
def merge_class_shells(state, shells, kinds)
|
|
261
|
+
shells.each do |qualified|
|
|
262
|
+
next if find_class_decl(state.decls, qualified)
|
|
263
|
+
|
|
264
|
+
insert_class_shell(state, qualified, kinds)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def insert_class_shell(state, qualified, kinds)
|
|
269
|
+
segments = qualified.split("::")
|
|
270
|
+
anchor_segs, missing = split_at_existing_ancestor(state.decls, segments)
|
|
271
|
+
anchor_decl = anchor_segs.empty? ? nil : find_class_decl(state.decls, anchor_segs.join("::"))
|
|
272
|
+
depth = anchor_decl ? anchor_decl_indent_depth(anchor_decl) : 0
|
|
273
|
+
snippet = build_shell_snippet(missing, anchor_segs, kinds, depth)
|
|
274
|
+
state.source = if anchor_decl
|
|
275
|
+
insert_before_end(state.source, anchor_decl, snippet)
|
|
276
|
+
else
|
|
277
|
+
append_top_level(state.source, snippet)
|
|
278
|
+
end
|
|
279
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def split_at_existing_ancestor(decls, segments)
|
|
283
|
+
(segments.size - 1).downto(0).each do |i|
|
|
284
|
+
ancestor = segments[0...i].join("::")
|
|
285
|
+
return [segments[0...i], segments[i..]] if i.zero? || find_class_decl(decls, ancestor)
|
|
286
|
+
end
|
|
287
|
+
[[], segments]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Pulls the indent depth (in `INDENT` units) one level
|
|
291
|
+
# deeper than the anchor decl's own column. Pre-
|
|
292
|
+
# existing members might be missing (an empty
|
|
293
|
+
# `class Foo; end`) so the keyword column is the
|
|
294
|
+
# robust signal.
|
|
295
|
+
def anchor_decl_indent_depth(decl)
|
|
296
|
+
decl_column = decl.location[:keyword].start_column
|
|
297
|
+
(decl_column / INDENT.size) + 1
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def build_shell_snippet(missing, anchor_segs, kinds, depth)
|
|
301
|
+
return "" if missing.empty?
|
|
302
|
+
|
|
303
|
+
head, *rest = missing
|
|
304
|
+
qualified = (anchor_segs + [head]).join("::")
|
|
305
|
+
indent = INDENT * depth
|
|
306
|
+
if rest.empty?
|
|
307
|
+
keyword = kinds[qualified] || :class
|
|
308
|
+
"#{indent}#{keyword} #{head}\n#{indent}end\n"
|
|
309
|
+
else
|
|
310
|
+
inner = build_shell_snippet(rest, anchor_segs + [head], kinds, depth + 1)
|
|
311
|
+
keyword = kinds[qualified] || :module
|
|
312
|
+
"#{indent}#{keyword} #{head}\n#{inner}#{indent}end\n"
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def insert_before_end(source, decl, snippet)
|
|
317
|
+
end_pos = decl.location[:end].start_pos
|
|
318
|
+
source[0...end_pos] + snippet + source[end_pos..]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def append_top_level(source, snippet)
|
|
322
|
+
ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def parse_signature(source)
|
|
326
|
+
_, _, decls = RBS::Parser.parse_signature(source)
|
|
327
|
+
decls
|
|
328
|
+
rescue RBS::ParsingError
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def merge_class(state, class_name, methods)
|
|
333
|
+
decl = find_class_decl(state.decls, class_name)
|
|
334
|
+
state.source = if decl.nil?
|
|
335
|
+
append_new_class(state.source, class_name, methods, state.applied)
|
|
336
|
+
else
|
|
337
|
+
merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
|
|
338
|
+
end
|
|
339
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Walks the parsed decl tree recursively, tracking the
|
|
343
|
+
# enclosing module/class prefix, and returns the
|
|
344
|
+
# declaration whose fully-qualified name matches
|
|
345
|
+
# `qualified_name`. Recursing into modules lets us
|
|
346
|
+
# match `Rigor::Type::Nominal` against the
|
|
347
|
+
# `class Nominal` declaration nested inside
|
|
348
|
+
# `module Rigor; module Type; … end; end`.
|
|
349
|
+
def find_class_decl(decls, qualified_name)
|
|
350
|
+
find_class_decl_in(decls, [], qualified_name)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def find_class_decl_in(decls, prefix, qualified_name)
|
|
354
|
+
decls.each do |decl|
|
|
355
|
+
next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
|
|
356
|
+
|
|
357
|
+
local = decl.name.to_s.sub(/\A::/, "")
|
|
358
|
+
full = prefix.empty? ? local : "#{prefix.join('::')}::#{local}"
|
|
359
|
+
return decl if full == qualified_name
|
|
360
|
+
|
|
361
|
+
nested = find_class_decl_in(decl.members, prefix + [local], qualified_name)
|
|
362
|
+
return nested if nested
|
|
363
|
+
end
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Appends an entirely new `class Foo … end` block at the
|
|
368
|
+
# end of the file (with a leading blank line as
|
|
369
|
+
# separator).
|
|
370
|
+
def append_new_class(source, class_name, methods, applied)
|
|
371
|
+
body = methods.map { |c| "#{INDENT}#{c.rbs}" }.join("\n")
|
|
372
|
+
snippet = "\nclass #{class_name}\n#{body}\nend\n"
|
|
373
|
+
applied.concat(methods)
|
|
374
|
+
ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def ends_with_newline?(source)
|
|
378
|
+
source.end_with?("\n")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def merge_into_existing_class(source, decl, methods, applied, skipped)
|
|
382
|
+
existing_pairs = collect_member_pairs(decl)
|
|
383
|
+
new_methods, conflicting = partition_against_existing(methods, existing_pairs)
|
|
384
|
+
|
|
385
|
+
source = insert_into_class(source, decl, new_methods)
|
|
386
|
+
applied.concat(new_methods)
|
|
387
|
+
|
|
388
|
+
if @overwrite
|
|
389
|
+
source, replaced = replace_eligible_conflicts(source, decl, conflicting)
|
|
390
|
+
applied.concat(replaced)
|
|
391
|
+
skipped.concat(conflicting.reject { |c| replaced.include?(c) }.map { |c| [c, :user_authored] })
|
|
392
|
+
else
|
|
393
|
+
skipped.concat(conflicting.map { |c| [c, :user_authored] })
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
source
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Returns a list of `[method_name (Symbol), kind (Symbol)]`
|
|
400
|
+
# pairs for every method-like member in the declaration.
|
|
401
|
+
# ADR-14 slice 4 recognises `MethodDefinition`'s
|
|
402
|
+
# `:instance` / `:singleton` kind plus the three
|
|
403
|
+
# `attr_*` declaration kinds so a source-side
|
|
404
|
+
# `attr_reader :name` and an RBS-side `attr_reader name: T`
|
|
405
|
+
# are treated as the same member (i.e. user-authored).
|
|
406
|
+
def collect_member_pairs(decl)
|
|
407
|
+
pairs = []
|
|
408
|
+
decl.members.each { |m| collect_pairs_for_member(m, pairs) }
|
|
409
|
+
pairs
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def collect_pairs_for_member(member, pairs)
|
|
413
|
+
case member
|
|
414
|
+
when RBS::AST::Members::MethodDefinition
|
|
415
|
+
pairs << [member.name, member.kind]
|
|
416
|
+
when RBS::AST::Members::AttrReader
|
|
417
|
+
pairs << [member.name, :instance]
|
|
418
|
+
when RBS::AST::Members::AttrWriter
|
|
419
|
+
pairs << [:"#{member.name}=", :instance]
|
|
420
|
+
when RBS::AST::Members::AttrAccessor
|
|
421
|
+
pairs << [member.name, :instance]
|
|
422
|
+
pairs << [:"#{member.name}=", :instance]
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def partition_against_existing(methods, existing_pairs)
|
|
427
|
+
methods.partition { |c| !existing_pairs.include?([c.method_name, c.kind]) }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Inserts each new method line one column before the
|
|
431
|
+
# class declaration's `end` keyword. The insertion text
|
|
432
|
+
# carries its own leading indent + trailing newline so
|
|
433
|
+
# the surrounding source's whitespace stays intact.
|
|
434
|
+
def insert_into_class(source, decl, new_methods)
|
|
435
|
+
return source if new_methods.empty?
|
|
436
|
+
|
|
437
|
+
end_pos = decl.location[:end].start_pos
|
|
438
|
+
addition = new_methods.map { |c| "#{INDENT}#{c.rbs}\n" }.join
|
|
439
|
+
source[0...end_pos] + addition + source[end_pos..]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Walks the class's existing method declarations; for
|
|
443
|
+
# each replaceable candidate that matches a member
|
|
444
|
+
# name, slices out the old declaration's source range
|
|
445
|
+
# and substitutes the new RBS one-liner. Members that
|
|
446
|
+
# are not `MethodDefinition`s are left alone.
|
|
447
|
+
#
|
|
448
|
+
# Two candidate classifications are eligible for
|
|
449
|
+
# replacement under `--overwrite`:
|
|
450
|
+
#
|
|
451
|
+
# 1. `TIGHTER_RETURN` — the classifier already proved the
|
|
452
|
+
# new return type is a strict subtype of the declared
|
|
453
|
+
# one (with lenience guards passed).
|
|
454
|
+
# 2. `NEW_METHOD` whose new RBS strictly tightens an
|
|
455
|
+
# `untyped` position in the existing declaration. The
|
|
456
|
+
# canonical case is `initialize_stub_candidate`, which
|
|
457
|
+
# bypasses the existing-RBS comparison and always
|
|
458
|
+
# classifies as `NEW_METHOD` — when sig-gen's
|
|
459
|
+
# `--params=observed` upgrades a `(path: untyped) -> void`
|
|
460
|
+
# declaration to `(path: String) -> void` we want
|
|
461
|
+
# `--overwrite` to apply it.
|
|
462
|
+
def replace_eligible_conflicts(source, decl, candidates)
|
|
463
|
+
eligible = candidates.select { |c| eligible_for_replacement?(c, decl, source) }
|
|
464
|
+
return [source, []] if eligible.empty?
|
|
465
|
+
|
|
466
|
+
replaced = []
|
|
467
|
+
# Apply replacements from highest byte position downward
|
|
468
|
+
# so earlier byte offsets remain valid as the source
|
|
469
|
+
# grows or shrinks.
|
|
470
|
+
sorted = eligible.sort_by { |c| -member_position(decl, c.method_name, c.kind) }
|
|
471
|
+
sorted.each do |candidate|
|
|
472
|
+
source = apply_replacement(source, decl, candidate) and replaced << candidate
|
|
473
|
+
end
|
|
474
|
+
[source, replaced]
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def eligible_for_replacement?(candidate, decl, source)
|
|
478
|
+
case candidate.classification
|
|
479
|
+
when Classification::TIGHTER_RETURN then true
|
|
480
|
+
when Classification::NEW_METHOD then tightens_untyped?(candidate, decl, source)
|
|
481
|
+
else false
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Compares the existing member's source-side RBS text
|
|
486
|
+
# against the candidate's proposed RBS text. Returns
|
|
487
|
+
# true when the new spelling has STRICTLY FEWER bare
|
|
488
|
+
# `untyped` tokens than the existing one — i.e. at
|
|
489
|
+
# least one `untyped` slot becomes a concrete type AND
|
|
490
|
+
# no concrete slot becomes `untyped`. Word-boundary
|
|
491
|
+
# matching ensures we count `untyped` only as a type
|
|
492
|
+
# token, not as a substring inside identifiers.
|
|
493
|
+
def tightens_untyped?(candidate, decl, source)
|
|
494
|
+
member = find_method_member(decl, candidate.method_name, candidate.kind)
|
|
495
|
+
return false if member.nil?
|
|
496
|
+
|
|
497
|
+
existing_rbs = source[member.location.start_pos...member.location.end_pos]
|
|
498
|
+
count_untyped(candidate.rbs) < count_untyped(existing_rbs)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def count_untyped(rbs)
|
|
502
|
+
rbs.scan(/\buntyped\b/).size
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def member_position(decl, method_name, kind)
|
|
506
|
+
member = find_method_member(decl, method_name, kind)
|
|
507
|
+
member ? member.location.start_pos : -1
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def find_method_member(decl, method_name, kind)
|
|
511
|
+
decl.members.find do |m|
|
|
512
|
+
m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == method_name && m.kind == kind
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Splices the new RBS one-liner over the existing
|
|
517
|
+
# declaration's byte range. `RBS::Parser`'s location
|
|
518
|
+
# starts at the `def` keyword, NOT at the column zero of
|
|
519
|
+
# the line, so the leading whitespace stays inside
|
|
520
|
+
# `source[0...start_pos]` and we do not re-emit it.
|
|
521
|
+
def apply_replacement(source, decl, candidate)
|
|
522
|
+
member = find_method_member(decl, candidate.method_name, candidate.kind)
|
|
523
|
+
return nil if member.nil?
|
|
524
|
+
|
|
525
|
+
loc = member.location
|
|
526
|
+
source[0...loc.start_pos] + candidate.rbs + source[loc.end_pos..]
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sig_gen/classification"
|
|
4
|
+
require_relative "sig_gen/method_candidate"
|
|
5
|
+
require_relative "sig_gen/observed_call"
|
|
6
|
+
require_relative "sig_gen/type_elaborator"
|
|
7
|
+
require_relative "sig_gen/observation_collector"
|
|
8
|
+
require_relative "sig_gen/generator"
|
|
9
|
+
require_relative "sig_gen/renderer"
|
|
10
|
+
require_relative "sig_gen/layout_index"
|
|
11
|
+
require_relative "sig_gen/path_mapper"
|
|
12
|
+
require_relative "sig_gen/write_result"
|
|
13
|
+
require_relative "sig_gen/writer"
|
|
14
|
+
|
|
15
|
+
module Rigor
|
|
16
|
+
# Namespace for the RBS signature generator that powers
|
|
17
|
+
# `rigor sig-gen` (ADR-14).
|
|
18
|
+
#
|
|
19
|
+
# The generator emits RBS from Rigor's inference results so
|
|
20
|
+
# users close RBS coverage gaps without freehand authorship.
|
|
21
|
+
# See `docs/adr/14-rbs-sig-generation.md` for the design
|
|
22
|
+
# rationale and the slicing plan.
|
|
23
|
+
module SigGen
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trinary"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Type
|
|
7
|
+
# A `Method` carrier that tracks the bound `(receiver, name)` pair.
|
|
8
|
+
#
|
|
9
|
+
# Ruby's `Object#method(name)` returns a `Method` instance whose
|
|
10
|
+
# later `.call` / `.()` / `[]` dispatches `name` on the original
|
|
11
|
+
# receiver. The plain RBS `Method` nominal cannot carry that
|
|
12
|
+
# binding, so call sites on the resulting `Method` collapse to
|
|
13
|
+
# `untyped` — losing the per-method precision the original
|
|
14
|
+
# receiver supports.
|
|
15
|
+
#
|
|
16
|
+
# `BoundMethod` keeps the binding so the dispatcher can substitute
|
|
17
|
+
# the original `(receiver, name)` dispatch at `.call` / `.()` /
|
|
18
|
+
# `[]` time. The carrier erases to `Method` at the RBS boundary so
|
|
19
|
+
# downstream RBS interop (e.g. passing the value into a method
|
|
20
|
+
# whose parameter is typed `::Method`) stays compatible — the
|
|
21
|
+
# binding is only consulted when Rigor itself dispatches.
|
|
22
|
+
#
|
|
23
|
+
# See `lib/rigor/inference/method_dispatcher/method_folding.rb`
|
|
24
|
+
# for the forward (`Object#method(:sym)`) and backward
|
|
25
|
+
# (`BoundMethod#call`) folding tiers that consume / produce this
|
|
26
|
+
# carrier.
|
|
27
|
+
class BoundMethod
|
|
28
|
+
attr_reader :receiver_type, :method_name
|
|
29
|
+
|
|
30
|
+
def initialize(receiver_type:, method_name:)
|
|
31
|
+
raise ArgumentError, "receiver_type must not be nil" if receiver_type.nil?
|
|
32
|
+
raise ArgumentError, "method_name must be a Symbol, got #{method_name.inspect}" unless method_name.is_a?(Symbol)
|
|
33
|
+
|
|
34
|
+
@receiver_type = receiver_type
|
|
35
|
+
@method_name = method_name
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def describe(verbosity = :short)
|
|
40
|
+
"Method<#{receiver_type.describe(verbosity)}##{method_name}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def erase_to_rbs
|
|
44
|
+
"Method"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def top
|
|
48
|
+
Trinary.no
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bot
|
|
52
|
+
Trinary.no
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dynamic
|
|
56
|
+
Trinary.no
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def accepts(other, mode: :gradual)
|
|
60
|
+
Inference::Acceptance.accepts(self, other, mode: mode)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ==(other)
|
|
64
|
+
other.is_a?(BoundMethod) &&
|
|
65
|
+
receiver_type == other.receiver_type &&
|
|
66
|
+
method_name == other.method_name
|
|
67
|
+
end
|
|
68
|
+
alias eql? ==
|
|
69
|
+
|
|
70
|
+
def hash
|
|
71
|
+
[BoundMethod, receiver_type, method_name].hash
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def inspect
|
|
75
|
+
"#<Rigor::Type::BoundMethod #{describe(:short)}>"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|