rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
data/lib/rigor/scope.rb CHANGED
@@ -24,7 +24,8 @@ module Rigor
24
24
  :declaration_sourced,
25
25
  :source_path, :discovery, :struct_fold_safe_locals,
26
26
  :dynamic_origins, :local_origins, :ivar_origins,
27
- :void_origins
27
+ :void_origins,
28
+ :optimistic_origins, :optimistic_locals, :optimistic_ivars
28
29
 
29
30
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
30
31
  # reference; the per-table readers stay on Scope so engine call sites and plugins are unaffected by the
@@ -149,7 +150,10 @@ module Rigor
149
150
  dynamic_origins: {}.compare_by_identity,
150
151
  local_origins: EMPTY_ORIGINS,
151
152
  ivar_origins: EMPTY_ORIGINS,
152
- void_origins: {}.compare_by_identity
153
+ void_origins: {}.compare_by_identity,
154
+ optimistic_origins: {}.compare_by_identity,
155
+ optimistic_locals: EMPTY_ORIGINS,
156
+ optimistic_ivars: EMPTY_ORIGINS
153
157
  )
154
158
  @environment = environment
155
159
  @locals = locals
@@ -168,9 +172,36 @@ module Rigor
168
172
  @local_origins = local_origins
169
173
  @ivar_origins = ivar_origins
170
174
  @void_origins = void_origins
175
+ @optimistic_origins = optimistic_origins
176
+ @optimistic_locals = optimistic_locals
177
+ @optimistic_ivars = optimistic_ivars
171
178
  freeze
172
179
  end
173
180
 
181
+ # Issue #286 — the {Inference::OptimisticOrigin} cause attached to a call node whose result is nil-free
182
+ # only because `RbsDispatch` reads past `%a{implicitly-returns-nil}`, or `nil` when the value's
183
+ # nil-freeness is a property of its class. Mirrors {#dynamic_origins} / {#void_origins}: advisory
184
+ # metadata, ignored by `==` / `hash`, and never varying a flow decision on its own.
185
+ def record_optimistic_origin(node, cause)
186
+ @optimistic_origins[node] = cause
187
+ self
188
+ end
189
+
190
+ def optimistic_local(name) = @optimistic_locals[name.to_sym]
191
+ def optimistic_ivar(name) = @optimistic_ivars[name.to_sym]
192
+
193
+ def with_optimistic_local(name, cause)
194
+ return self if cause.nil?
195
+
196
+ rebuild(optimistic_locals: @optimistic_locals.merge(name.to_sym => cause).freeze)
197
+ end
198
+
199
+ def with_optimistic_ivar(name, cause)
200
+ return self if cause.nil?
201
+
202
+ rebuild(optimistic_ivars: @optimistic_ivars.merge(name.to_sym => cause).freeze)
203
+ end
204
+
174
205
  # ADR-82 WD1 — the propagated origin of the `Dynamic` value currently bound to a local / instance
175
206
  # variable, or `nil` when none is tracked. Consulted by `Inference::ProtectionScanner` when a dispatch's
176
207
  # receiver is a bare `x` / `@x` read whose own node carries no origin.
@@ -215,7 +246,8 @@ module Rigor
215
246
  indexed_narrowings: new_indexed_narrowings,
216
247
  method_chain_narrowings: new_chain_narrowings,
217
248
  declaration_sourced: drop_declaration_sourced_for(:local, name),
218
- local_origins: drop_origin(@local_origins, name))
249
+ local_origins: drop_origin(@local_origins, name),
250
+ optimistic_locals: drop_origin(@optimistic_locals, name))
219
251
  end
220
252
 
221
253
  def with_fact(fact)
@@ -281,7 +313,8 @@ module Rigor
281
313
  indexed_narrowings: new_indexed_narrowings,
282
314
  method_chain_narrowings: new_chain_narrowings,
283
315
  declaration_sourced: drop_declaration_sourced_for(:ivar, name),
284
- ivar_origins: drop_origin(@ivar_origins, name))
316
+ ivar_origins: drop_origin(@ivar_origins, name),
317
+ optimistic_ivars: drop_origin(@optimistic_ivars, name))
285
318
  end
286
319
 
287
320
  # ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only provenance is the class-ivar index.
@@ -394,11 +427,13 @@ module Rigor
394
427
  # recognised `define_method` invocation inside class/module bodies. The `rigor check` undefined-method and
