rigortype 0.1.19 → 0.2.1

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 (197) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -6
  3. data/data/core_overlay/numeric.rbs +33 -0
  4. data/data/core_overlay/pathname.rbs +25 -0
  5. data/data/core_overlay/string_scanner.rbs +28 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
  7. data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
  8. data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
  9. data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
  10. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
  11. data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
  12. data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
  13. data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
  14. data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
  15. data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
  16. data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
  17. data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
  18. data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
  19. data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
  21. data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
  22. data/data/vendored_gem_sigs/redis/future.rbs +5 -0
  23. data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
  24. data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
  25. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
  26. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  29. data/lib/rigor/analysis/check_rules.rb +492 -71
  30. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  31. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  32. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  33. data/lib/rigor/analysis/fact_store.rb +5 -4
  34. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  36. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  37. data/lib/rigor/analysis/runner.rb +17 -6
  38. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  39. data/lib/rigor/analysis/worker_session.rb +10 -14
  40. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  41. data/lib/rigor/cache/store.rb +5 -3
  42. data/lib/rigor/cli/annotate_command.rb +28 -7
  43. data/lib/rigor/cli/baseline_command.rb +4 -3
  44. data/lib/rigor/cli/check_command.rb +138 -16
  45. data/lib/rigor/cli/coverage_command.rb +138 -31
  46. data/lib/rigor/cli/coverage_mutation.rb +149 -0
  47. data/lib/rigor/cli/coverage_scan.rb +57 -0
  48. data/lib/rigor/cli/explain_command.rb +2 -0
  49. data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
  50. data/lib/rigor/cli/fused_protection_report.rb +76 -0
  51. data/lib/rigor/cli/lsp_command.rb +3 -7
  52. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  53. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  54. data/lib/rigor/cli/options.rb +9 -0
  55. data/lib/rigor/cli/plugins_command.rb +2 -1
  56. data/lib/rigor/cli/protection_renderer.rb +63 -0
  57. data/lib/rigor/cli/protection_report.rb +68 -0
  58. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  59. data/lib/rigor/cli/trace_command.rb +2 -1
  60. data/lib/rigor/cli/triage_command.rb +2 -1
  61. data/lib/rigor/cli/type_of_command.rb +1 -1
  62. data/lib/rigor/cli/type_scan_command.rb +2 -1
  63. data/lib/rigor/cli.rb +3 -2
  64. data/lib/rigor/config_audit.rb +152 -0
  65. data/lib/rigor/configuration/dependencies.rb +2 -4
  66. data/lib/rigor/configuration.rb +57 -7
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  68. data/lib/rigor/environment/class_registry.rb +4 -3
  69. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  70. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  71. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  72. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  73. data/lib/rigor/environment/rbs_loader.rb +76 -5
  74. data/lib/rigor/environment.rb +66 -8
  75. data/lib/rigor/flow_contribution/fact.rb +1 -1
  76. data/lib/rigor/flow_contribution.rb +3 -5
  77. data/lib/rigor/inference/acceptance.rb +17 -9
  78. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  79. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  80. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  81. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  82. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  83. data/lib/rigor/inference/expression_typer.rb +20 -28
  84. data/lib/rigor/inference/hkt_body.rb +8 -11
  85. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  86. data/lib/rigor/inference/hkt_registry.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  88. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +169 -24
  89. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  90. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  91. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  92. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  93. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  94. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  95. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  96. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  97. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  98. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  99. data/lib/rigor/inference/mutation_widening.rb +5 -11
  100. data/lib/rigor/inference/narrowing.rb +14 -16
  101. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  102. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  103. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  104. data/lib/rigor/inference/protection_scanner.rb +86 -0
  105. data/lib/rigor/inference/scope_indexer.rb +129 -55
  106. data/lib/rigor/inference/statement_evaluator.rb +271 -114
  107. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  108. data/lib/rigor/inference/synthetic_method.rb +7 -7
  109. data/lib/rigor/language_server/completion_provider.rb +6 -12
  110. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  111. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  112. data/lib/rigor/language_server/hover_provider.rb +2 -3
  113. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  114. data/lib/rigor/language_server/server.rb +9 -17
  115. data/lib/rigor/language_server.rb +4 -5
  116. data/lib/rigor/plugin/base.rb +10 -8
  117. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  118. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  119. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  120. data/lib/rigor/plugin/macro.rb +4 -5
  121. data/lib/rigor/plugin/manifest.rb +45 -66
  122. data/lib/rigor/plugin/registry.rb +6 -7
  123. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  124. data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
  125. data/lib/rigor/protection/mutation_scanner.rb +180 -0
  126. data/lib/rigor/protection/mutator.rb +267 -0
  127. data/lib/rigor/protection/test_suite_oracle.rb +68 -0
  128. data/lib/rigor/rbs_extended.rb +24 -36
  129. data/lib/rigor/reflection.rb +4 -7
  130. data/lib/rigor/scope/discovery_index.rb +14 -2
  131. data/lib/rigor/scope.rb +54 -11
  132. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  133. data/lib/rigor/sig_gen/writer.rb +40 -2
  134. data/lib/rigor/signature_path_audit.rb +92 -0
  135. data/lib/rigor/source/constant_path.rb +62 -0
  136. data/lib/rigor/source.rb +1 -0
  137. data/lib/rigor/type/bound_method.rb +2 -11
  138. data/lib/rigor/type/combinator.rb +16 -3
  139. data/lib/rigor/type/constant.rb +2 -11
  140. data/lib/rigor/type/data_class.rb +2 -11
  141. data/lib/rigor/type/data_instance.rb +2 -11
  142. data/lib/rigor/type/hash_shape.rb +2 -11
  143. data/lib/rigor/type/integer_range.rb +2 -11
  144. data/lib/rigor/type/intersection.rb +2 -11
  145. data/lib/rigor/type/nominal.rb +2 -11
  146. data/lib/rigor/type/plain_lattice.rb +37 -0
  147. data/lib/rigor/type/refined.rb +72 -13
  148. data/lib/rigor/type/singleton.rb +2 -11
  149. data/lib/rigor/type/struct_class.rb +75 -0
  150. data/lib/rigor/type/struct_instance.rb +93 -0
  151. data/lib/rigor/type/tuple.rb +5 -15
  152. data/lib/rigor/type.rb +2 -0
  153. data/lib/rigor/version.rb +1 -1
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  156. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  163. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  165. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  166. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  168. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  171. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  173. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  174. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  175. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  176. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  179. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  182. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  189. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  190. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  191. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  195. data/sig/rigor/scope.rbs +9 -1
  196. data/sig/rigor/type.rbs +36 -1
  197. metadata +49 -1
