rigortype 0.3.1 → 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 (101) 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/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -65,9 +65,11 @@ module Rigor
65
65
  # intentionally read-only and appended LAST so user-supplied `signature_paths` win on name conflicts.
66
66
  def build_env_for(libraries:, signature_paths:, virtual_rbs: [])
67
67
  rbs_loader = RBS::EnvironmentLoader.new
68
- libraries.each do |library|
69
- next unless rbs_loader.has_library?(library: library, version: nil)
70
-
68
+ libraries = libraries_without_shadowed_bigdecimal_math(libraries)
69
+ loaded_libraries = libraries.select do |library|
70
+ rbs_loader.has_library?(library: library, version: nil)
71
+ end
72
+ loaded_libraries.each do |library|
71
73
  rbs_loader.add(library: library, version: nil)
72
74
  end
73
75
  # Project `signature_paths:` are loaded per-file by {.add_project_signatures} AFTER `from_loader`,
@@ -76,16 +78,7 @@ module Rigor
76
78
  # type-of query then degrades to `Dynamic[top]` — the "sig looks harmful" failure of the 2026-07-06
77
79
  # mastodon coverage note). Per-file loading quarantines the broken file instead. Vendored / core-overlay
78
80
  # sigs are Rigor-shipped and trusted, so they stay on the loader's fast batch path.
79
- vendored_gem_sig_paths.each do |path|
80
- rbs_loader.add(path: path) if path.directory?
81
- end
82
- # Rigor-owned core overlay — loaded LAST so an upstream declaration always wins on conflict; these
83
- # reopenings only fill genuine holes (e.g. `Numeric#to_f`/`to_i`/`to_r`, which upstream RBS
84
- # declares on the concrete subclasses but not on the abstract `Numeric` that Rigor's
85
- # arithmetic-chain widening produces).
86
- core_overlay_sig_paths.each do |path|
87
- rbs_loader.add(path: path) if path.directory?
88
- end
81
+ add_bundled_signatures(rbs_loader, loaded_libraries.to_set(&:to_s))
89
82
  env = RBS::Environment.from_loader(rbs_loader)
90
83
  add_project_signatures(env, signature_paths)
91
84
  add_virtual_rbs(env, virtual_rbs)
@@ -94,10 +87,66 @@ module Rigor
94
87
  stub_missing_referenced_types(env, resolved, project_sig_files(signature_paths))
95
88
  end
96
89
 
90
+ # rbs ships `stdlib/bigdecimal/` and `stdlib/bigdecimal-math/` as two libraries, so
91
+ # `Environment::DEFAULT_LIBRARIES` names both. But `RBS::EnvironmentLoader` resolves a library to an
92
+ # INSTALLED GEM's `sig/` in preference to rbs's own `stdlib/` copy, and the `bigdecimal` gem has
93
+ # shipped `sig/big_math.rbs` — declaring the very same `BigMath` module — since 4.0. On such a host
94
+ # both declarations land in one environment, `RBS::DefinitionBuilder` raises
95
+ # `DuplicatedMethodDefinitionError` on `BigMath.E`, and the WHOLE module silently degrades to
96
+ # `Dynamic[top]` (issue #299) — every `BigMath` call, real method and typo alike, stops being
97
+ # witnessed.
98
+ #
99
+ # `bigdecimal-math` is the entry to drop, not `bigdecimal`: the gem's copy is maintained alongside
100
+ # the implementation, types `log10` / `log1p` / `expm1` / `tan` / `tanh` (which rbs's copy omits
101
+ # outright), and accepts `real | BigDecimal` where rbs's accepts only `BigDecimal`. Dropping it is
102
+ # therefore a strict typing gain, not a trade.
103
+ #
104
+ # Host-dependent by construction, and deliberately so: where `bigdecimal` resolves to rbs's own
105
+ # `stdlib/bigdecimal/` (an older gem with no `sig/`, or no gem at all), nothing collides, nothing is
106
+ # dropped, and `bigdecimal-math` still supplies `BigMath`.
107
+ BIGDECIMAL_LIBRARY = "bigdecimal"
108
+ private_constant :BIGDECIMAL_LIBRARY
109
+ BIGDECIMAL_MATH_LIBRARY = "bigdecimal-math"
110
+ private_constant :BIGDECIMAL_MATH_LIBRARY
111
+ # The one basename that decides it: `BigMath` lives in `big_math.rbs` on both sides.
112
+ BIG_MATH_SIG_BASENAME = "big_math.rbs"
113
+ private_constant :BIG_MATH_SIG_BASENAME
114
+
115
+ # @param libraries [Array<String>] the resolved library list, `DEFAULT_LIBRARIES` included.
116
+ # @return [Array<String>] the same list, minus `bigdecimal-math` when `bigdecimal` already brings
117
+ # `BigMath` in.
118
+ def libraries_without_shadowed_bigdecimal_math(libraries)
119
+ return libraries unless libraries.include?(BIGDECIMAL_LIBRARY) && libraries.include?(BIGDECIMAL_MATH_LIBRARY)
120
+ return libraries unless bigdecimal_library_declares_big_math?
121
+
122
+ libraries - [BIGDECIMAL_MATH_LIBRARY]
123
+ end
124
+
125
+ # Resolves `library: "bigdecimal"` on a THROWAWAY loader (never the one building the env) and reports
126
+ # whether any directory it resolves to ships `big_math.rbs`. Fails soft to `false`: an unresolvable
127
+ # library must not take the whole environment build down, and keeping both entries is the
128
+ # pre-existing behaviour.
129
+ def bigdecimal_library_declares_big_math?
130
+ probe = ::RBS::EnvironmentLoader.new(core_root: nil)
131
+ return false unless probe.has_library?(library: BIGDECIMAL_LIBRARY, version: nil)
132
+
133
+ probe.add(library: BIGDECIMAL_LIBRARY, version: nil)
134
+ shadowed = false
135
+ probe.each_dir { |_source, dir| shadowed ||= dir.join(BIG_MATH_SIG_BASENAME).file? }
136
+ shadowed
137
+ rescue StandardError
138
+ false
139
+ end
140
+
97
141
  # True when `content` parses as an RBS signature. {#virtual_rbs_collision_quarantined} uses this to