395
428
  # wrong-arity rules consult this map to suppress diagnostics for methods the user has defined dynamically,
396
429
  # even when no RBS sig describes them.
430
+ # A name defined on both sides of one class records {DiscoveryIndex::METHOD_KIND_BOTH} and matches either kind.
397
431
  def discovered_method?(class_name, method_name, kind)
398
432
  table = @discovery.discovered_methods[class_name.to_s]
399
433
  return false unless table
400
434
 
401
- table[method_name.to_sym] == kind
435
+ recorded = table[method_name.to_sym]
436
+ recorded == kind || recorded == DiscoveryIndex::METHOD_KIND_BOTH
402
437
  end
403
438
 
404
439
  # ADR-34 § "Decision" — predicate identifying a toplevel-shaped scope (no enclosing `class` / `module` body).
@@ -724,7 +759,10 @@ module Rigor
724
759
  dynamic_origins: @dynamic_origins,
725
760
  local_origins: @local_origins,
726
761
  ivar_origins: @ivar_origins,
727
- void_origins: @void_origins
762
+ void_origins: @void_origins,
763
+ optimistic_origins: @optimistic_origins,
764
+ optimistic_locals: @optimistic_locals,
765
+ optimistic_ivars: @optimistic_ivars
728
766
  )
729
767
  self.class.new(
730
768
  environment: environment, locals: locals,
@@ -739,7 +777,10 @@ module Rigor
739
777
  dynamic_origins: dynamic_origins,
740
778
  local_origins: local_origins,
741
779
  ivar_origins: ivar_origins,
742
- void_origins: void_origins
780
+ void_origins: void_origins,
781
+ optimistic_origins: optimistic_origins,
782
+ optimistic_locals: optimistic_locals,
783
+ optimistic_ivars: optimistic_ivars
743
784
  )
744
785
  end
745
786
 
@@ -777,7 +818,10 @@ module Rigor
777
818
  dynamic_origins: @dynamic_origins,
778
819
  local_origins: join_origins(@local_origins, other.local_origins),
779
820
  ivar_origins: join_origins(@ivar_origins, other.ivar_origins),
780
- void_origins: @void_origins
821
+ void_origins: @void_origins,
822
+ optimistic_origins: @optimistic_origins,
823
+ optimistic_locals: join_origins(@optimistic_locals, other.optimistic_locals),
824
+ optimistic_ivars: join_origins(@optimistic_ivars, other.optimistic_ivars)
781
825
  )
782
826
  end
783
827
 
@@ -12,6 +12,7 @@ require_relative "../source/node_children"
12
12
  require_relative "../inference/def_return_typer"
13
13
  require_relative "../inference/scope_indexer"
14
14
  require_relative "../inference/rbs_type_translator"
15
+ require_relative "meta_class_shape"
15
16
  require_relative "rbs_validity"
16
17
 
17
18
  module Rigor
@@ -61,6 +62,7 @@ module Rigor
61
62
  @module_function_methods = Set.new
62
63
  @class_shells = Set.new
63
64
  @class_superclasses = {}
65
+ @meta_layouts = {}
64
66
  # Whole-run, NOT per-file: a rendering defect is reported once at the end of the run.
65
67
  @unrenderable = []
66
68
  end
@@ -114,7 +116,13 @@ module Rigor
114
116
  @module_function_methods = Set.new
115
117
  @class_shells = Set.new
116
118
  @class_superclasses = {}
119
+ @meta_layouts = collect_meta_layouts(scope_index)
120
+ register_meta_classes
117
121
  defs = collect_method_definitions(parse_result.value)
122
+ # Candidate construction freezes the per-file maps above (see {#build_candidate}), so every registration
123
+ # pass has to be finished before the first `build_candidate` call. Meta members lead the RETURNED order —
124
+ # a value class's members and constructors read first, ahead of the methods its block body defines.
125
+ meta_candidates = collect_meta_member_candidates(path, scope_index)
118
126
  candidates_from_defs = defs.filter_map do |def_node, class_name, kind|
119
127
  # An analyzer bug typing one def's body must cost only that def's candidate, never the whole
120
128
  # `rigor sig-gen` run. The `check` path recovers each *file* this way (worker_session.rb); sig-gen
@@ -125,7 +133,8 @@ module Rigor
125
133
  nil
