rigortype 0.3.1 → 0.3.3

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 +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
data/lib/rigor/scope.rb CHANGED
@@ -23,8 +23,10 @@ module Rigor
23
23
  :indexed_narrowings, :method_chain_narrowings,
24
24
  :declaration_sourced,
25
25
  :source_path, :discovery, :struct_fold_safe_locals,
26
+ :opaque_block_self,
26
27
  :dynamic_origins, :local_origins, :ivar_origins,
27
- :void_origins
28
+ :void_origins,
29
+ :optimistic_origins, :optimistic_locals, :optimistic_ivars
28
30
 
29
31
  # ADR-53 Track A — the seed-time discovery tables live on the {DiscoveryIndex} the scope carries by a single
30
32
  # reference; the per-table readers stay on Scope so engine call sites and plugins are unaffected by the
@@ -146,10 +148,14 @@ module Rigor
146
148
  declaration_sourced: EMPTY_DECLARATION_SOURCED,
147
149
  source_path: nil,
148
150
  struct_fold_safe_locals: EMPTY_FOLD_SAFE,
151
+ opaque_block_self: false,
149
152
  dynamic_origins: {}.compare_by_identity,
150
153
  local_origins: EMPTY_ORIGINS,
151
154
  ivar_origins: EMPTY_ORIGINS,
152
- void_origins: {}.compare_by_identity
155
+ void_origins: {}.compare_by_identity,
156
+ optimistic_origins: {}.compare_by_identity,
157
+ optimistic_locals: EMPTY_ORIGINS,
158
+ optimistic_ivars: EMPTY_ORIGINS
153
159
  )
154
160
  @environment = environment
155
161
  @locals = locals
@@ -164,13 +170,41 @@ module Rigor
164
170
  @declaration_sourced = declaration_sourced
165
171
  @source_path = source_path
166
172
  @struct_fold_safe_locals = struct_fold_safe_locals
173
+ @opaque_block_self = opaque_block_self
167
174
  @dynamic_origins = dynamic_origins
168
175
  @local_origins = local_origins
169
176
  @ivar_origins = ivar_origins
170
177
  @void_origins = void_origins
178
+ @optimistic_origins = optimistic_origins
179
+ @optimistic_locals = optimistic_locals
180
+ @optimistic_ivars = optimistic_ivars
171
181
  freeze
172
182
  end
173
183
 
184
+ # Issue #286 — the {Inference::OptimisticOrigin} cause attached to a call node whose result is nil-free
185
+ # only because `RbsDispatch` reads past `%a{implicitly-returns-nil}`, or `nil` when the value's
186
+ # nil-freeness is a property of its class. Mirrors {#dynamic_origins} / {#void_origins}: advisory
187
+ # metadata, ignored by `==` / `hash`, and never varying a flow decision on its own.
188
+ def record_optimistic_origin(node, cause)
189
+ @optimistic_origins[node] = cause
190
+ self
191
+ end
192
+
193
+ def optimistic_local(name) = @optimistic_locals[name.to_sym]
194
+ def optimistic_ivar(name) = @optimistic_ivars[name.to_sym]
195
+
196
+ def with_optimistic_local(name, cause)
197
+ return self if cause.nil?
198
+
199
+ rebuild(optimistic_locals: @optimistic_locals.merge(name.to_sym => cause).freeze)
200
+ end
201
+
202
+ def with_optimistic_ivar(name, cause)
203
+ return self if cause.nil?
204
+
205
+ rebuild(optimistic_ivars: @optimistic_ivars.merge(name.to_sym => cause).freeze)
206
+ end
207
+
174
208
  # ADR-82 WD1 — the propagated origin of the `Dynamic` value currently bound to a local / instance
175
209
  # variable, or `nil` when none is tracked. Consulted by `Inference::ProtectionScanner` when a dispatch's
176
210
  # receiver is a bare `x` / `@x` read whose own node carries no origin.
@@ -215,7 +249,8 @@ module Rigor
215
249
  indexed_narrowings: new_indexed_narrowings,
216
250
  method_chain_narrowings: new_chain_narrowings,
217
251
  declaration_sourced: drop_declaration_sourced_for(:local, name),
218
- local_origins: drop_origin(@local_origins, name))
252
+ local_origins: drop_origin(@local_origins, name),
253
+ optimistic_locals: drop_origin(@optimistic_locals, name))
219
254
  end
220
255
 
221
256
  def with_fact(fact)
@@ -242,6 +277,21 @@ module Rigor
242
277
  rebuild(struct_fold_safe_locals: locals)
243
278
  end
244
279
 