98
142
  # tell a collision-dropped virtual entry (parses, but absent from the env) from a parse-failed one
99
143
  # (the synthesizer's own WD6 skip, reported separately).
100
144
  def parseable_rbs?(content)
145
+ # Pre-parser encoding guard ({.invalid_encoding?}): invalid UTF-8 raises `ArgumentError` (not
146
+ # `ParsingError`) out of `RBS::Parser.magic_comment`'s regex on rbs 4.1, escaping the rescue below,
147
+ # and could hang the C lexer outright on the older releases the gemspec supports.
148
+ return false if invalid_encoding?(content)
149
+
101
150
  ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor: virtual parse check)", content: content))
102
151
  true
103
152
  rescue ::RBS::BaseError
@@ -148,21 +197,29 @@ module Rigor
148
197
  # calls to `Dynamic[Top]` instead (the same no-false-positive contract as the dependency-source
149
198
  # tier).
150
199
  #
151
- # Detection re-uses RBS's own builder (correct by construction): build every PROJECT class and read
152
- # the missing name out of the raised error. Bounded to `signature_paths` classes (stdlib / vendored
153
- # RBS is well-formed) and to {MAX_STUB_PASSES} iterations — a fresh stub can expose a deeper
154
- # reference the first build error hid, but empty stubs reference nothing, so the fixpoint converges
155
- # quickly.
200
+ # Detection reads the PROJECT declarations and mirrors rbs's own membership test
201
+ # ({.unresolved_referenced_types}); it is bounded to `signature_paths` classes (stdlib / vendored RBS
202
+ # is well-formed) and to {MAX_STUB_PASSES} iterations — a fresh stub can expose a deeper reference the
203
+ # first pass could not see past, but empty stubs reference nothing, so the fixpoint converges quickly.
156
204
  MAX_STUB_PASSES = 5
157
205
 
158
206
  def stub_missing_referenced_types(base_env, resolved, project_files)
159
207
  return resolved if project_files.empty?
160
208
 
209
+ previous = nil
161
210
  MAX_STUB_PASSES.times do
162
211
  missing = unresolved_referenced_types(resolved, project_files)
163
212
  break if missing.empty?
164
213
 
165
- append_stub_declarations(base_env, missing)
214
+ # Bound the fixpoint by PROGRESS, not by the cap alone. A pass that appends no declaration, or
215
+ # that re-detects the set it already saw, cannot converge — and before this guard the cap was the
216
+ # only stop, so the pathological input paid the full detection sweep five times over (measured on
217
+ # herb: five passes, nothing synthesized). The cap stays as the backstop for a genuinely deepening
218
+ # chain of references.
219
+ current = missing.to_set
220
+ break if current == previous || !append_stub_declarations(base_env, missing)
221
+
222
+ previous = current
166
223
  resolved = base_env.resolve_type_names
167
224
  end
168
225
  resolved
@@ -240,25 +297,48 @@ module Rigor
240
297
  end
241
298
  end
242
299
 
300
+ # The quarantine note for a file rejected by {.invalid_encoding?} — worded to be distinct from any
301
+ # rbs-emitted parse error so specs (and users) can tell Rigor's pre-parser skip from the parser's own
302
+ # UTF-8 diagnostics.
303
+ INVALID_ENCODING_NOTE = "not valid UTF-8 — skipped before reaching the RBS parser"
304
+
305
+ # Pre-parser guard for content Rigor hands to `RBS::Parser`. On rbs 4.1+ an invalid UTF-8 byte is a
306
+ # clean `ParsingError` (ruby/rbs#2983), but on the older releases the gemspec supports (`>= 3.0,
307
+ # < 5.0`) the C lexer could infinite-loop or abort on it (fixed upstream in ruby/rbs#2973) — a hang no
308
+ # `rescue` can catch, and the one failure mode the quarantine's fail-soft rescues cannot absorb. So
309
+ # the check runs before the parser on every rbs version: uniform behaviour, and the quarantine note
310
+ # stays actionable ("fix the file's encoding") rather than version-dependent.
311
+ def invalid_encoding?(content)
312
+ !content.valid_encoding?
313
+ end
314
+
243
315
  # Parse one project `.rbs` into `[buffer, directives, decls]`, or nil when it is unparseable /