126
134
  end
127
135
  obs_ivar_map = build_observed_ivar_map(parse_result.value)
128
- candidates_from_defs + collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
136
+ meta_candidates + candidates_from_defs +
137
+ collect_attr_candidates(parse_result.value, path, scope_index, obs_ivar_map)
129
138
  end
130
139
 
131
140
  # Walks the AST collecting `(def_node, class_name, kind)` tuples for every `def` Rigor can re-type. Slice 1
@@ -163,8 +172,11 @@ module Rigor
163
172
  collect_def_node(node, prefix, in_singleton_class, module_function_active, out)
164
173
  return
165
174
  when Prism::ConstantWriteNode
166
- register_data_struct_shell(node, prefix)
167
- # fall through to recurse into the RHS so a trailing `do ... end` block carrying defs is still walked.
175
+ body = meta_block_body(node, prefix)
176
+ if body
177
+ walk_defs(body, prefix + [node.name.to_s], false, false, out)
178
+ return
179
+ end
168
180
  when Prism::StatementsNode
169
181
  walk_statements(node, prefix, in_singleton_class, module_function_active, out)
170
182
  return
@@ -192,8 +204,8 @@ module Rigor
192
204
  # collapse the whole env (the 2026-07-04 redmine `GitAdapter < AbstractAdapter` crash). Only a plain
193
205
  # constant superclass is emittable: `class X < Foo` / `class X < Foo::Bar` yields the source token verbatim
194
206
  # (RBS resolves it relative to the emitted namespace, matching Ruby's lexical scope). A computed
195
- # superclass (`Struct.new`, `Data.define`, `Class.new`, any `CallNode`) is left unrecorded — those flow
196
- # through the {#register_data_struct_shell} shell path or are simply un-representable, and guessing would
207
+ # superclass (`Class.new`, any other `CallNode`) is left unrecorded — a `Data.define` / `Struct.new` one is
208
+ # already carried by {#register_meta_classes}, and the rest are un-representable, where guessing would
197
209
  # misfold.
198
210
  def record_superclass(node, full)
199
211
  return unless node.is_a?(Prism::ClassNode)
@@ -202,37 +214,60 @@ module Rigor
202
214
  @class_superclasses[full] = superclass if superclass
203
215
  end
204
216
 
205
- # ADR-14 gap-#3 (e): recognises `Const = Data.define(...)` and `Const = Struct.new(...)` as class
206
- # declarations. The runtime side stamps a brand-new anonymous class at the RHS and binds it to `Const`, so
207
- # the generated RBS needs an explicit `class Const` declaration even though no `class Const ... end` block
208
- # appears in source. Without it, references to `Const` in return types fail to resolve under Steep (the
209
- # canonical case is `GemResolver::Resolved | GemResolver::Unresolvable` where
210
- # `Unresolvable = Data.define(:gem_name, :reason)`).
211
- #
212
- # The walker records the fully-qualified constant name in `@class_shells` (carried through to every
213
- # candidate so the writer's tree-builder picks it up) AND in `@namespace_kinds` so the leaf's `class`
214
- # keyword wins over the intermediate-segment `module` default.
215
- def register_data_struct_shell(node, prefix)
216
- return unless data_or_struct_call?(node.value)
217
+ # The ADR-48 member layouts for this file, in one table keyed by qualified class name. Reading the engine's
218
+ # own tables instead of re-recognising `Data.define` / `Struct.new` here is what keeps sig-gen's view of a
219
+ # value class from drifting from the analyser's: the walker that populates them ({Inference::ScopeIndexer})
220
+ # already covers the constant-assigned form (`Point = Data.define(:x, :y)`), the named-subclass form
221
+ # (`class Point < Data.define(:x, :y)`), a `::Data` receiver, and the `keyword_init:` flag. sig-gen's earlier
222
+ # private recogniser covered none of the last three and was the reason #227's output was wrong rather than
223
+ # merely thin.
224
+ MetaLayout = Data.define(:kind, :member_names, :keyword_init)
225
+ private_constant :MetaLayout
217
226
 
218
- full = (prefix + [node.name.to_s]).join("::")
219
- @class_shells << full
220
- @namespace_kinds[full] = :class
221
- end
227
+ def collect_meta_layouts(scope_index)
228
+ scope = scope_index.each_value.first
229
+ return {} if scope.nil?
222
230
 