280
+ # Issue #316 — marks a block body whose `self` Rigor does not model. Ruby gives a block no `self` of its
281
+ # own: the yielding method decides, and `instance_eval` / `instance_exec` (the mechanism behind every
282
+ # `self`-rebinding DSL — RSpec example groups, `Class.new { … }`, Rake, Sinatra) is indistinguishable from
283
+ # `Array#each` without knowing the callee. The flag is set at every block entry that leaves `self_type`
284
+ # unnarrowed and is inherited by every scope derived inside the block; it never leaks past the block,
285
+ # because `eval_call` returns the caller's scope unchanged.
286
+ def entering_opaque_block
287
+ return self if @opaque_block_self
288
+
289
+ rebuild(opaque_block_self: true)
290
+ end
291
+
292
+ # True when this scope sits inside a block whose `self` is unmodelled ({#entering_opaque_block}).
293
+ def opaque_block_self? = @opaque_block_self
294
+
245
295
  # True when `name`'s `Struct` member reads are fold-safe in this body (the local is provably never mutated /
246
296
  # aliased / escaped).
247
297
  def struct_fold_safe?(name)
@@ -281,7 +331,8 @@ module Rigor
281
331
  indexed_narrowings: new_indexed_narrowings,
282
332
  method_chain_narrowings: new_chain_narrowings,
283
333
  declaration_sourced: drop_declaration_sourced_for(:ivar, name),
284
- ivar_origins: drop_origin(@ivar_origins, name))
334
+ ivar_origins: drop_origin(@ivar_origins, name),
335
+ optimistic_ivars: drop_origin(@optimistic_ivars, name))
285
336
  end
286
337
 
287
338
  # ADR-58 WD1 — used by the method-entry seed to mark an ivar whose only provenance is the class-ivar index.
@@ -319,8 +370,11 @@ module Rigor
319
370
  # whose type is an open-call-site *lower bound* — firing against a lower bound is a false positive by
320
371
  # construction (the ADR-67 WD1 reasoning at the parameter boundary, carried one hop into the body). The
321
372
  # distinct kind keeps the inferred-param sites separable from ADR-58's ivar-copy `:local` mark, which a
322
- # later un-guarding slice (WD6b) needs. `with_local` drops it on any flow-live rewrite of the local
323
- # (`drop_local_declaration_marks`), so only the pristine parameter binding carries it.
373
+ # later un-guarding slice (WD6b) needs and the two kinds behave OPPOSITELY on both axes: `:local` is
374
+ # dropped by `with_local` and intersected by `join`, while `:inferred_param` is sticky across `with_local`
375
+ # and unioned by `join`. See {#without_inferred_param_mark} below for the clearing contract, and
376
+ # `docs/internal-spec/inference-engine.md` § "Declaration-sourced provenance mark (ADR-58)" for the
377
+ # normative statement of both.
324
378
  def with_inferred_param_mark(name)
325
379
  rebuild(declaration_sourced: add_declaration_sourced(:inferred_param, name))
326
380
  end
@@ -394,11 +448,13 @@ module Rigor
394
448
  # recognised `define_method` invocation inside class/module bodies. The `rigor check` undefined-method and
395
449
  # wrong-arity rules consult this map to suppress diagnostics for methods the user has defined dynamically,
396
450
  # even when no RBS sig describes them.
451
+ # A name defined on both sides of one class records {DiscoveryIndex::METHOD_KIND_BOTH} and matches either kind.
397
452
  def discovered_method?(class_name, method_name, kind)
398
453
  table = @discovery.discovered_methods[class_name.to_s]
399
454
  return false unless table
400
455
 
401
- table[method_name.to_sym] == kind
456
+ recorded = table[method_name.to_sym]
457
+ recorded == kind || recorded == DiscoveryIndex::METHOD_KIND_BOTH
402
458
  end
403
459
 
404
460
  # ADR-34 § "Decision" — predicate identifying a toplevel-shaped scope (no enclosing `class` / `module` body).
@@ -470,6 +526,43 @@ module Rigor
470
526
  Inference::DefNodeResolver.resolve(entry)
471
527
  end
472
528
 
529
+ # Issue #316 — the CONFIDENCE-GATED companion of {#top_level_def_for}, and the only accessor the type
530
+ # inference may bind through. {#top_level_def_for} stays unrestricted because it also serves the
531
+ # *suppression* side (`call.unresolved-toplevel`, `call.undefined-method`): a name the project defines at
532
+ # the top level must never be reported as unresolved, whatever this gate decides.
533
+ #
534
+ # Returns nil — decline to bind, stay silent — when BOTH hold:
535
+ #
536
+ # 1. The call site sits inside a block whose `self` is unmodelled ({#opaque_block_self?}) and no narrowed
537
+ # `self_type` says otherwise. A top-level `def` is a private method on `Object`, so it is *callable*
538
+ # from any `self`; what the analyzer cannot see is whether the block's real `self` gained a PUBLIC
539
+ # same-named method by `include` / `extend`, which wins the MRO over the private `Object` def. RSpec's
540
+ # `output` / `include` / `match` matchers against a project's own `def output` are exactly this.
541
+ # 2. The `def` lives in a DIFFERENT file from the call site. Collocation is the evidence that the two
542
+ # belong to one lexical structure — the `RSpec.describe do; def helper; end; it { helper } end` case
543
+ # v0.0.3 A and #319 deliberately serve. Cross-file, the two share only a name.
544
+ #
545
+ # Both conditions are required, so a top-level helper called from genuine top-level code keeps resolving
546
+ # (cross-file included), and a helper defined beside its DSL-block call site keeps resolving too. When the
547
+ # project pre-pass recorded no source for the name, the file test cannot be answered and the historical
548
+ # bind is kept.
549
+ def bindable_top_level_def_for(method_name)
550
+ node = top_level_def_for(method_name)
551
+ return node if node.nil?
552
+ return node unless @opaque_block_self && @self_type.nil?
553
+
554
+ same_file_top_level_def?(method_name) ? node : nil
555
+ end
556
+
557
+ def same_file_top_level_def?(method_name)
558
+ key = Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY
559
+ site = discovered_def_sources.dig(key, method_name.to_sym)
560
+ return true if site.nil? || @source_path.nil?
561
+
562
+ File.expand_path(site.sub(/:\d+\z/, "")) == File.expand_path(@source_path)
563
+ end
564
+ private :same_file_top_level_def?
565
+
473
566
  # ADR-46 slice 3 — a top-level (`def helper` outside any class) call has NO class ancestry to walk, so unlike