244
- # unreadable. Mirrors `RBS::EnvironmentLoader#each_signature`'s per-file parse so the decls register
245
- # identically to the loader's batch path.
316
+ # unreadable / not valid UTF-8. Mirrors `RBS::EnvironmentLoader#each_signature`'s per-file parse so
317
+ # the decls register identically to the loader's batch path.
246
318
  def parse_signature_file(file)
247
- buffer = ::RBS::Buffer.new(name: file, content: File.read(file, encoding: "UTF-8"))
319
+ content = File.read(file, encoding: "UTF-8")
320
+ return nil if invalid_encoding?(content)
321
+
322
+ buffer = ::RBS::Buffer.new(name: file, content: content)
248
323
  _buffer, directives, decls = ::RBS::Parser.parse_signature(buffer)
249
324
  [buffer, directives, decls]
250
325
  rescue ::RBS::ParsingError, Errno::ENOENT, Errno::EISDIR, Errno::EACCES
251
326
  nil
252
327
  end
253
328
 
254
- # The project `signature_paths:` files that FAIL to parse, as `[absolute_path, first_error_line]` pairs
255
- # (sorted, deterministic). Detection is independent of {.add_project_signatures} so the warning fires
256
- # even on a cache hit (where the env was already built with the file quarantined). Cheap: it only
257
- # re-parses the user's own (usually small) `sig/` set, and returns empty immediately when there is no
258
- # `signature_paths:`.
329
+ # The project `signature_paths:` files that FAIL to parse (or are not valid UTF-8), as
330
+ # `[absolute_path, first_error_line]` pairs (sorted, deterministic). Detection is independent of
331
+ # {.add_project_signatures} so the warning fires even on a cache hit (where the env was already built
332
+ # with the file quarantined). Cheap: it only re-parses the user's own (usually small) `sig/` set, and
333
+ # returns empty immediately when there is no `signature_paths:`.
259
334
  def quarantined_project_signatures(signature_paths)
260
335
  project_sig_files(signature_paths).sort.filter_map do |file|
261
- buffer = ::RBS::Buffer.new(name: file, content: File.read(file, encoding: "UTF-8"))
336
+ # The note carries the path itself because the warn composer prints only this element — a
337
+ # `ParsingError` message embeds its `path:line:` prefix, so the composer never adds one.
338
+ content = File.read(file, encoding: "UTF-8")
339
+ next [file, "#{file}: #{INVALID_ENCODING_NOTE}"] if invalid_encoding?(content)
340
+
341
+ buffer = ::RBS::Buffer.new(name: file, content: content)
262
342
  ::RBS::Parser.parse_signature(buffer)
263
343
  nil
264
344
  rescue ::RBS::ParsingError => e
@@ -268,26 +348,122 @@ module Rigor
268
348
  end
269
349
  end
270
350
 
271
- # Builds every project class (instance + singleton side) and returns the `::`-stripped names of the
272
- # types whose absence raised `NoTypeFoundError`. Only the FIRST missing reference per class surfaces
273
- # per build, which is why the caller loops.
351
+ # The `::`-stripped names of every type a PROJECT signature references that no loaded declaration
352
+ # provides the input to {.append_stub_declarations}.
353
+ #
354
+ # Detection READS the declarations; it does not build them. Until #207 it built every project class
355
+ # instance- and singleton-side with a throwaway `RBS::DefinitionBuilder` and recovered the name from
356
+ # the raised `NoTypeFoundError` — correct by construction, and **7.84M allocations, a third of a cold
357
+ # `check lib`** on Rigor's own tree, to find nothing once `sig/` is self-consistent. The walk below
358
+ # costs ~21k.
359
+ #
360
+ # It mirrors rbs's raise sites, so the answer is the builder's:
361
+ #
362
+ # * `DefinitionBuilder#validate_type_presence` over the ARGS of a super class, a module `self` type,
363
+ # and a mixin. The name itself is deliberately NOT checked: a missing super class or mixin raises
364
+ # `NoSuperclassFoundError` / `NoMixinFoundError`, which this pass has never stubbed.
365
+ # * `VarianceCalculator#type` over every method type `validate_type_params` reaches, which raises for
366
+ # `ClassInstance` / `Interface` / `Alias` only. It skips `initialize`, and it is not called for the
367
+ # singleton side — so a name reachable only through `def initialize:` or `def self.x:` is not
368
+ # reported here, exactly as the builder did not report it. Stubbing those names anyway cost
369
+ # allocations on the corpus and changed no diagnostic.
370
+ #
371
+ # Membership is decided with the builder's own predicate ({.declared_reference?}), so a name reported
372
+ # here is one no declaration in the env provides. A resolvable name can never be reported, which is
373
+ # what keeps the stub safe: ADR-5 tier 2 trades precision for a fail-soft, and a stub for a name that
374
+ # WOULD have resolved would shadow a real type instead.
375
+ #
376
+ # One reduction in scope. The builder also walked each project class's ANCESTORS, so a dangling
377
+ # reference inside a *gem's* signature reachable from a project class was reported too; this walk
378
+ # reads project declarations only. No such name occurs across the eight RBS-shipping projects the
379
+ # change was measured on, and the cost of missing one is the fail-soft `Dynamic` ADR-5 tier 2 already
380
+ # accepts — not a false diagnostic. A project INTERFACE's own dangling reference is likewise not
381
+ # reported, and likewise was not by the builder (`validate_type_params` does not variance-walk the
382
+ # methods a class imports from an interface) — pinned by spec so the two stay together.
383
+ #
384
+ # Equivalence with the builder sweep is pinned by spec, which keeps the sweep as its oracle. Full
385
+ # evaluation: `docs/notes/20260730-stub-pass1-static-detection-evaluation.md`.
274
386
  def unresolved_referenced_types(env, project_files)