223
- DATA_STRUCT_SHELL_HEADS = {
224
- "Data" => :define,
225
- "Struct" => :new
226
- }.freeze
227
- private_constant :DATA_STRUCT_SHELL_HEADS
231
+ layouts = {}
232
+ scope.data_member_layouts.each do |name, members|
233
+ layouts[name] = MetaLayout.new(kind: :data, member_names: members, keyword_init: false)
234
+ end
235
+ scope.struct_member_layouts.each do |name, layout|
236
+ layouts[name] = MetaLayout.new(kind: :struct, member_names: layout[:members],
237
+ keyword_init: layout[:keyword_init])
238
+ end
239
+ layouts
240
+ end
228
241
 
229
- def data_or_struct_call?(value)
230
- return false unless value.is_a?(Prism::CallNode)
242
+ # ADR-14 gap-#3 (e): a `Const = Data.define(...)` / `Const = Struct.new(...)` assignment declares a class the
243
+ # source never spells with a `class` keyword — the runtime stamps an anonymous class at the rvalue and binds
244
+ # it to `Const` — so the generated RBS needs an explicit `class Const` of its own. Without it, references to
245
+ # `Const` in a return type fail to resolve under Steep (the canonical case is
246
+ # `GemResolver::Resolved | GemResolver::Unresolvable`, where `Unresolvable = Data.define(:gem_name, :reason)`).
247
+ #
248
+ # Every layout-carrying class therefore gets the `class` keyword in `@namespace_kinds` (so the leaf wins over
249
+ # the intermediate-segment `module` default), its `::Data` / `::Struct[untyped]` ancestry, and a `@class_shells`
250
+ # entry so the writer declares it even when every member candidate is suppressed as already-declared. The
251
+ # named-subclass form is registered too: its `class` keyword is not in question, but its computed superclass
252
+ # is exactly what {#record_superclass} refuses to guess at.
253
+ def register_meta_classes
254
+ @meta_layouts.each do |class_name, layout|
255
+ @namespace_kinds[class_name] = :class
256
+ @class_shells << class_name
257
+ @class_superclasses[class_name] = MetaClassShape::SUPERCLASSES.fetch(layout.kind)
258
+ end
259
+ end
231
260
 
232
- receiver = value.receiver
233
- return false unless receiver.is_a?(Prism::ConstantReadNode)
261
+ # The `do ... end` body of a `Const = Data.define(...) do ... end` assignment, when `Const` carries a layout.
262
+ # Defs inside that block bind on `Const`, NOT on the enclosing namespace — the runtime `class_eval`s the block
263
+ # into the anonymous class it just stamped. Attributing them to the enclosing namespace is what made sig-gen
264
+ # report `VoidOrigin#label` against `Rigor::Inference` and then, because a method-bearing leaf defaults to the
265
+ # `class` keyword, redeclare that module as a class (#227).
266
+ def meta_block_body(node, prefix)
267
+ return nil unless node.value.is_a?(Prism::CallNode)
268
+ return nil unless @meta_layouts.key?((prefix + [node.name.to_s]).join("::"))
234
269
 
235
- DATA_STRUCT_SHELL_HEADS[receiver.name.to_s] == value.name
270
+ node.value.block&.body
236
271
  end
237
272
 
238
273
  # Module / class bodies are walked through the `walk_statements` path so `module_function` (no-args)
@@ -824,6 +859,12 @@ module Rigor
824
859
  # Skip method bodies — attr_* there would refer to whatever the method is doing dynamically, not a
825
860
  # class-level declaration.
826
861
  return
862
+ when Prism::ConstantWriteNode
863
+ body = meta_block_body(node, prefix)
864
+ if body
865
+ walk_attr_calls(body, prefix + [node.name.to_s], false, ctx)
866
+ return
867
+ end
827
868
  when Prism::CallNode
828
869
  collect_attr_call(node, prefix, in_singleton_class, ctx)
829
870
  end
@@ -1065,6 +1106,64 @@ module Rigor
1065
1106
  when :writer then "def #{method_name}: (#{erased}) -> #{wrapped}"
1066
1107
  end
1067
1108
  end