@@ -12,6 +12,7 @@ require_relative "method_dispatcher/constant_folding"
12
12
  require_relative "method_dispatcher/literal_string_folding"
13
13
  require_relative "method_dispatcher/shape_dispatch"
14
14
  require_relative "method_dispatcher/data_folding"
15
+ require_relative "method_dispatcher/struct_folding"
15
16
  require_relative "method_dispatcher/rbs_dispatch"
16
17
  require_relative "method_dispatcher/iterator_dispatch"
17
18
  require_relative "method_dispatcher/reduce_folding"
@@ -38,28 +39,15 @@ module Rigor
38
39
  # callers (today only `ExpressionTyper`) own the fail-soft fallback
39
40
  # and decide whether to record a `FallbackTracer` event.
40
41
  #
41
- # Tiers (in order):
42
+ # Tier order is documented inline in `resolve`; the precise-tier
43
+ # group is built from `PRECISE_TIERS_HEAD`, `STDLIB_SINGLETON_FOLDERS`,
44
+ # and `PRECISE_TIERS_TAIL`. `ShapeDispatch` runs above {RbsDispatch}
45
+ # so a precise per-position/per-key answer wins over the projected
46
+ # `Array#[]`/`Hash#fetch` RBS answer.
42
47
  #
43
- # 1. {ConstantFolding}: executes the Ruby operation directly when
44
- # the receiver and argument are `Constant` carriers and the
45
- # method is on the curated whitelist. Slice 2.
46
- # 2. {ShapeDispatch}: returns the precise element/value type for a
47
- # curated catalogue of `Tuple`/`HashShape` element-access
48
- # methods (`first`, `last`, `[]` with a static integer/key,
49
- # `fetch`, `dig`, `size`/`length`/`count`). Slice 5 phase 2.
50
- # 3. {RbsDispatch}: looks up the receiver's class in the RBS
51
- # environment carried by the scope and translates the method's
52
- # return type into a Rigor::Type. Slice 4.
53
- #
54
- # `ShapeDispatch` deliberately runs *above* {RbsDispatch} so the
55
- # precise per-position/per-key answer wins over the projected
56
- # `Array#[]`/`Hash#fetch` answer; it falls through (`nil`) when
57
- # the call cannot be proved against the static shape, in which
58
- # case the projection answer from {RbsDispatch} applies.
59
- #
60
- # The dispatcher's public signature reserves space for `block_type:`
61
- # and ADR-2 plugin extensions (later slices), so call sites added
62
- # now do not have to be rewritten when those tiers arrive.
48
+ # The `block_type:` and plugin contribution (`dynamic_return`) tiers
49
+ # landed in Slice 6 phase C and v0.1.1 Track 2 respectively; all
50
+ # call sites pass through `dispatch`/`resolve` unchanged.
63
51
  module MethodDispatcher # rubocop:disable Metrics/ModuleLength
64
52
  module_function
65
53
 
@@ -326,19 +314,6 @@ module Rigor
326
314
  Type::Combinator.untyped
327
315
  end
328
316
 