275
- builder = ::RBS::DefinitionBuilder.new(env: env)
276
- missing = []
277
- env.class_decls.each do |type_name, entry|
387
+ missing = {}
388
+ checked = {}
389
+ env.class_decls.each_value do |entry|
278
390
  next unless project_entry?(entry, project_files)
279
391
 
280
- %i[build_instance build_singleton].each do |build|
281
- builder.public_send(build, type_name)
282
- rescue ::RBS::NoTypeFoundError => e
283
- name = e.message[/Could not find (\S+)/, 1]
284
- missing << name.sub(/\A::/, "") if name
285
- rescue ::RBS::BaseError
286
- # Other build failures (duplicate decl, mixin cycle, ...) are not ours to repair here leave
287
- # them fail-soft.
392
+ entry_declarations(entry).each { |decl| collect_declaration_references(env, decl, missing, checked) }
393
+ end
394
+ missing.keys
395
+ end
396
+
397
+ # Decl-level references the builder validates: a super class's / module self type's / mixin's type
398
+ # ARGUMENTS, plus every member. `super_class` is a `Declarations::Class` accessor and `self_types` a
399
+ # `Declarations::Module` one, so both are reached behind a shape check.
400
+ def collect_declaration_references(env, decl, missing, checked)
401
+ if decl.respond_to?(:super_class)
402
+ decl.super_class&.args&.each { |arg| collect_type_references(env, arg, missing, checked) }
403
+ end
404
+ if decl.respond_to?(:self_types)
405
+ decl.self_types&.each do |self_type|
406
+ self_type.args.each { |arg| collect_type_references(env, arg, missing, checked) }
407
+ end
408
+ end
409
+ collect_member_references(env, decl.members, missing, checked)
410
+ end
411
+
412
+ # Member-level references. `initialize` and the singleton side are skipped because
413
+ # `validate_type_params` never reaches them (see {.unresolved_referenced_types}); `:singleton_instance`
414
+ # (`def self?.x`) defines the instance side too, so it is NOT skipped.
415
+ def collect_member_references(env, members, missing, checked)
416
+ members.each do |member|
417
+ next if member.respond_to?(:kind) && member.kind == :singleton
418
+
419
+ case member
420
+ when ::RBS::AST::Members::MethodDefinition
421
+ next if member.name == :initialize
422
+
423
+ member.overloads.each do |overload|
424
+ collect_type_references(env, overload.method_type, missing, checked)
425
+ end
426
+ when ::RBS::AST::Members::AttrReader, ::RBS::AST::Members::AttrWriter,
427
+ ::RBS::AST::Members::AttrAccessor
428
+ collect_type_references(env, member.type, missing, checked)
429
+ when ::RBS::AST::Members::Include, ::RBS::AST::Members::Extend,
430
+ ::RBS::AST::Members::Prepend
431
+ member.args.each { |arg| collect_type_references(env, arg, missing, checked) }
288
432
  end
289
433
  end
290
- missing.uniq
434
+ end
435
+
436
+ # Walks one type (or method type) and appends the names no declaration provides. Only the three node
437
+ # classes `VarianceCalculator#type` raises for carry a checkable name; everything else is traversed for
438
+ # the types nested inside it.
439
+ def collect_type_references(env, type, missing, checked)
440
+ return if type.nil?
441
+
442
+ case type
443
+ when ::RBS::Types::ClassInstance, ::RBS::Types::Interface, ::RBS::Types::Alias
444
+ name = type.name
445
+ name = name.absolute! unless name.absolute?
446
+ missing[name.to_s.sub(/\A::/, "")] = true unless declared_reference?(env, name, checked)
447
+ end
448
+ return unless type.respond_to?(:each_type)
449
+
450
+ type.each_type do |nested|
451
+ collect_type_references(env, nested, missing, checked)
452
+ end
453
+ end
454
+
455
+ # `DefinitionBuilder#validate_type_name`'s membership test, memoised per env walk (a project's method
456
+ # signatures name the same handful of types over and over). A name whose normalization raises is
457
+ # treated as declared: repairing it is not this pass's job, and a wrong stub is the worse failure.
458
+ def declared_reference?(env, name, checked)
459
+ key = name.to_s
460
+ return checked[key] if checked.key?(key)
461
+
462
+ checked[key] = begin
463
+ env.type_name?(env.normalize_type_name(name))
464
+ rescue StandardError
465
+ true
466
+ end
291
467
  end
292
468
 
293
469
  # Normalises a `class_decls` entry's representative declaration across the gemspec's supported RBS
@@ -304,6 +480,24 @@ module Rigor
304
480
  end
305
481
  end
306
482
 