1109
+
1110
+ # One candidate per member accessor and constructor {MetaClassShape} renders for each layout-carrying class.
1111
+ # These are the members no `def` or `attr_*` in the source declares, so nothing else in sig-gen can find them —
1112
+ # and once `register_meta_classes` has declared the class, an undeclared member reads as a missing one.
1113
+ def collect_meta_member_candidates(path, scope_index)
1114
+ return [] if @meta_layouts.empty?
1115
+
1116
+ scope = scope_index.each_value.first
1117
+ environment = scope&.environment
1118
+ @meta_layouts.flat_map do |class_name, layout|
1119
+ types = meta_member_types(class_name, layout.member_names)
1120
+ shape = MetaClassShape.of(
1121
+ kind: layout.kind, members: layout.member_names, keyword_init: layout.keyword_init,
1122
+ member_types: types.transform_values { |type| paren_wrap_union(union_erase([type])) }
1123
+ )
1124
+ shape.member_decls.filter_map do |member|
1125
+ meta_member_candidate(path, class_name, member, types, scope, environment)
1126
+ end
1127
+ end
1128
+ end
1129
+
1130
+ def meta_member_candidate(path, class_name, member, types, scope, environment)
1131
+ existing = lookup_existing_method(class_name, member.method_name, member.kind, environment, scope)
1132
+ return nil if declared_on_class_itself?(existing, class_name)
1133
+
1134
+ build_candidate(
1135
+ path: path, class_name: class_name, method_name: member.method_name, kind: member.kind,
1136
+ classification: Classification::NEW_METHOD,
1137
+ inferred_return: types[member.source_member] || Type::Combinator.untyped,
1138
+ rbs: member.rbs
1139
+ )
1140
+ end
1141
+
1142
+ # Whether an RBS declaration found for a member is the user's own, i.e. sits on this very class. Inheritance
1143
+ # is the whole point of the distinction: `::Data.new: () -> bot` and `::Struct.new`'s factory both answer the
1144
+ # `.new` lookup for every value class, and deferring to them is what leaves the arity false positive in place.
1145
+ def declared_on_class_itself?(method_def, class_name)
1146
+ return false if method_def.nil?
1147
+ return false unless method_def.respond_to?(:defined_in)
1148
+
1149
+ method_def.defined_in.to_s.delete_prefix("::") == class_name
1150
+ end
1151
+
1152
+ # `--params=observed` member types. {ObservationCollector} routes `Point.new(...)` call sites to
1153
+ # `[class_name, :initialize]`, so a keyword call site names its member directly, while a positional one is
1154
+ # matched by index — and only from call sites whose arity covers the whole member list, so a one-argument
1155
+ # `Point.new(attrs)` shim cannot type member 0 as `Hash`. Empty without `--params=observed`.
1156
+ def meta_member_types(class_name, members)
1157
+ observations = @observations[[class_name, :initialize]] || []
1158
+ return {} if observations.empty?
1159
+
1160
+ full_arity = observations.select { |obs| obs.positional.size == members.size }
1161
+ members.each_with_index.to_h do |member, index|
1162
+ observed = observations.filter_map { |obs| obs.keyword[member] } +
1163
+ full_arity.filter_map { |obs| obs.positional[index] }
1164
+ [member, observed.empty? ? nil : Type::Combinator.union(*observed)]
1165
+ end.compact
1166
+ end
1068
1167
  end
1069
1168
  end
1070
1169
  end
@@ -74,6 +74,12 @@ module Rigor
74
74
 
75
75
  def index_file(rbs_path, accumulator)
76
76
  source = rbs_path.read
77
+ # Pre-parser encoding guard (mirrors `Environment::RbsLoader.invalid_encoding?`): on rbs releases
78
+ # before 4.1 the C lexer could hang on an invalid UTF-8 byte, and a hang is the one failure the
79
+ # rescue below cannot absorb. Skipping is safe here for the same reason the rescue is — placement
80
+ # falls back as for any unparseable file, and the env loader's quarantine warning names the file.
81
+ return unless source.valid_encoding?
82
+
77
83
  _, _, decls = RBS::Parser.parse_signature(source)
78
84
  record_decls(decls, [], rbs_path, accumulator)
