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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -31
  3. data/lib/rigor/analysis/check_rules.rb +10 -18
  4. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +75 -0
  5. data/lib/rigor/analysis/dependency_source_inference/builder.rb +47 -21
  6. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  7. data/lib/rigor/analysis/dependency_source_inference/index.rb +32 -3
  8. data/lib/rigor/analysis/dependency_source_inference/walker.rb +1 -1
  9. data/lib/rigor/analysis/dependency_source_inference.rb +1 -0
  10. data/lib/rigor/analysis/diagnostic.rb +0 -2
  11. data/lib/rigor/analysis/fact_store.rb +11 -3
  12. data/lib/rigor/analysis/rule_catalog.rb +2 -2
  13. data/lib/rigor/analysis/runner.rb +114 -3
  14. data/lib/rigor/builtins/imported_refinements.rb +360 -55
  15. data/lib/rigor/cache/descriptor.rb +1 -1
  16. data/lib/rigor/cache/store.rb +1 -1
  17. data/lib/rigor/cli/diff_command.rb +1 -1
  18. data/lib/rigor/cli/sig_gen_command.rb +173 -0
  19. data/lib/rigor/cli/type_of_command.rb +1 -1
  20. data/lib/rigor/cli/type_scan_renderer.rb +1 -1
  21. data/lib/rigor/cli/type_scan_report.rb +2 -2
  22. data/lib/rigor/cli.rb +9 -1
  23. data/lib/rigor/configuration/dependencies.rb +2 -2
  24. data/lib/rigor/configuration.rb +2 -2
  25. data/lib/rigor/environment.rb +35 -4
  26. data/lib/rigor/flow_contribution/conflict.rb +2 -2
  27. data/lib/rigor/flow_contribution/element.rb +1 -1
  28. data/lib/rigor/flow_contribution/fact.rb +1 -1
  29. data/lib/rigor/flow_contribution/merge_result.rb +1 -1
  30. data/lib/rigor/flow_contribution/merger.rb +3 -3
  31. data/lib/rigor/flow_contribution.rb +2 -2
  32. data/lib/rigor/inference/block_parameter_binder.rb +0 -2
  33. data/lib/rigor/inference/coverage_scanner.rb +1 -1
  34. data/lib/rigor/inference/expression_typer.rb +67 -11
  35. data/lib/rigor/inference/fallback.rb +1 -1
  36. data/lib/rigor/inference/method_dispatcher/block_folding.rb +3 -5
  37. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +0 -12
  38. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -3
  39. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +1 -1
  40. data/lib/rigor/inference/method_dispatcher/method_folding.rb +118 -0
  41. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -11
  42. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +27 -11
  43. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +0 -4
  44. data/lib/rigor/inference/method_dispatcher.rb +146 -2
  45. data/lib/rigor/inference/method_parameter_binder.rb +1 -3
  46. data/lib/rigor/inference/narrowing.rb +2 -4
  47. data/lib/rigor/inference/rbs_type_translator.rb +0 -2
  48. data/lib/rigor/inference/scope_indexer.rb +14 -9
  49. data/lib/rigor/inference/statement_evaluator.rb +7 -7
  50. data/lib/rigor/plugin/io_boundary.rb +0 -2
  51. data/lib/rigor/plugin/loader.rb +2 -2
  52. data/lib/rigor/plugin/manifest.rb +30 -9
  53. data/lib/rigor/plugin/registry.rb +11 -0
  54. data/lib/rigor/plugin/services.rb +1 -1
  55. data/lib/rigor/plugin/type_node_resolver.rb +52 -0
  56. data/lib/rigor/plugin.rb +1 -0
  57. data/lib/rigor/rbs_extended/reporter.rb +91 -0
  58. data/lib/rigor/rbs_extended.rb +131 -32
  59. data/lib/rigor/scope.rb +25 -8
  60. data/lib/rigor/sig_gen/classification.rb +36 -0
  61. data/lib/rigor/sig_gen/generator.rb +1048 -0
  62. data/lib/rigor/sig_gen/layout_index.rb +108 -0
  63. data/lib/rigor/sig_gen/method_candidate.rb +62 -0
  64. data/lib/rigor/sig_gen/observation_collector.rb +391 -0
  65. data/lib/rigor/sig_gen/observed_call.rb +62 -0
  66. data/lib/rigor/sig_gen/path_mapper.rb +116 -0
  67. data/lib/rigor/sig_gen/renderer.rb +157 -0
  68. data/lib/rigor/sig_gen/type_elaborator.rb +92 -0
  69. data/lib/rigor/sig_gen/write_result.rb +48 -0
  70. data/lib/rigor/sig_gen/writer.rb +530 -0
  71. data/lib/rigor/sig_gen.rb +25 -0
  72. data/lib/rigor/type/bound_method.rb +79 -0
  73. data/lib/rigor/type/combinator.rb +195 -2
  74. data/lib/rigor/type/constant.rb +13 -0
  75. data/lib/rigor/type/hash_shape.rb +0 -2
  76. data/lib/rigor/type/union.rb +20 -1
  77. data/lib/rigor/type.rb +1 -0
  78. data/lib/rigor/type_node/generic.rb +62 -0
  79. data/lib/rigor/type_node/identifier.rb +30 -0
  80. data/lib/rigor/type_node/indexed_access.rb +41 -0
  81. data/lib/rigor/type_node/integer_literal.rb +29 -0
  82. data/lib/rigor/type_node/name_scope.rb +52 -0
  83. data/lib/rigor/type_node/resolver_chain.rb +56 -0
  84. data/lib/rigor/type_node/string_literal.rb +29 -0
  85. data/lib/rigor/type_node/symbol_literal.rb +28 -0
  86. data/lib/rigor/type_node/union.rb +42 -0
  87. data/lib/rigor/type_node.rb +29 -0
  88. data/lib/rigor/version.rb +1 -1
  89. data/lib/rigor.rb +2 -0
  90. data/sig/rigor/analysis/check_rules/always_truthy_condition_collector.rbs +10 -0
  91. data/sig/rigor/analysis/check_rules/dead_assignment_collector.rbs +10 -0
  92. data/sig/rigor/analysis/dependency_source_inference/gem_resolver.rbs +25 -0
  93. data/sig/rigor/analysis/dependency_source_inference/index.rbs +9 -0
  94. data/sig/rigor/cli/diff_command.rbs +4 -0
  95. data/sig/rigor/cli/explain_command.rbs +4 -0
  96. data/sig/rigor/cli/sig_gen_command.rbs +4 -0
  97. data/sig/rigor/cli/type_scan_command.rbs +3 -0
  98. data/sig/rigor/environment.rbs +5 -2
  99. data/sig/rigor/inference/builtins/method_catalog.rbs +4 -0
  100. data/sig/rigor/inference/builtins/numeric_catalog.rbs +3 -0
  101. data/sig/rigor/inference/builtins.rbs +2 -0
  102. data/sig/rigor/plugin/access_denied_error.rbs +3 -0
  103. data/sig/rigor/plugin/base.rbs +6 -0
  104. data/sig/rigor/plugin/fact_store.rbs +11 -0
  105. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  106. data/sig/rigor/plugin/load_error.rbs +6 -0
  107. data/sig/rigor/plugin/loader.rbs +20 -0
  108. data/sig/rigor/plugin/manifest.rbs +9 -0
  109. data/sig/rigor/plugin/registry.rbs +3 -0
  110. data/sig/rigor/plugin/services.rbs +3 -0
  111. data/sig/rigor/plugin/trust_policy.rbs +4 -0
  112. data/sig/rigor/plugin/type_node_resolver.rbs +3 -0
  113. data/sig/rigor/plugin.rbs +8 -0
  114. data/sig/rigor/scope.rbs +4 -2
  115. data/sig/rigor/type.rbs +28 -6
  116. 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