483
+ # Collects the AST declaration nodes behind a `class_decls` entry across the supported RBS range (`rbs
484
+ # >= 3.0, < 5.0`). RBS 4's `ModuleEntry` / `ClassEntry` expose `each_decl` yielding bare AST
485
+ # declarations; RBS 3.x exposes `decls`, an array of `MultiEntry::D` wrappers whose `#decl` is the AST
486
+ # declaration. The single-`decl` shape is handled defensively so the loader survives an rbs-gem minor
487
+ # bump. Class-side because both the env-build detection walk and the instance-side
488
+ # `#names_synthesized_in` need it, and the guard must stay single-rooted.
489
+ def entry_declarations(entry)
490
+ if entry.respond_to?(:each_decl)
491
+ [].tap { |acc| entry.each_decl { |decl| acc << decl } }
492
+ elsif entry.respond_to?(:decls)
493
+ entry.decls.map { |d| d.respond_to?(:decl) ? d.decl : d }
494
+ elsif entry.respond_to?(:decl)
495
+ [entry.decl]
496
+ else
497
+ []
498
+ end
499
+ end
500
+
307
501
  # Appends freshly-parsed declarations to an `RBS::Environment` across the gemspec's supported RBS
308
502
  # range (`rbs >= 3.0, < 5.0`). RBS 4.x wraps the declarations in an `RBS::Source::RBS` and takes them
309
503
  # through `env.add_source`; RBS 3.x has neither `RBS::Source` nor `add_source` and instead registers
@@ -336,9 +530,17 @@ module Rigor
336
530
  end
337
531
 
338
532
  # Adds empty stub declarations for the missing referenced types (and any enclosing namespace they
339
- # need) to the pre-resolve env, tagged with {SYNTHETIC_STUB_BUFFER}. A name that is a prefix of
340
- # another name is declared `module` (it is a namespace); a leaf is declared `class` (referenced types
341
- # appear in instance position far more often than as mixins).
533
+ # need) to the pre-resolve env, tagged with {SYNTHETIC_STUB_BUFFER}. Returns true when at least one
534
+ # declaration landed, so {.stub_missing_referenced_types} can stop on a pass that made no progress.
535
+ #
536
+ # Each name gets the declaration kind its own syntax requires ({.stub_declaration_for}), and each
537
+ # declaration is validated ALONE before it joins the buffer. Declaring every name `class` — the shape
538
+ # before #237 — made a dangling interface (`_Foo`) or type-alias (`foo`) reference unparseable, and
539
+ # since the batch shares one buffer the `RBS::BaseError` rescue below then discarded every stub in it,
540
+ # well-formed ones included. Measured on herb, whose 74 missing names are all dangling type aliases:
541
+ # the pass synthesized nothing at all, leaving the project's 48 signature files inert, while still
542
+ # paying for the detection sweep. A per-declaration check costs one small parse per name and bounds
543
+ # the damage of one bad name to itself.
342
544
  #
343
545
  # Names already declared in `base_env` are skipped — exactly the `declared.include?` guard
344
546
  # {.collect_missing_namespaces} applies. Without it, stubbing a nested reference (`Foo::Bar::Baz`)
@@ -356,17 +558,45 @@ module Rigor
356
558
  (1...parts.length).each { |i| names << parts[0, i].join("::") }
357
559
  end
358
560
  names = names.reject { |name| declared.include?(name) }.to_set
359
- return if names.empty?
561
+ return false if names.empty?
360
562
 
361
- source = names.sort_by { |n| n.count(":") }.map do |name|
362
- keyword = names.any? { |other| other != name && other.start_with?("#{name}::") } ? "module" : "class"
363
- "#{keyword} #{name}\nend\n"
563
+ source = names.sort_by { |n| n.count(":") }.filter_map do |name|
564
+ declaration = stub_declaration_for(name, names)
565
+ declaration if parseable_rbs?(declaration)
364
566
  end.join
567
+ return false if source.empty?
568
+
365
569
  buffer = ::RBS::Buffer.new(name: SYNTHETIC_STUB_BUFFER, content: source)
366
570
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
367
571
  add_parsed_decls(base_env, buffer, directives, decls)
572
+ true
368
573
  rescue ::RBS::BaseError
369
- nil
574
+ false
575
+ end
576
+
577
+ # The declaration one stubbed name needs, keyed on the syntax of its leaf:
578
+ #
579
+ # * a name other stubbed names nest inside is a namespace, so `module`;
580
+ # * an RBS interface name (`_Foo`) may only be declared `interface`;
581
+ # * a type-alias name (`foo`) may only be declared `type`, and aliases `untyped` so a value of that
582
+ # type reads as `Dynamic[Top]` — the honest answer for a type Rigor invented;
583
+ # * anything else is `class` (referenced types appear in instance position far more often than as
584
+ # mixins).
585
+ #
586
+ # The interface stub is FP-safe without joining {#synthesized_type_names}: {RbsTypeTranslator} maps
587
+ # every interface type to untyped, and the nil / non-nil acceptance guards in {Analysis::CheckRules}
588
+ # treat a method-less interface as accepting everything.
589
+ def stub_declaration_for(name, names)
590
+ leaf = name.split("::").last.to_s
591
+ if names.any? { |other| other != name && other.start_with?("#{name}::") }
592
+ "module #{name}\nend\n"
593
+ elsif leaf.start_with?("_")
594
+ "interface #{name}\nend\n"
595
+ elsif leaf.match?(/\A[a-z]/)
596
+ "type #{name} = untyped\n"
597
+ else
598
+ "class #{name}\nend\n"
599
+ end
370
600
  end