329
- # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
330
- # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
331
- # `dynamic_return` rules, wraps the contributed types as
332
- # `FlowContribution` bundles, merges them through
333
- # `FlowContribution::Merger`, and returns the merged
334
- # `return_type` slot (or nil when no plugin contributed a
335
- # return type).
336
- #
337
- # Plugins whose hook raises have their contribution
338
- # silently dropped for this call so the dispatch chain
339
- # keeps moving — the run-level diagnostic envelope (per
340
- # ADR-2 § "Plugin Trust and I/O Policy") is owned by
341
- # `Analysis::Runner#plugin_emitted_diagnostics`.
342
317
  # ADR-20 slice 3 — looks up the receiver / method pair
343
318
  # in {Rigor::Builtins::HktBuiltins::METHOD_RETURN_OVERRIDES}
344
319
  # and returns the reduced HKT type. Only fires when the
@@ -413,6 +388,19 @@ module Rigor
413
388
  end
414
389
  end
415
390
 
391
+ # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
392
+ # slice 7; ADR-52 WD3 — consults each loaded plugin's gated
393
+ # `dynamic_return` rules, wraps the contributed types as
394
+ # `FlowContribution` bundles, merges them through
395
+ # `FlowContribution::Merger`, and returns the merged
396
+ # `return_type` slot (or nil when no plugin contributed a
397
+ # return type).
398
+ #
399
+ # Plugins whose hook raises have their contribution
400
+ # silently dropped for this call so the dispatch chain
401
+ # keeps moving — the run-level diagnostic envelope (per
402
+ # ADR-2 § "Plugin Trust and I/O Policy") is owned by
403
+ # `Analysis::Runner#plugin_emitted_diagnostics`.
416
404
  def try_plugin_contribution(call_node, scope, receiver_type)
417
405
  return nil if call_node.nil? || scope.nil?
418
406
 
@@ -425,16 +413,6 @@ module Rigor
425
413
  FlowContribution::Merger.merge(contributions).return_type
426
414
  end
427
415
 
428
- # ADR-10 slice 2b-ii. Consults the per-run
429
- # `Analysis::DependencySourceInference::Index` carried by
430
- # the environment for `(class_name, method_name)`
431
- # observations harvested from opt-in gems' `roots:`. On a
432
- # hit, returns `Combinator.untyped` so the call site
433
- # carries the `Dynamic[top]` provenance (per ADR-10's
434
- # "Inference contract": gem-source-inferred shapes never
435
- # publish as ground-truth `T`). Returns `nil` when the
436
- # environment carries no index, the index has no entry, or
437
- # the receiver has no nominal class to look up.
438
416
  # ADR-16 synthetic-method tier. Slice 2b shipped the floor —
439
417
  # a match short-circuits at the right precedence (above
440
418
  # dep-source / discovered / user-class-fallback; below RBS)
@@ -556,6 +534,16 @@ module Rigor
556
534
  Type::Combinator.dynamic(entry.return_type)
557
535
  end
558
536
 
537
+ # ADR-10 slice 2b-ii. Consults the per-run
538
+ # `Analysis::DependencySourceInference::Index` carried by
539
+ # the environment for `(class_name, method_name)`
540
+ # observations harvested from opt-in gems' `roots:`. On a
541
+ # hit, returns `Combinator.untyped` so the call site
542
+ # carries the `Dynamic[top]` provenance (per ADR-10's
543
+ # "Inference contract": gem-source-inferred shapes never
544
+ # publish as ground-truth `T`). Returns `nil` when the
545
+ # environment carries no index, the index has no entry, or
546
+ # the receiver has no nominal class to look up.
559
547
  def try_dependency_source(receiver_type, method_name, environment)
560
548
  index = environment&.dependency_source_index
561
549
  return nil if index.nil? || index.empty?
@@ -759,10 +747,6 @@ module Rigor
759
747
  # below this chain and is invoked by the outer `dispatch`
760
748
  # method.
761
749
  #
762
- # `BlockFolding` runs last among the precision tiers because
763
- # its rules apply only to block-taking calls, so the cheaper
764
- # arity-based fold tiers above it filter out the common
765
- # cases first. When `block_type` is nil the tier is a no-op.
766
750
  # The precise-tier folders, consulted in order via the uniform
767
751
  # `_DispatchTier` interface (`try_dispatch(CallContext) -> Type?`).
768
752
  # Order is significant: ConstantFolding's exact-value folds win
@@ -815,6 +799,14 @@ module Rigor
815
799
  data_result = DataFolding.try_dispatch(context)
816
800
  return data_result if data_result
817
801
 