79
85
  rescue StandardError
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module SigGen
5
+ # The RBS surface of a class the runtime builds from `Data.define(...)` / `Struct.new(...)` — its ancestry, its
6
+ # synthesised member accessors, and its constructors — rendered from an ADR-48 member layout.
7
+ #
8
+ # Declaring the class without them would be worse than not declaring it at all. `::Data`'s own RBS declares
9
+ # `def self.new: () -> bot` and `::Struct`'s declares only the `Struct.new("Name", :a, :b)` factory, so a
10
+ # subclass that carries no `.new` of its own turns every `Point.new(1, 2)` into a false-positive arity error —
11
+ # where the undeclared class it replaced merely typed as `Dynamic` and reported nothing. The same reasoning
12
+ # covers the member readers and `.[]` (`Point[1, 2]`, which `::Data`'s RBS does not declare): narrowing dispatch
13
+ # from `Dynamic` to a nominal class means everything the runtime synthesises must be declared, or it reads as
14
+ # missing.
15
+ #
16
+ # Member types arrive already erased to RBS and already parenthesised for return position. One pre-wrapped
17
+ # string serves every position this module interpolates into — `(a: (String | Integer))`,
18
+ # `((String | Integer) a)`, `?(String | Integer) a` all parse and validate.
19
+ module MetaClassShape
20
+ # `::Struct` is generic (`class Struct[E]`), and RBS rejects a bare `< ::Struct` with
21
+ # `InvalidTypeApplicationError`. `untyped` is the only element type a layout can justify.
22
+ SUPERCLASSES = { data: "::Data", struct: "::Struct[untyped]" }.freeze
23
+
24
+ # `method_name` / `kind` feed the writer's existing-member matching. `source_member` is the layout member a
25
+ # reader / writer derives from, and nil for a constructor, so the caller can attach the member's carrier to
26
+ # the candidate it builds.
27
+ Member = ::Data.define(:method_name, :kind, :rbs, :source_member)
28
+ Shape = ::Data.define(:superclass, :member_decls)
29
+
30
+ module_function
31
+
32
+ # @param kind [:data, :struct]
33
+ # @param members [Array<Symbol>] ordered member names from the ADR-48 layout.
34
+ # @param member_types [Hash{Symbol => String}] erased RBS spellings; a member with no entry renders `untyped`.
35
+ # @param keyword_init [Boolean] the `Struct.new(..., keyword_init: true)` flag; ignored for `:data`.
36
+ # @return [Shape]
37
+ def of(kind:, members:, member_types: {}, keyword_init: false)
38
+ types = members.to_h { |member| [member, member_types[member] || "untyped"] }
39
+ accessors = kind == :struct ? struct_accessors(members, types) : readers(members, types)
40
+ Shape.new(superclass: SUPERCLASSES.fetch(kind),
41
+ member_decls: accessors + constructors(kind, members, types, keyword_init))
42
+ end
43
+
44
+ def readers(members, types)
45
+ members.map do |member|
46
+ Member.new(method_name: member, kind: :instance, source_member: member,
47
+ rbs: "def #{member}: () -> #{types[member]}")
48
+ end
49
+ end
50
+
51
+ # A Struct's members are mutable, so each one contributes a writer too. The writer returns the assigned
52
+ # value's type — Ruby's assignment semantics, and the spelling the `attr_writer` path already emits.
53
+ def struct_accessors(members, types)
54
+ writers = members.map do |member|
55
+ Member.new(method_name: :"#{member}=", kind: :instance, source_member: member,
56
+ rbs: "def #{member}=: (#{types[member]}) -> #{types[member]}")
57
+ end
58
+ readers(members, types) + writers
59
+ end
60
+
61
+ # `.new` and its `.[]` alias share one overload list.
62
+ def constructors(kind, members, types, keyword_init)
63
+ overloads = constructor_overloads(kind, members, types, keyword_init).join(" | ")
64
+ %i[new []].map do |name|
65
+ Member.new(method_name: name, kind: :singleton, source_member: nil, rbs: "def self.#{name}: #{overloads}")
66
+ end
67
+ end
68
+
69
+ # `Data` requires every member; a Struct fills a missing one with `nil`, so its positions are all optional.
70
+ #
71
+ # `keyword_init: true` accepts keyword arguments only. Every other layout gets BOTH forms, because the flag
72
+ # reads `false` for an absent `keyword_init:` as well as for a literal `keyword_init: false` — and since Ruby
73
+ # 3.2 the absent case, by far the dominant one, accepts both. Emitting both is the false-positive-free reading
74
+ # of an ambiguity the layout cannot resolve.
75
+ def constructor_overloads(kind, members, types, keyword_init)
76
+ optional = kind == :struct ? "?" : ""
77
+ keyword = "(#{members.map { |m| "#{optional}#{m}: #{types[m]}" }.join(', ')}) -> instance"
78
+ return [keyword] if kind == :struct && keyword_init
79
+
80
+ [keyword, "(#{members.map { |m| "#{optional}#{types[m]} #{m}" }.join(', ')}) -> instance"]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -66,7 +66,7 @@ module Rigor
66
66
 
67
67
  def render_classes(items)
68
68
  items.group_by(&:class_name).each do |class_name, methods|
69
- @out.puts("class #{class_name}")
69
+ @out.puts(declaration_header(methods.first, class_name))
70
70
  methods.each do |candidate|
71
71
  tag = case candidate.classification
72
72
  when Classification::NEW_METHOD then "[new]"
@@ -81,6 +81,17 @@ module Rigor
81
81
  end
82
82
  end
83
83
 
84
+ # The keyword and ancestry for a printed group, from the same per-file maps the `--write` path reads. Print
85
+ # mode used to hard-code `class`, which turned a module into a class the moment it held an emittable method —
86
+ # output that raises `RBS::DuplicatedDeclarationError` on load if the real `module` is declared anywhere else
87
+ # (#227). Defaulting to `class` when the map has no entry keeps the pre-existing spelling for a leaf class.
88
+ def declaration_header(candidate, class_name)
89
+ return "module #{class_name}" if candidate.namespace_kinds[class_name] == :module
90
+
91
+ superclass = candidate.class_superclasses[class_name]
92
+ superclass ? "class #{class_name} < #{superclass}" : "class #{class_name}"
93
+ end
94
+
84
95
  def render_diff(candidates)
85
96
  if candidates.empty?
86
97
  @out.puts("No candidates")
@@ -127,6 +138,7 @@ module Rigor
127
138
  when :updated then render_write_updated(result)
128
139
  when :skipped_outside_sig_root then render_write_skipped(result)
129
140
  when :skipped_invalid_rbs then render_write_invalid(result)
141
+ when :skipped_invalid_encoding then render_write_invalid_encoding(result)
130
142
  end
131
143
  end
132
144
  end
@@ -153,6 +165,13 @@ module Rigor
153
165
  @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
154
166
  end
155
167
 
168
+ # The EXISTING target file is not valid UTF-8, so the writer will not merge into it — the opposite
169
+ # attribution from {#render_write_invalid}: this one is the file's problem, and the fix is the user's.
170
+ def render_write_invalid_encoding(result)
171
+ @out.puts("REFUSED #{result.target_path} — the existing file is not valid UTF-8, so it was not updated")
172
+ @out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
173
+ end
174
+
156
175
  def render_write_json(results)
157
176
  @out.puts(JSON.pretty_generate({ results: results.map(&:to_h) }))
158
177
  end
@@ -11,13 +11,15 @@ module Rigor
11
11
  # - `target_path` — `.rbs` file the writer was responsible for (`nil` when the source path falls outside
12
12
  # the project signature tree, in which case `action` is `:skipped_outside_sig_root`).
13
13
  # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root` /
14
- # `:skipped_invalid_rbs`.
14
+ # `:skipped_invalid_rbs` / `:skipped_invalid_encoding`.
15
15
  # - `applied` — the {MethodCandidate}s that actually landed on disk.
16
16
  # - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
17
17
  # entry pairs the candidate with a skip reason keyword (`:user_authored`).
18
- # - `error` — the parse error, when `action` is `:skipped_invalid_rbs`: the file the writer assembled does
19
- # not parse, so it was NOT written (writing it would poison the project's sig tree — the consumer
20
- # quarantines an unparseable `.rbs`, taking every other type in that file down with it).
18
+ # - `error` — the refusal cause, when `action` is a refusal: for `:skipped_invalid_rbs` the file the writer
19
+ # assembled does not parse, so it was NOT written (writing it would poison the project's sig tree — the
20
+ # consumer quarantines an unparseable `.rbs`, taking every other type in that file down with it); for
21
+ # `:skipped_invalid_encoding` the EXISTING target file is not valid UTF-8, so the writer refuses to merge
22
+ # into content it cannot read faithfully.
21
23
  class WriteResult
22
24
  attr_reader :source_path, :target_path, :action, :applied, :skipped, :error
23
25