371
601
 
372
602
  # The `::`-stripped names of every class / module / class-alias declaration already present in
@@ -389,6 +619,9 @@ module Rigor
389
619
 
390
620
  virtual_rbs.each do |filename, content|
391
621
  next if content.nil? || content.empty?
622
+ # Same pre-parser guard as {.parse_signature_file}: a synthesizer echoing project bytes can carry
623
+ # invalid UTF-8, which pre-4.1 rbs lexers could hang on — and a hang escapes the rescue below.
624
+ next if invalid_encoding?(content.to_s)
392
625
 
393
626
  buffer = ::RBS::Buffer.new(name: filename.to_s, content: content.to_s)
394
627
  _, directives, decls = ::RBS::Parser.parse_signature(buffer)
@@ -437,6 +670,71 @@ module Rigor
437
670
  [Pathname(CORE_OVERLAY_SIGS_ROOT)]
438
671
  end
439
672
 
673
+ # Bundled signature sources that SUPPLEMENT a stdlib library's own declarations rather than stand
674
+ # alone: each re-opens a class the named `RBS::EnvironmentLoader` library declares with a mixin
675
+ # (`CGI::QueryExtension`), a superclass (`Prism::Result`), or an `| ...` overload continuation
676
+ # (`StringScanner#[]`, `Resolv#initialize`). Loading one into an environment built WITHOUT its
677
+ # library does not fail the env build — `RBS::DefinitionBuilder` raises later
678
+ # (`NoMixinFoundError` / `NoSuperclassFoundError` / `InvalidOverloadMethodError`), Rigor fails soft,
679
+ # and the WHOLE re-opened class silently degrades to `Dynamic[top]` (issue #299's narrow-environment
680
+ # tail). So each supplement is loaded ONLY when its library actually resolved; in production every
681
+ # gating library is in `Environment::DEFAULT_LIBRARIES`, so the full environment is unchanged.
682
+ #
683
+ # `core_overlay/pathname.rbs` also carries an `| ...` continuation but is deliberately NOT listed:
684
+ # its base (`Pathname#expand_path`) lives in rbs's `core/pathname.rbs` on every supported rbs
685
+ # release (3.10 / 4.0 / 4.1), so the continuation is safe — and applicable — with no library loaded.
686
+ #
687
+ # Keyed by `data/vendored_gem_sigs/` directory basename.
688
+ LIBRARY_SUPPLEMENT_VENDORED_DIRS = {
689
+ "cgi" => "cgi",
690
+ "prism" => "prism"
691
+ }.freeze
692
+ private_constant :LIBRARY_SUPPLEMENT_VENDORED_DIRS
693
+
694
+ # Keyed by `data/core_overlay/` file basename. Files not listed here re-open Ruby-core classes (or
695
+ # add self-contained declarations) and stay unconditional.
696
+ LIBRARY_SUPPLEMENT_CORE_OVERLAYS = {
697
+ "resolv.rbs" => "resolv",
698
+ "string_scanner.rbs" => "strscan"
699
+ }.freeze
700
+ private_constant :LIBRARY_SUPPLEMENT_CORE_OVERLAYS
701
+
702
+ # Adds the Rigor-shipped signature sources to `rbs_loader`: every `data/vendored_gem_sigs/<gem>/`
703
+ # directory, then the `data/core_overlay/` files — the overlay LAST so an upstream declaration
704
+ # always wins on conflict (these reopenings only fill genuine holes, e.g. `Numeric#to_f`/`to_i`/
705
+ # `to_r`, which upstream RBS declares on the concrete subclasses but not on the abstract `Numeric`
706
+ # that Rigor's arithmetic-chain widening produces). The overlay is added per-file, not
707
+ # per-directory, because the `LIBRARY_SUPPLEMENT_CORE_OVERLAYS` files must be gated individually.
708
+ #
709
+ # @param loaded_library_names [Set<String>] libraries that actually resolved on this loader.
710
+ def add_bundled_signatures(rbs_loader, loaded_library_names)
711
+ vendored_gem_sig_paths.each do |path|
712
+ next unless path.directory?
713
+ next unless supplement_dependency_loaded?(LIBRARY_SUPPLEMENT_VENDORED_DIRS, path, loaded_library_names)
714
+
715
+ rbs_loader.add(path: path)
716
+ end
717
+ core_overlay_sig_paths.each do |dir|
718
+ next unless dir.directory?
719
+
720
+ dir.children.sort.each do |file|
721
+ next unless file.file? && file.extname == ".rbs"
722
+ next unless supplement_dependency_loaded?(LIBRARY_SUPPLEMENT_CORE_OVERLAYS, file, loaded_library_names)
723
+
724
+ rbs_loader.add(path: file)
725
+ end
726
+ end
727
+ end
728
+
729
+ # @param supplements [Hash{String => String}] basename → gating library map.
730
+ # @param path [Pathname] the vendored directory or overlay file to test.
731
+ # @param loaded_library_names [Set<String>] libraries that actually resolved on this loader.
732
+ # @return [Boolean] true when `path` carries no library dependency, or its library loaded.
733
+ def supplement_dependency_loaded?(supplements, path, loaded_library_names)
734
+ library = supplements[path.basename.to_s]
735
+ library.nil? || loaded_library_names.include?(library)
736
+ end
737
+
440
738
  # Rigor-owned per-gem RBS overlays (`data/gem_overlay/<gem>/`), ADR-72. Unlike the unconditional