802
+ # ADR-48 Struct follow-up — runs in the same band and for the same
803
+ # reason as DataFolding: `meta_new` would otherwise intercept every
804
+ # `Singleton[*].new` (the old `struct_new_lift` produced a bare
805
+ # `Singleton[Struct]`), masking a Struct class's precise instance.
806
+ # Only fires on Struct receivers, so it never shadows meta's lifts.
807
+ struct_result = StructFolding.try_dispatch(context)
808
+ return struct_result if struct_result
809
+
818
810
  meta_result = try_meta_introspection(context.receiver, context.method_name, context.args)
819
811
  return meta_result if meta_result
820
812
 
@@ -67,18 +67,12 @@ module Rigor
67
67
  # ...; @warning_issued = true`). Requires tracking the
68
68
  # first-write position; flow-sensitive but orthogonal.
69
69
  # - **Local-variable mutation inside a block body** (e.g.
70
- # `arr = []; xs.each { |x| arr << x }`). Block bodies
71
- # create a child scope; the existing closure-escape model
72
- # only widens outer locals when the block ESCAPES the
73
- # call. An in-place mutator inside a non-escaping block on
74
- # an outer LOCAL does not yet flow back. **Ivar mutations
75
- # inside a block ARE handled** (ivars live in the
76
- # method-body scope, not the block-local scope) — the
77
- # widening fires from inside the block and the new ivar
78
- # binding is visible to the outer scope.
70
+ # `arr = []; xs.each { |x| arr << x }`) landed as
71
+ # ADR-56 slice A (`widen_after_block`). **Ivar mutations
72
+ # inside a block ARE also handled** (ivars live in the
73
+ # method-body scope, not the block-local scope).
79
74
  #
80
- # Those four are documented as "G2 remaining" in
81
- # `docs/CURRENT_WORK.md` and are intentionally deferred.
75
+ # The remaining three items above are demand-gated; see ADR-56.
82
76
  module MutationWidening
83
77
  # Array mutators that change either the size or the element
84
78
  # set of a literal-shape carrier (Tuple). Receiver-mutating
@@ -11,7 +11,8 @@ require_relative "../builtins/regex_refinement"
11
11
 
12
12
  module Rigor
13
13
  module Inference
14
- # Slice 6 phase 1 minimal narrowing surface.
14
+ # Control-flow predicate narrowing and type-lattice narrowing
15
+ # primitives.
15
16
  #
16
17
  # `Rigor::Inference::Narrowing` answers two related questions:
17
18
  #
@@ -19,22 +20,19 @@ module Rigor
19
20
  # truthy fragment, its falsey fragment, its nil fragment, and its
20
21
  # non-nil fragment? These primitives understand the value-lattice
21
22
  # algebra (`Constant`, `Nominal`, `Singleton`, `Tuple`, `HashShape`,
22
- # `Union`) and stay conservative on `Top` and `Dynamic[T]`, where
23
- # the analyzer cannot prove the boundary either way.
23
+ # `Union`) and stay conservative on `Top` and `Dynamic[T]`.
24
24
  # 2. Predicate-level narrowing: given a Prism predicate node and an
25
25
  # entry scope, what are the truthy-edge scope and the falsey-edge
26
- # scope after the predicate has been evaluated? The phase 1
27
- # catalogue covers truthiness on `LocalVariableReadNode`, `nil?`
28
- # against a local, the unary `!` inverter, parenthesised
29
- # predicates, and short-circuiting `&&` / `||` chains.
26
+ # scope? The catalogue covers truthiness, `nil?`, `!`, `&&`/`||`,
27
+ # class-membership (`is_a?`, `kind_of?`, `instance_of?`), trusted
28
+ # equality/inequality against static literals, `case`/`when`,
29
+ # regex match globals, string predicates (`start_with?` etc.),
30
+ # key-presence, array emptiness, numeric comparison, and
31
+ # `respond_to?`.
30
32
  #
31
- # Predicate-level narrowing is consumed by
32
- # `Rigor::Inference::StatementEvaluator` to refine the `then` and
33
- # `else` scopes of `IfNode`/`UnlessNode`. Phase 1 narrows local
34
- # bindings on truthiness and `nil?`; phase 2 extends the catalogue
35
- # with class-membership predicates (`is_a?`, `kind_of?`,
36
- # `instance_of?`) and trusted equality/inequality checks against
37
- # static literals.
33
+ # Consumed by `Rigor::Inference::StatementEvaluator` to refine
34
+ # `then`/`else` scopes of `IfNode`/`UnlessNode` and
35
+ # `case`/`when` branches.
38
36
  #
39
37
  # The module is pure: every public function returns fresh values and
40
38
  # MUST NOT mutate its inputs. Unrecognised predicate shapes degrade
@@ -43,8 +41,8 @@ module Rigor
43
41
  # `[truthy_scope, falsey_scope]` pair (the entry scope twice when no
44
42
  # rule matches).
45
43
  #
46
- # See docs/internal-spec/inference-engine.md (Slice 6 — Narrowing)
47
- # and docs/type-specification/control-flow-analysis.md for the
44
+ # See docs/internal-spec/inference-engine.md (Narrowing) and
45
+ # docs/type-specification/control-flow-analysis.md for the
48
46
  # binding contract.