474
567
  # {#user_def_for} a miss here records no positive ancestry edge that would re-check the consumer when the
475
568
  # method later appears. Record the cross-file edge explicitly: the file defining the top-level method
@@ -721,10 +814,14 @@ module Rigor
721
814
  declaration_sourced: @declaration_sourced,
722
815
  source_path: @source_path,
723
816
  struct_fold_safe_locals: @struct_fold_safe_locals,
817
+ opaque_block_self: @opaque_block_self,
724
818
  dynamic_origins: @dynamic_origins,
725
819
  local_origins: @local_origins,
726
820
  ivar_origins: @ivar_origins,
727
- void_origins: @void_origins
821
+ void_origins: @void_origins,
822
+ optimistic_origins: @optimistic_origins,
823
+ optimistic_locals: @optimistic_locals,
824
+ optimistic_ivars: @optimistic_ivars
728
825
  )
729
826
  self.class.new(
730
827
  environment: environment, locals: locals,
@@ -736,10 +833,14 @@ module Rigor
736
833
  declaration_sourced: declaration_sourced,
737
834
  source_path: source_path,
738
835
  struct_fold_safe_locals: struct_fold_safe_locals,
836
+ opaque_block_self: opaque_block_self,
739
837
  dynamic_origins: dynamic_origins,
740
838
  local_origins: local_origins,
741
839
  ivar_origins: ivar_origins,
742
- void_origins: void_origins
840
+ void_origins: void_origins,
841
+ optimistic_origins: optimistic_origins,
842
+ optimistic_locals: optimistic_locals,
843
+ optimistic_ivars: optimistic_ivars
743
844
  )
744
845
  end
745
846
 
@@ -777,7 +878,10 @@ module Rigor
777
878
  dynamic_origins: @dynamic_origins,
778
879
  local_origins: join_origins(@local_origins, other.local_origins),
779
880
  ivar_origins: join_origins(@ivar_origins, other.ivar_origins),
780
- void_origins: @void_origins
881
+ void_origins: @void_origins,
882
+ optimistic_origins: @optimistic_origins,
883
+ optimistic_locals: join_origins(@optimistic_locals, other.optimistic_locals),
884
+ optimistic_ivars: join_origins(@optimistic_ivars, other.optimistic_ivars)
781
885
  )
782
886
  end
783
887
 
@@ -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
 
@@ -275,6 +275,18 @@ module Rigor
275
275
 
276
276
  def update_existing(source_path, target, candidates)
277
277
  source = target.read
278
+ # Pre-parser encoding guard, mirroring `Environment::RbsLoader.invalid_encoding?`: handed invalid
279
+ # UTF-8, `RBS::Parser` raises a bare `ArgumentError` on rbs 4.1 (not the `ParsingError` that
280
+ # `parse_signature` below rescues) and could hang outright on the older releases the gemspec
281
+ # supports. The refusal is loud — unlike an unparseable file (`:noop`, surfaced by `rigor check`'s
282
+ # own quarantine warning), a mojibake file would otherwise fail with a stack trace naming neither
283
+ # the file nor the fix.
284
+ unless source.valid_encoding?
285
+ return WriteResult.new(source_path: source_path, target_path: target,
286
+ action: :skipped_invalid_encoding,
287
+ error: "#{target} is not valid UTF-8")
288
+ end
289
+
278
290
  decls = parse_signature(source)
279
291
  return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
280
292
 
data/lib/rigor/sig_gen.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "sig_gen/classification"
4
4
  require_relative "sig_gen/method_candidate"
5
5
  require_relative "sig_gen/observed_call"
6
+ require_relative "sig_gen/meta_class_shape"
6
7
  require_relative "sig_gen/type_elaborator"
7
8
  require_relative "sig_gen/observation_collector"
8
9
  require_relative "sig_gen/generator"