441
739
  # `core_overlay`, each gem's overlay is loaded ONLY when that gem is locked in the project's
442
740
  # Gemfile.lock but ships no RBS of its own — {Environment.for_project} decides eligibility and passes
@@ -506,10 +804,11 @@ module Rigor
506
804
  @virtual_rbs = virtual_rbs.map { |name, content| [name.to_s.dup.freeze, content.to_s.dup.freeze].freeze }.freeze
507
805
  # Per-loader memoization bucket. Held as a single mutable Hash so the loader instance itself can be
508
806
  # `.freeze`d (per ADR-15 reflection-facade contract) without losing the lazy-memo behaviour. Slot
509
- # names currently consulted: `:env`, `:env_loaded`, `:env_build_warned`, `:builder`, `:reflection`,
510
- # `:instance_definitions_table`, `:singleton_definitions_table`. Constructed via `Hash.new` (NOT a
511
- # `{ ... }` literal) so Rigor's `HashShape` narrowing doesn't infer a fixed key set from the initial
512
- # state and fold post-initial slot reads (e.g. `@state[:env_loaded]`) to a constant `nil`.
807
+ # names currently consulted: `:env`, `:env_loaded`, `:env_build_warned`, `:definition_build_warned`,
808
+ # `:builder`, `:reflection`, `:instance_definitions_table`, `:singleton_definitions_table`.
809
+ # Constructed via `Hash.new` (NOT a `{ ... }` literal) so Rigor's `HashShape` narrowing doesn't
810
+ # infer a fixed key set from the initial state and fold post-initial slot reads (e.g.
811
+ # `@state[:env_loaded]`) to a constant `nil`.
513
812
  @state = Hash.new # rubocop:disable Style/EmptyLiteral
514
813
  @instance_definition_cache = {}
515
814
  @singleton_definition_cache = {}
@@ -580,6 +879,11 @@ module Rigor
580
879
  # that mention them could build (e.g. an unavailable `DRb::DRbServer`, or a stale
581
880
  # `Textbringer::EditorError`). Recovered off the built env like {#synthesized_namespaces}, so it
582
881
  # survives the marshalled-env cache.
882
+ #
883
+ # Class / module stubs only — the `interface` and `type` stubs {.stub_declaration_for} also emits live in
884
+ # `interface_decls` / `type_alias_decls`, not `class_decls`. That is deliberate: this list exists to feed
885
+ # {#synthesized_type_names}, whose consumers key on a nominal receiver's class name, and both of those
886
+ # kinds already read as untyped through {Inference::RbsTypeTranslator}.
583
887
  def synthesized_stub_types
584
888
  names_synthesized_in(SYNTHETIC_STUB_BUFFER)
585
889
  end
@@ -963,7 +1267,7 @@ module Rigor
963
1267
  return [] if e.nil?
964
1268
 
965
1269
  names = e.class_decls.filter_map do |type_name, entry|
966
- decls = entry_declarations(entry)
1270
+ decls = self.class.entry_declarations(entry)
967
1271
  next if decls.empty?
968
1272
  next unless decls.all? { |decl| synthetic_decl?(decl, buffer_name) }
969
1273
 
@@ -972,23 +1276,6 @@ module Rigor
972
1276
  names.sort_by { |name| name.count("::") }
973
1277
  end
974
1278
 
975
- # Collects the AST declaration nodes behind a `class_decls` entry across the supported RBS range (`rbs
976
- # >= 3.0, < 5.0`). RBS 4's `ModuleEntry` / `ClassEntry` expose `each_decl` yielding bare AST
977
- # declarations; RBS 3.x exposes `decls`, an array of `MultiEntry::D` wrappers whose `#decl` is the AST
978
- # declaration. The single-`decl` shape is handled defensively so the loader survives an rbs-gem minor
979
- # bump.
980
- def entry_declarations(entry)
981
- if entry.respond_to?(:each_decl)
982
- [].tap { |acc| entry.each_decl { |decl| acc << decl } }
983
- elsif entry.respond_to?(:decls)
984
- entry.decls.map { |d| d.respond_to?(:decl) ? d.decl : d }
985
- elsif entry.respond_to?(:decl)
986
- [entry.decl]
987
- else
988
- []
989
- end
990
- end
991
-
992
1279
  # True when an AST declaration was emitted into `buffer_name` (one of the synthetic-source sentinels)
993
1280
  # — identified by the buffer name on its location.
994
1281
  def synthetic_decl?(decl, buffer_name)
@@ -1230,7 +1517,8 @@ module Rigor
1230
1517
  return nil unless env.class_decls.key?(rbs_name)
1231
1518
 
1232
1519
  builder.build_instance(rbs_name)
1233
- rescue ::RBS::BaseError
1520
+ rescue ::RBS::BaseError => e
1521
+ warn_about_definition_build_failure(class_name, e)
1234
1522
  nil
1235
1523
  end
1236
1524
 
@@ -1243,10 +1531,81 @@ module Rigor
1243
1531
  return nil unless env.class_decls.key?(rbs_name)