49
47
  # rubocop:disable Metrics/ModuleLength
50
48
  module Narrowing
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "scope_indexer"
6
+ require_relative "../source/node_walker"
7
+
8
+ module Rigor
9
+ module Inference
10
+ # Pure argument-type classification for {ParameterInferenceCollector} — no
11
+ # collector state, so it lives outside the orchestration class. Decides which
12
+ # argument types may seed a parameter (concrete enough for the protection
13
+ # metric to bite) and widens a literal argument to its nominal.
14
+ module ParameterArgTypes
15
+ module_function
16
+
17
+ CONSTANT_CLASSES = {
18
+ Integer => "Integer", Float => "Float", String => "String",
19
+ Symbol => "Symbol", Range => "Range", TrueClass => "TrueClass",
20
+ FalseClass => "FalseClass", NilClass => "NilClass"
21
+ }.freeze
22
+
23
+ # A parameter holds a *value of* a type across its lifetime, not a pinned
24
+ # literal — so a `Constant<"text">` argument widens to its nominal
25
+ # (`String`); recurses through unions so `Constant<"a"> | Constant<"b">`
26
+ # collapses to `String`.
27
+ def widen_for_param(type)
28
+ case type
29
+ when Type::Constant
30
+ name = constant_class_name(type.value)
31
+ name ? Type::Combinator.nominal_of(name) : type
32
+ when Type::Union
33
+ Type::Combinator.union(*type.members.map { |member| widen_for_param(member) })
34
+ else
35
+ type
36
+ end
37
+ end
38
+
39
+ def constant_class_name(value)
40
+ CONSTANT_CLASSES.each { |klass, name| return name if value.is_a?(klass) }
41
+ nil
42
+ end
43
+
44
+ # The dispatch class for a receiver type, for the subset the collector
45
+ # resolves to a user `def` (mirrors `CheckRules#concrete_class_name` for the
46
+ # carriers a user-method receiver is typed as). `class_name_of` is the
47
+ # Nominal/Singleton-only variant for an implicit-self `self_type`.
48
+ def concrete_class_name(type)
49
+ case type
50
+ when Type::Nominal, Type::Singleton then type.class_name
51
+ when Type::Tuple then "Array"
52
+ when Type::HashShape then "Hash"
53
+ end
54
+ end
55
+
56
+ def class_name_of(type)
57
+ type.class_name if type.is_a?(Type::Nominal) || type.is_a?(Type::Singleton)
58
+ end
59
+
60
+ # Whether `type` is too gradual to seed (not a concrete dispatch target) —
61
+ # the negation of `ProtectionScanner#concrete_receiver?` (a union is
62
+ # concrete only when every arm is).
63
+ def non_concrete?(type)
64
+ case type
65
+ when Type::Dynamic, Type::Top, Type::Bot then true
66
+ when Type::Union then type.members.any? { |member| non_concrete?(member) }
67
+ else false
68
+ end
69
+ end
70
+ end
71
+
72
+ # ADR-67 WD3 + WD5 — call-site parameter type inference (capped fixpoint).
73
+ #
74
+ # A `def` parameter with no RBS signature types `untyped` (the gradual entry
75
+ # point), so a param flowing into an ivar or a receiver drains everything
76
+ # downstream to `Dynamic`. This pass closes that hole: it walks every project
77
+ # file, types each call's positional arguments in their lexical scope,
78
+ # resolves which user-defined `def` each call targets, and records the
79
+ # **union of resolved call-site argument types** per parameter — TypeProf's
80
+ # "a parameter's type is the union of every actual argument across all call
81
+ # sites". WD5: it iterates (cap {DEFAULT_ROUNDS}), re-seeding each round with
82
+ # the previous round's inferred parameters, so a parameter passed *another*
83
+ # parameter is typed one hop further per round (round 1 alone is the
84
+ # single-level pass).
85
+ #
86
+ # The result is keyed by `[class_name, method_name, kind]` — the same triple
87
+ # `StatementEvaluator#build_method_entry_scope` reconstructs from the lexical
88
+ # class path — so the consumer can seed an undeclared parameter with its
89
+ # inferred type. The table is precision-additive only: it never feeds a
90
+ # parameter-boundary diagnostic (an inferred type lives solely as a body
91
+ # local, and the boundary rules consult RBS, not body locals — see ADR-67
92
+ # WD1), so being wrong cannot manufacture a false positive at a caller.
93
+ #
94
+ # Soundness (WD4): the inferred type is a sound over-approximation only when
95
+ # every contributed call site resolves to a concrete argument type. Any
96
+ # `Dynamic` / `Top` / `Bot` argument (a `send`/dynamic-dispatch caller, or a
97
+ # parameter not yet typed in an earlier round) **poisons** the parameter,
98
+ # which then contributes nothing this round (it may type in a later round
99
+ # once its own argument resolves). Unresolved call sites do not contribute.
100
+ #
101
+ # What it does NOT do yet (deferred — the check-wiring slice): feeding the
102
+ # table into the `check` walk (only `coverage --protection` consumes it
103
+ # today), keyword / optional / rest / block parameters, inherited-method
104
+ # receivers, top-level helpers, and a rigorous closed-call-site-set proof
105
+ # (the union is optimistic over resolved sites — acceptable because the only
106
+ # consumer is the protection metric, which runs no diagnostics).
107
+ class ParameterInferenceCollector
108
+ EMPTY = {}.freeze
109
+
110
+ # A defensive widening cap (ADR-41): a parameter unioned from more than
111
+ # this many distinct concrete call-site types is widened to `untyped`
112
+ # (poisoned) rather than carrying an unbounded union.
113
+ MAX_CALL_SITE_TYPES = 16
114
+
115
+ # WD5 — the call-site union is a worklist fixpoint: each round re-types the
116
+ # project with the previous round's inferred parameters seeded, so a
117
+ # parameter passed *another* parameter is typed one hop further per round.
118
+ # Capped (no true-convergence requirement — the metric tolerates a bounded
119
+ # approximation, and the table can oscillate at the margin since a newly
120
+ # resolved receiver can surface a fresh untyped-argument call site). The
121
+ # cap matches the `Inference::BodyFixpoint` cap-3 convention. Round 1 alone
122
+ # is the single-level pass.
123
+ DEFAULT_ROUNDS = 3
124
+
125
+ # @param files [Array<String>] project `.rb` paths to scan for call sites.
126
+ # @param environment [Rigor::Environment]
127
+ # @param target_ruby [String, nil] Prism parse target.
128
+ # @param max_rounds [Integer] the WD5 fixpoint cap (1 = single-level).
129
+ # @return [Hash{[String,Symbol,Symbol] => Hash{Symbol => Rigor::Type}}] frozen.
130
+ def self.collect(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
131
+ new(files: files, environment: environment, target_ruby: target_ruby, max_rounds: max_rounds).collect
132
+ end
133
+
134
+ def initialize(files:, environment:, target_ruby: nil, max_rounds: DEFAULT_ROUNDS)
135
+ @files = files
136
+ @environment = environment
137
+ @target_ruby = target_ruby
138
+ @max_rounds = max_rounds
139
+ # Reset per round (see {#run_round}). `[[class, method, kind], param_sym]`
140
+ # => [Type] of observed concrete arguments (a default-block Hash, not a
141
+ # `{}` literal, so the analyzer types its reads generically — {#finalize}),
142
+ # plus the ids widened to `untyped` (an untyped / over-cap argument).
143
+ @type_observations = Hash.new { |hash, id| hash[id] = [] }
144
+ @poisoned_params = Set.new
145
+ end
146
+
147
+ def collect
148
+ parsed = parse_all
149
+ discovery = discovery_seed_tables
150
+ table = EMPTY
151
+ @max_rounds.times do
152
+ rounded = run_round(parsed, discovery, table)
153
+ return rounded if rounded == table # fixpoint reached
154
+
155
+ table = rounded
156
+ end
157
+ table
158
+ end
159
+
160
+ private
161
+
162
+ # Parse every project file once; rounds re-index the cached ASTs against an
163
+ # evolving seed rather than re-parsing.
164
+ def parse_all
165
+ @files.filter_map do |path|
166
+ result = Prism.parse(File.read(path), filepath: path, version: @target_ruby)
167
+ [path, result.value] if result.errors.empty?
168
+ rescue Errno::ENOENT, Errno::EISDIR, Errno::EACCES
169
+ nil
170
+ end
171
+ end
172
+
173
+ # One fixpoint round: re-type every file with `seed_table` (the previous
174
+ # round's inferred parameters) seeded, collecting the next round's table.
175
+ def run_round(parsed, discovery_tables, seed_table)
176
+ @type_observations = Hash.new { |hash, id| hash[id] = [] }
177
+ @poisoned_params = Set.new
178
+ seed_scope = build_seed_scope(discovery_tables, seed_table)
179
+ parsed.each do |path, ast|
180
+ index = ScopeIndexer.index(ast, default_scope: seed_scope.with_source_path(path))
181
+ Source::NodeWalker.each(ast) do |node|
182
+ record_call(node, index) if node.is_a?(Prism::CallNode)
183
+ end
184
+ end
185
+ finalize
186
+ end
187
+
188
+ # A scope carrying the cross-file discovery index (so `Foo.new` receivers
189
+ # and implicit-self calls resolve to a user `def`) plus the prior round's
190
+ # inferred parameters (so an argument that reads a parameter types to its
191
+ # current inferred type — the WD5 propagation).
192
+ def build_seed_scope(discovery_tables, seed_table)
193
+ base = Scope.empty(environment: @environment)
194
+ tables = discovery_tables
195
+ tables = tables.merge(param_inferred_types: seed_table) unless seed_table.empty?
196
+ return base if tables.empty?
197
+
198
+ base.with_discovery(base.discovery.with(**tables))
199
+ end
200
+
201
+ def discovery_seed_tables
202
+ classes = ScopeIndexer.discovered_classes_for_paths(@files)
203
+ def_index = ScopeIndexer.discovered_def_index_for_paths(@files)
204
+ tables = {}
205
+ tables[:discovered_classes] = classes unless classes.empty?
206
+ DISCOVERY_FIELD.each do |index_key, field|
207
+ table = def_index.fetch(index_key)
208
+ tables[field] = table unless table.empty?
209
+ end
210
+ tables
211
+ rescue StandardError
212
+ # Discovery is best-effort; a malformed corner of the project must not
213
+ # crash the protection scan. Without discovery the collector simply
214
+ # resolves fewer call sites.
215
+ {}
216
+ end
217
+
218
+ DISCOVERY_FIELD = {
219
+ def_nodes: :discovered_def_nodes,
220
+ singleton_def_nodes: :discovered_singleton_def_nodes,
221
+ def_sources: :discovered_def_sources,
222
+ superclasses: :discovered_superclasses,
223
+ includes: :discovered_includes,
224
+ class_sources: :discovered_class_sources,
225
+ method_visibilities: :discovered_method_visibilities,
226
+ methods: :discovered_methods,
227
+ data_member_layouts: :data_member_layouts,
228
+ struct_member_layouts: :struct_member_layouts
229
+ }.freeze
230
+ private_constant :DISCOVERY_FIELD
231
+
232
+ def record_call(call_node, index)
233
+ args = positional_args(call_node)
234
+ return if args.nil?
235
+
236
+ scope = index[call_node]
237
+ return if scope.nil?
238
+
239
+ callee = resolve_callee(call_node, scope, index)
240
+ return if callee.nil?
241
+
242
+ class_name, method, kind, def_node = callee
243
+ requireds = simple_requireds(def_node)
244
+ return if requireds.nil? || requireds.size != args.size
245
+
246
+ key = [class_name, method, kind]
247
+ args.each_with_index do |arg, i|
248
+ arg_scope = index[arg]
249
+ accumulate(key, requireds[i].name, arg_scope&.type_of(arg))
250
+ end
251
+ end
252
+
253
+ # The plain positional arguments, or nil when the call carries any
254
+ # non-plain argument (splat / keyword / block-pass / forwarding) — those
255
+ # break the positional-index ↔ parameter mapping, so the call site is
256
+ # skipped rather than mis-attributed.
257
+ def positional_args(call_node)
258
+ arguments = call_node.arguments
259
+ return [] if arguments.nil?
260
+
261
+ list = arguments.arguments
262
+ return nil if list.any? { |arg| non_plain_argument?(arg) }
263
+
264
+ list
265
+ end
266
+
267
+ def non_plain_argument?(arg)
268
+ arg.is_a?(Prism::SplatNode) ||
269
+ arg.is_a?(Prism::KeywordHashNode) ||
270
+ arg.is_a?(Prism::BlockArgumentNode) ||
271
+ arg.is_a?(Prism::ForwardingArgumentsNode) ||
272
+ arg.is_a?(Prism::AssocNode) ||
273
+ arg.is_a?(Prism::AssocSplatNode)
274
+ end
275
+
276
+ # @return [[String, Symbol, Symbol, Prism::DefNode], nil]
277
+ def resolve_callee(call_node, scope, index)
278
+ if call_node.receiver.nil?
279
+ class_name, kind = implicit_self_target(scope)
280
+ else
281
+ class_name, kind = explicit_receiver_target(call_node.receiver, index, scope)
282
+ end
283
+ return nil if class_name.nil?
284
+
285
+ def_node = lookup_def(scope, class_name, call_node.name, kind)
286
+ return nil if def_node.nil?
287
+
288
+ [class_name, call_node.name, kind, def_node]
289
+ end
290
+
291
+ def implicit_self_target(scope)
292
+ self_type = scope.self_type
293
+ return [nil, nil] if self_type.nil?
294
+
295
+ [ParameterArgTypes.class_name_of(self_type), self_type.is_a?(Type::Singleton) ? :singleton : :instance]
296
+ end
297
+
298
+ def explicit_receiver_target(receiver, index, scope)
299
+ receiver_type = (index[receiver] || scope).type_of(receiver)
300
+ [ParameterArgTypes.concrete_class_name(receiver_type),
301
+ receiver_type.is_a?(Type::Singleton) ? :singleton : :instance]
302
+ end
303
+
304
+ def lookup_def(scope, class_name, method, kind)
305
+ table = kind == :singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
306
+ per_class = table[class_name]
307
+ per_class && per_class[method]
308
+ end
309
+
310
+ # The required-positional parameters, or nil when the method's parameter
311
+ # list is not a simple all-required shape (matching the single-level
312
+ # contract `ExpressionTyper#user_method_param_shape_simple?` uses) or
313
+ # contains a destructured `(a, b)` slot (no bindable name).
314
+ def simple_requireds(def_node)
315
+ params = def_node.parameters
316
+ return [] if params.nil?
317
+ return nil unless params.is_a?(Prism::ParametersNode)
318
+ return nil unless params.optionals.empty? && params.rest.nil? && params.posts.empty? &&
319
+ params.keywords.empty? && params.keyword_rest.nil? && params.block.nil?
320
+ return nil unless params.requireds.all?(Prism::RequiredParameterNode)
321
+
322
+ params.requireds
323
+ end
324
+
325
+ def accumulate(key, param_name, arg_type)
326
+ id = [key, param_name.to_sym]
327
+ return if @poisoned_params.include?(id)
328
+
329
+ if arg_type.nil? || ParameterArgTypes.non_concrete?(arg_type)
330
+ poison(id)
331
+ else
332
+ observations = @type_observations[id]
333
+ observations << ParameterArgTypes.widen_for_param(arg_type)
334
+ poison(id) if observations.length > MAX_CALL_SITE_TYPES
335
+ end
336
+ end
337
+
338
+ # A poisoned parameter is dropped from the observation store and recorded
339
+ # so later call sites short-circuit. `id` is the `[[class, method, kind],
340
+ # param]` pair.
341
+ def poison(id)
342
+ @poisoned_params << id
343
+ @type_observations.delete(id)
344
+ end
345
+
346
+ def finalize
347
+ # `result` is a default-block Hash (not a `{}` literal) so the analyzer
348
+ # types its reads generically rather than folding the empty shape — the
349
+ # nesting writes stay plain assignments, no literal-fold conditions.
350
+ result = Hash.new { |hash, key| hash[key] = {} }
351
+ @type_observations.each do |id, observations|
352
+ next if @poisoned_params.include?(id)
353
+ next if observations.empty?
354
+
355
+ union = Type::Combinator.union(*observations)
356
+ # A union that collapsed to a non-concrete shape (e.g. a gradual arm
357
+ # leaked in) is no better than `untyped`; drop it.
358
+ next if ParameterArgTypes.non_concrete?(union)
359
+
360
+ key, param = id
361
+ result[key][param] = union
362
+ end
363
+ result.transform_values(&:freeze).freeze
364
+ end
365
+ end
366
+ end
367
+ end
@@ -13,13 +13,10 @@ module Rigor
13
13
  # project-side `lib/core_ext/string_extensions.rb` patches
14
14
  # are visible to cross-file dispatch.
15
15
  #
16
- # Slice 2 ships the registry at the **floor**: the dispatcher
17
- # answers `Type::Combinator.untyped` (Dynamic[Top]) on a hit;
18
- # return-type inference for patched methods stays deferred
19
- # (a separate slice when concrete demand surfaces — most
20
- # real-world `core_ext` patches return shapes the analyzer
21
- # could heuristically extract via the same machinery the
22
- # ADR-10 walker uses, but slice 2 keeps the surface narrow).
16
+ # The dispatcher answers `Dynamic[T]` (with a heuristic static
17
+ # facet) when `Entry#return_type` is non-nil, or `Dynamic[Top]`
18
+ # when the heuristic declined (`nil`). See {Entry} for the
19
+ # per-field contract.
23
20
  class ProjectPatchedMethods
24
21
  # Frozen value-object recording one `def` observed by the
25
22
  # pre-pass. `class_name` is the qualified prefix
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "project_patched_methods"
6
6
  require_relative "../analysis/dependency_source_inference/return_type_heuristic"
7
+ require_relative "../source/constant_path"
7
8
 
8
9
  module Rigor
9
10
  module Inference
@@ -161,7 +162,7 @@ module Rigor
161
162
  private_class_method :walk_children
162
163
 
163
164
  def descend_class_or_module(node, qualified_prefix, in_singleton_class, source_path, entries)
164
- name = qualified_name_for(node.constant_path)
165
+ name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
165
166
  if name && node.body
166
167
  walk_node(node.body, qualified_prefix + [name], in_singleton_class, source_path, entries)
167
168
  else
@@ -193,18 +194,6 @@ module Rigor
193
194
  )
194
195
  end
195
196
  private_class_method :record_def_node
196
-
197
- def qualified_name_for(node)
198
- case node
199
- when Prism::ConstantReadNode then node.name.to_s
200
- when Prism::ConstantPathNode
201
- parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
202
- return nil if !node.parent.nil? && parent.nil?
203
-
204
- parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
205
- end
206
- end
207
- private_class_method :qualified_name_for
208
197
  end
209
198
  end
210
199
  end