1244
1532
 
1245
1533
  builder.build_singleton(rbs_name)
1246
- rescue ::RBS::BaseError
1534
+ rescue ::RBS::BaseError => e
1535
+ warn_about_definition_build_failure(class_name, e)
1247
1536
  nil
1248
1537
  end
1249
1538
 
1539
+ # The third twin of {#warn_about_quarantined_signatures} / {#warn_about_virtual_rbs_collisions}: name,
1540
+ # once per class per PROCESS, a `RBS::DefinitionBuilder` failure caught in
1541
+ # {#build_instance_definition} / {#build_singleton_definition}'s rescue. Without it, `class_known?`
1542
+ # stays true (it only consults {#known_class_names_set}, never a definition build), so every call on
1543
+ # the class — real methods and typos alike — silently degrades to `Dynamic[top]`.
1544
+ #
1545
+ # STRUCTURAL DIVERGENCE from its two siblings: they fire from the single central site at the end of
1546
+ # {#env}, because the whole env is built eagerly and every quarantine/collision is already known by
1547
+ # then. Definition builds are LAZY (ADR-54 WD1 — built on demand per class the FIRST time a caller asks,
1548
+ # long after {#env} has already run), so there is no later central checkpoint to fire from before the
1549
+ # affected classes even exist. This warns inline at the rescue site instead, gated on
1550
+ # `@state[:definition_build_warned]` (keyed by class name) so the instance and singleton sides — and
1551
+ # any re-entry once the per-process `@instance_definition_cache` / `@singleton_definition_cache`
1552
+ # memoize the failure — warn at most once per class name, cache-hit runs included (a definition build
1553
+ # is per-process regardless of the RBS-env cache tier). `@state` is per-LOADER-INSTANCE, not
1554
+ # process-global, so under the fork-based analysis pool each worker holds its own loader and its own
1555
+ # `@state`: a class whose definition fails can print its warning once per worker that happens to touch
1556
+ # it, i.e. more than once in a single `rigor check` run. Deduplicating that across processes is out of
1557
+ # scope here — see [#295](https://github.com/rigortype/rigor/issues/295).
1558
+ def warn_about_definition_build_failure(class_name, error)
1559
+ warned = (@state[:definition_build_warned] ||= {})
1560
+ key = class_name.to_s
1561
+ return if warned[key]
1562
+
1563
+ warned[key] = true
1564
+ first_line = error.message.to_s.lines.first.to_s.strip
1565
+ buffers = definition_build_conflict_buffers(error)
1566
+ collisions =
1567
+ if buffers.empty?
1568
+ ""
1569
+ else
1570
+ listed = buffers.first(QUARANTINE_WARN_LIMIT)
1571
+ more = buffers.size - listed.size
1572
+ lines = listed.map { |name| " - #{name}" }
1573
+ lines << " … and #{more} more" if more.positive?
1574
+ "\n Colliding declaration(s):\n#{lines.join("\n")}"
1575
+ end
1576
+ warn(
1577
+ "rigor: RBS definition build failed for `#{class_name}`: #{error.class}: #{first_line}\n " \
1578
+ "Rigor still treats the class as known, so calls into it now silently degrade to\n " \
1579
+ "`Dynamic[top]` — real methods and typos alike — instead of resolving normally.#{collisions}"
1580
+ )
1581
+ end
1582
+
1583
+ # The definition-build twin of {#env_build_conflict_buffers}: the declaration source file(s) named by a
1584
+ # `RBS::DefinitionBuilder` failure, so {#warn_about_definition_build_failure} can name the colliding
1585
+ # declarations rather than only the exception's class and message. The payload shape varies by error
1586
+ # class (`references/rbs`'s `lib/rbs/errors.rb`): `DuplicatedMethodDefinitionError` and
1587
+ # `InvalidOverloadMethodError` carry `#members` (each with a `#location`);
1588
+ # `DuplicatedInterfaceMethodDefinitionError` carries a single `#member`; most others
1589
+ # (`NoSuperclassFoundError`, `UnknownMethodAliasError`, `RecursiveAncestorError`, …) carry only a bare
1590
+ # `#location`. Falls back to an empty list — the warning still names the class and the exception's own
1591
+ # message — for the remainder (e.g. `SuperclassMismatchError`, which carries neither).
1592
+ def definition_build_conflict_buffers(error)
1593
+ locations =
1594
+ if error.respond_to?(:members) && error.members
1595
+ Array(error.members).map(&:location)
1596
+ elsif error.respond_to?(:member) && error.member
1597
+ [error.member.location]
1598
+ elsif error.respond_to?(:location)
1599
+ [error.location]
1600
+ else
1601
+ []
1602
+ end
1603
+
1604
+ locations.compact.filter_map { |loc| loc.buffer&.name }.map(&:to_s).uniq.freeze
1605
+ rescue ::RBS::BaseError, StandardError
1606
+ [].freeze
1607
+ end
1608
+
1250
1609
  # Resolve an RBS class/module ALIAS to its canonical declared name. `class Mutex = Thread::Mutex`
1251
1610
  # lives only in `class_alias_decls`, so `class_known?` reports it (it checks that table) but the
1252
1611
  # definition builder — which only knows `class_decls` — could not enumerate its methods, leaving alias