rigortype 0.2.9 → 0.3.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -27,13 +27,14 @@ module Rigor
27
27
  # the chain collapses to `Constant[nil]` so the carrier surfaces through downstream narrowing. A
28
28
  # non-shape intermediate falls through to the projection answer.
29
29
  # - HashShape#`size`/`length`: no-arg.
30
- # - HashShape#`[]`, HashShape#`fetch`, HashShape#`dig` with a single `Constant[Symbol|String]`
31
- # argument matching one of the declared keys. `[]` and `dig` resolve missing keys to
30
+ # - HashShape#`[]`, HashShape#`fetch`, HashShape#`dig` with a single `Constant` scalar-key
31
+ # argument (Symbol / String / Integer / Float / true / false / nil) matching one of the declared
32
+ # keys. `[]` and `dig` resolve missing keys to
32
33
  # `Constant[nil]`; `fetch` (no default, no block) falls through on a miss because Ruby would raise
33
34
  # `KeyError` and the analyzer prefers the conservative projection answer.
34
35
  # - HashShape#`dig` with multi-arg chains (Slice 5 phase 2 sub-phase 2). Same chaining semantics as
35
36
  # Tuple#`dig`.
36
- # - HashShape#`values_at` with a list of `Constant[Symbol|String]` arguments (Slice 5 phase 2
37
+ # - HashShape#`values_at` with a list of `Constant` scalar-key arguments (Slice 5 phase 2
37
38
  # sub-phase 2). The result is a `Tuple` whose elements are the per-key values (`Constant[nil]` for
38
39
  # missing keys, mirroring Ruby's runtime behaviour).
39
40
  #
@@ -244,6 +245,14 @@ module Rigor
244
245
  carrier
245
246
  end
246
247
 
248
+ # True when `key` is a value the `HashShape` carrier can hold as a key (the value-pinned scalar
249
+ # set — Symbol, String, Integer, Float, true, false, nil). Key identity in every handler below
250
+ # is Ruby's own `eql?` via the shape's native `pairs` Hash, so `1` and `1.0` are distinct keys
251
+ # while `1.0` and `1.00` collide — exactly the runtime's `Hash` semantics.
252
+ def static_shape_key?(key)
253
+ Type::HashShape::ALLOWED_KEY_CLASSES.any? { |klass| key.is_a?(klass) }
254
+ end
255
+
247
256
  def dispatch_nominal_size(nominal, method_name, args)
248
257
  projection = nominal_projection(nominal, method_name, args)
249
258
  return projection if projection
@@ -424,7 +433,7 @@ module Rigor
424
433
  base = difference.base
425
434
  return nil unless base.is_a?(Type::Nominal)
426
435
 
427
- if removes_empty_witness?(difference)
436
+ if difference.removes_empty_witness?
428
437
  precise = empty_removal_projection(difference, method_name, args)
429
438
  return precise if precise
430
439
  end
@@ -432,23 +441,6 @@ module Rigor
432
441
  dispatch_nominal_size(base, method_name, args)
433
442
  end
434
443
 
435
- EMPTY_WITNESS_PREDICATES = {
436
- "String" => ->(removed) { removed.is_a?(Type::Constant) && removed.value == "" },
437
- "Integer" => lambda { |removed|
438
- removed.is_a?(Type::Constant) && removed.value.is_a?(Integer) && removed.value.zero?
439
- },
440
- "Array" => ->(removed) { removed.is_a?(Type::Tuple) && removed.elements.empty? },
441
- "Hash" => ->(removed) { removed.is_a?(Type::HashShape) && removed.pairs.empty? }
442
- }.freeze
443
- private_constant :EMPTY_WITNESS_PREDICATES
444
-
445
- def removes_empty_witness?(difference)
446
- return false unless difference.base.is_a?(Type::Nominal)
447
-
448
- predicate = EMPTY_WITNESS_PREDICATES[difference.base.class_name]
449
- !!(predicate && predicate.call(difference.removed))
450
- end
451
-
452
444
  # Methods on a non-empty String that preserve non-emptiness (they transform characters but never
453
445
  # reduce the string to "").
454
446
  NON_EMPTY_STRING_PRESERVING_UNARY = Set[:upcase, :downcase, :capitalize, :swapcase, :reverse].freeze
@@ -1308,7 +1300,7 @@ module Rigor
1308
1300
  return nil unless arg.is_a?(Type::Constant)
1309
1301
 
1310
1302
  key = arg.value
1311
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1303
+ return nil unless static_shape_key?(key)
1312
1304
  return nil unless shape.pairs.key?(key)
1313
1305
 
1314
1306
  values << shape.pairs[key]
@@ -1327,7 +1319,7 @@ module Rigor
1327
1319
  return nil unless arg.is_a?(Type::Constant)
1328
1320
 
1329
1321
  key = arg.value
1330
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1322
+ return nil unless static_shape_key?(key)
1331
1323
  return Type::Combinator.constant_of(nil) unless shape.pairs.key?(key)
1332
1324
 
1333
1325
  Type::Combinator.tuple_of(Type::Combinator.constant_of(key), shape.pairs[key])
@@ -1436,7 +1428,7 @@ module Rigor
1436
1428
  end
1437
1429
 
1438
1430
  # `shape.has_key?(k)` / `key?(k)` / `member?(k)` / `include?(k)` — folds to `Constant[true/false]`
1439
- # when the argument is a `Constant[Symbol|String]` and the shape is closed with no optional
1431
+ # when the argument is a `Constant` scalar key and the shape is closed with no optional
1440
1432
  # keys.
1441
1433
  def hash_has_key?(shape, _method_name, args)
1442
1434
  return nil unless args.size == 1
@@ -1447,7 +1439,7 @@ module Rigor
1447
1439
  return nil unless arg.is_a?(Type::Constant)
1448
1440
 
1449
1441
  key = arg.value
1450
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1442
+ return nil unless static_shape_key?(key)
1451
1443
 
1452
1444
  Type::Combinator.constant_of(shape.pairs.key?(key))
1453
1445
  end
@@ -1496,7 +1488,8 @@ module Rigor
1496
1488
  end
1497
1489
 
1498
1490
  # `shape.invert` — swaps keys and values. Folds when every value is a `Constant` whose value is a
1499
- # Symbol or String (the only hashable types that `HashShape` accepts as keys). Duplicate values
1491
+ # scalar the carrier accepts as a key (Symbol / String / Integer / Float / true / false / nil;
1492
+ # see {#static_shape_key?}). Duplicate values
1500
1493
  # would alias under inversion, so Rigor declines on collisions rather than silently dropping
1501
1494
  # entries.
1502
1495
  def hash_invert(shape, _method_name, args)
@@ -1504,7 +1497,7 @@ module Rigor
1504
1497
  return nil unless shape.closed?
1505
1498
  return nil unless shape.optional_keys.empty?
1506
1499
  return nil unless shape.pairs.values.all?(Type::Constant)
1507
- return nil unless shape.pairs.values.all? { |v| v.value.is_a?(Symbol) || v.value.is_a?(String) }
1500
+ return nil unless shape.pairs.values.all? { |v| static_shape_key?(v.value) }
1508
1501
 
1509
1502
  inverted = shape.pairs.each_with_object({}) do |(k, v), acc|
1510
1503
  return nil if acc.key?(v.value)
@@ -1565,7 +1558,7 @@ module Rigor
1565
1558
  Type::Combinator.hash_shape_of(shape.pairs.merge(other.pairs))
1566
1559
  end
1567
1560
 
1568
- # `shape[k]` and `shape.fetch(k)` for a static symbol/string key. Missing-key resolution depends
1561
+ # `shape[k]` and `shape.fetch(k)` for a value-pinned scalar key. Missing-key resolution depends
1569
1562
  # on the method:
1570
1563
  #
1571
1564
  # - `[]` returns `nil` at runtime; we surface `Constant[nil]` so the carrier is visible to
@@ -1599,13 +1592,13 @@ module Rigor
1599
1592
  end
1600
1593
 
1601
1594
  # Returns the per-step value type for a HashShape lookup (or `Constant[nil]` for a known-missing
1602
- # key). Returns `nil` when the argument is not a static symbol/string so the caller can fall
1595
+ # key). Returns `nil` when the argument is not a value-pinned scalar key so the caller can fall
1603
1596
  # through to the projection answer.
1604
1597
  def hash_dig_step(shape, arg)
1605
1598
  return nil unless arg.is_a?(Type::Constant)
1606
1599
 
1607
1600
  key = arg.value
1608
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1601
+ return nil unless static_shape_key?(key)
1609
1602
 
1610
1603
  if shape.pairs.key?(key)
1611
1604
  value = shape.pairs[key]
@@ -1631,7 +1624,7 @@ module Rigor
1631
1624
 
1632
1625
  # `shape.values_at(:a, :b, ...)` with a list of static keys. Returns a `Tuple` whose per-position
1633
1626
  # values are the per-key value types (`Constant[nil]` for missing keys, mirroring Ruby's runtime
1634
- # behaviour). Falls through when any argument is not a static symbol/string.
1627
+ # behaviour). Falls through when any argument is not a value-pinned scalar key.
1635
1628
  def hash_values_at(shape, _method_name, args)
1636
1629
  return nil if args.empty?
1637
1630
 
@@ -1647,7 +1640,7 @@ module Rigor
1647
1640
  end
1648
1641
 
1649
1642
  # `shape.slice(:a, :b, ...)` — returns a sub-HashShape containing only the specified keys. All
1650
- # arguments must be `Constant[Symbol|String]`. Keys not present in the shape are silently
1643
+ # arguments must be `Constant` scalar keys. Keys not present in the shape are silently
1651
1644
  # omitted (matching Ruby's runtime semantics — no nil padding). Declines on open shapes or when
1652
1645
  # any argument is not a static key.
1653
1646
  def hash_slice(shape, _method_name, args)
@@ -1660,7 +1653,7 @@ module Rigor
1660
1653
  return nil unless arg.is_a?(Type::Constant)
1661
1654
 
1662
1655
  key = arg.value
1663
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1656
+ return nil unless static_shape_key?(key)
1664
1657
 
1665
1658
  requested << key
1666
1659
  end
@@ -1669,7 +1662,7 @@ module Rigor
1669
1662
  end
1670
1663
 
1671
1664
  # `shape.except(:a, :b, ...)` — returns a sub-HashShape with the specified keys removed. All
1672
- # arguments must be `Constant[Symbol|String]`. Keys not present in the shape are silently
1665
+ # arguments must be `Constant` scalar keys. Keys not present in the shape are silently
1673
1666
  # ignored. Declines on open shapes or when any argument is not a static key.
1674
1667
  def hash_except(shape, _method_name, args)
1675
1668
  return nil if args.empty?
@@ -1681,7 +1674,7 @@ module Rigor
1681
1674
  return nil unless arg.is_a?(Type::Constant)
1682
1675
 
1683
1676
  key = arg.value
1684
- return nil unless key.is_a?(Symbol) || key.is_a?(String)
1677
+ return nil unless static_shape_key?(key)
1685
1678
 
1686
1679
  excluded[key] = true
1687
1680
  end
@@ -12,13 +12,12 @@ module Rigor
12
12
  # `Data` path does not need: **mutation soundness.**
13
13
  #
14
14
  # A `Struct` instance is mutable (`s.x = v`, `s[:x] = v`, escape), so a member map bound to a
15
- # variable can be invalidated by a later write. This slice (ADR-48 slices 1+2 in the sound
16
- # *transient* form) folds a member read ONLY off a **fresh** instance — the transient receiver of a
17
- # `.new(...).x` / `.with(...).x` chain, which provably cannot have been mutated between
18
- # materialisation and the read. A member read off a *stored* binding degrades to `Dynamic[top]`
19
- # rather than fold a possibly-stale value. Promoting the fold to mutation-free bound locals is the
20
- # deferred slice 3 (relax the fresh-receiver gate to a fold-safe-local scan); precise mutated-member
21
- # re-typing is slice 4.
15
+ # variable can be invalidated by a later write. A member read folds off a **fresh** instance (the
16
+ # transient receiver of a `.new(...).x` / `.with(...).x` chain, unmutatable between materialisation
17
+ # and the read — slices 1+2), off a **fold-safe stored local** the {Inference::StructFoldSafety} scan
18
+ # proved is never mutated / aliased / escaped (slice 3), or off a **setter-mutated fold-safe local**
19
+ # whose bindings {#apply_setter_writeback} keeps current (slice 4). Any other stored receiver degrades
20
+ # to `Dynamic[top]` rather than fold a possibly-stale value.
22
21
  #
23
22
  # Responsibilities:
24
23
  #
@@ -272,7 +271,8 @@ module Rigor
272
271
  end
273
272
 
274
273
  # A fold-safe stored receiver is a local-variable read whose name the body's fold-safe set (on the
275
- # scope) marks as never mutated.
274
+ # scope) marks as safe to fold — never aliased / escaped, and any mutation a straight-line member
275
+ # setter the write-back keeps the binding current for.
276
276
  def fold_safe_local_receiver?(context)
277
277
  node = context.call_node
278
278
  receiver = node&.receiver
@@ -281,6 +281,38 @@ module Rigor
281
281
 
282
282
  scope.struct_fold_safe?(receiver.name)
283
283
  end
284
+
285
+ # ADR-48 slice 4 — precise mutated-member re-typing. After a `local.member = v` setter on a
286
+ # fold-safe `StructInstance` local, rebind the local to a `StructInstance` with that member replaced
287
+ # by the assigned type, so a later `local.member` read folds to the assigned value (and a sibling
288
+ # `local.other` stays precise). Called from `eval_call`'s post-call scope, mirroring
289
+ # `MutationWidening.widen_after_call`. Sound ONLY for a fold-safe local: the fold-safe scan proves it
290
+ # is never aliased / escaped and its setters are straight-line, so the binding this installs is the
291
+ # local's true member state at every later read on the path. A non-fold-safe local is left untouched
292
+ # (its reads do not fold, so the binding is never consulted for folding).
293
+ #
294
+ # @param call_node [Prism::CallNode] the `local.member = v` call
295
+ # @param assigned_type [Rigor::Type, nil] the setter's assigned value type (the call's own result)
296
+ # @param scope [Rigor::Scope, nil]
297
+ # @return [Rigor::Scope] the (possibly) rebound scope
298
+ def apply_setter_writeback(call_node:, assigned_type:, scope:)
299
+ return scope if scope.nil? || assigned_type.nil?
300
+
301
+ receiver = call_node.receiver
302
+ return scope unless receiver.is_a?(Prism::LocalVariableReadNode)
303
+ return scope unless scope.struct_fold_safe?(receiver.name)
304
+
305
+ current = scope.local(receiver.name)
306
+ return scope unless current.is_a?(Type::StructInstance)
307
+
308
+ member = member_setter_target(call_node.name, current.members)
309
+ return scope if member.nil?
310
+
311
+ rebound = Type::Combinator.struct_instance_of(
312
+ members: current.members.merge(member => assigned_type), class_name: current.class_name
313
+ )
314
+ scope.with_local(receiver.name, rebound)
315
+ end
284
316
  end
285
317
  end
286
318
  end
@@ -8,6 +8,7 @@ require_relative "../builtins/hkt_builtins"
8
8
  require_relative "../builtins/static_return_refinements"
9
9
  require_relative "dynamic_origin"
10
10
  require_relative "flow_tracer"
11
+ require_relative "void_tail_summary"
11
12
  require_relative "method_dispatcher/call_context"
12
13
  require_relative "method_dispatcher/constant_folding"
13
14
  require_relative "method_dispatcher/literal_string_folding"
@@ -70,6 +71,14 @@ module Rigor
70
71
  block_type: block_type, environment: environment,
71
72
  call_node: call_node, scope: scope
72
73
  )
74
+ # ADR-100 WD4 — the transitive-void consult, HERE in the wrapper rather than in any result
75
+ # tier: which tier answers the intermediate's call depends on where the leaf's RBS came from
76
+ # (`RbsDispatch` on the ADR-93 synthesized `untyped` skeleton, or the post-dispatch
77
+ # `ExpressionTyper` body-inference tiers on a partial hand-written `sig/`), and both answers
78
+ # are correct types that must not change. Recording is therefore result-independent and
79
+ # type-blind; a statement-position record stays inert because the collector only reads value
80
+ # positions.
81
+ record_transitive_void_origin(receiver_type, method_name, call_node, scope)
73
82
  # `rigor trace` — record the dispatch outcome (resolved type, or the fail-soft `nil` the
74
83
  # caller widens to `Dynamic[Top]`).
75
84
  if FlowTracer.active?
@@ -225,6 +234,36 @@ module Rigor
225
234
  fallback_result
226
235
  end
227
236
 
237
+ # ADR-100 WD4 — records the transitive `-> void` provenance when the called method is a
238
+ # discovered project `def` whose {VoidTailSummary} chain ends in an author-declared `-> void`
239
+ # leaf on the same owner. Guards cheapest-first: the internal dispatcher callers nil `scope` /
240
+ # `call_node` out; the receiver must project through {#discovered_method_lookup}
241
+ # (`Nominal` / `Singleton` only — unions and `Dynamic` receivers stay out); and the
242
+ # `discovered_method?` pre-filter (two pure hash reads) keeps the hot path at ~zero cost —
243
+ # only a call that provably targets a discovered def of the right kind resolves the node and
244
+ # consults the summary. The `Prism::CallNode` guard mirrors `collect_plugin_contributions`:
245
+ # the `&:symbol` block path dispatches with the `Prism::BlockArgumentNode` itself, which is
246
+ # never a collector value position, so recording on it would be dead weight.
247
+ def record_transitive_void_origin(receiver_type, method_name, call_node, scope)
248
+ return if scope.nil? || call_node.nil?
249
+ return unless call_node.is_a?(Prism::CallNode)
250
+
251
+ class_name, kind = discovered_method_lookup(receiver_type)
252
+ return if class_name.nil?
253
+ return unless scope.discovered_method?(class_name, method_name, kind)
254
+
255
+ def_node =
256
+ if kind == :singleton
257
+ scope.singleton_def_for(class_name, method_name)
258
+ else
259
+ scope.user_def_for(class_name, method_name)
260
+ end
261
+ return if def_node.nil?
262
+
263
+ origin = VoidTailSummary.new(scope).origin_for(def_node, class_name, kind)
264
+ scope.record_void_origin(call_node, origin) if origin
265
+ end
266
+
228
267
  # v0.1.3 — discovered-method dispatch tier. `scope` carries the `discovered_methods` table
229
268
  # built once per program by `ScopeIndexer` (a `Hash[String, Hash[Symbol, :instance |
230
269
  # :singleton]]`). When the receiver names a discovered class AND the requested method is
@@ -701,7 +740,7 @@ module Rigor
701
740
  private_constant :STDLIB_SINGLETON_FOLDERS
702
741
 
703
742
  PRECISE_TIERS_TAIL = Ractor.make_shareable([
704
- KernelDispatch, MethodFolding, ReduceFolding, ArrayToHFolding, BlockFolding
743
+ MethodFolding, ReduceFolding, ArrayToHFolding, BlockFolding
705
744
  ].freeze)
706
745
  private_constant :PRECISE_TIERS_TAIL
707
746
 
@@ -735,6 +774,9 @@ module Rigor
735
774
  return result if result
736
775
  end
737
776
 
777
+ kernel_result = dispatch_kernel_intrinsic(context)
778
+ return kernel_result if kernel_result
779
+
738
780
  PRECISE_TIERS_TAIL.each do |tier|
739
781
  result = tier.try_dispatch(context)
740
782
  return result if result
@@ -742,6 +784,20 @@ module Rigor
742
784
  nil
743
785
  end
744
786
 
787
+ # ADR-91 WD1 — the single dispatcher-held ownership gate for the Kernel module-function surface
788
+ # (gate-by-held-key, the same move STDLIB_SINGLETON_FOLDERS made above). The tier runs only when
789
+ # the method name is in the compiled `INTRINSIC_NAMES` table AND the call is attributable to
790
+ # Kernel itself (implicit self / `Kernel.` receiver, no discovered user redefinition). Because the
791
+ # gate lives HERE and nowhere else, a fold inside KernelDispatch that "forgets" its ownership
792
+ # guard is unrepresentable — the whole tier is unreachable for a foreign receiver. Unit probes
793
+ # calling `KernelDispatch.try_dispatch` directly keep the caller-vouches-for-ownership contract.
794
+ def dispatch_kernel_intrinsic(context)
795
+ return nil unless KernelDispatch::INTRINSIC_NAMES.include?(context.method_name)
796
+ return nil unless KernelDispatch.kernel_owned_call?(context)
797
+
798
+ KernelDispatch.try_dispatch(context)
799
+ end
800
+
745
801
  def try_user_class_fallback(receiver_type, environment, call_node, context)
746
802
  return nil if environment.nil?
747
803
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../type"
4
+ require_relative "../source/node_children"
4
5
 
5
6
  module Rigor
6
7
  module Inference
@@ -101,7 +102,8 @@ module Rigor
101
102
 
102
103
  # Returns a scope with the call's receiver widened, when the receiver is a
103
104
  # local-/instance-variable read whose current binding is a literal-shape carrier
104
- # (`Tuple` / `HashShape`) AND the call name is a known in-place mutator for that shape.
105
+ # (`Tuple` / `HashShape`) or an empty-witness refinement (`non-empty-array` /
106
+ # `non-empty-hash`) AND the call name is a known in-place mutator for that shape.
105
107
  # Returns `current_scope` unchanged otherwise.
106
108
  #
107
109
  # @param call_node [Prism::CallNode]
@@ -160,7 +162,7 @@ module Rigor
160
162
  # scope's locals) trigger widening, so descending into nested blocks is safe and
161
163
  # necessary for the hkt_registry-shape case (an outer collection mutated inside an
162
164
  # iterator block whose body is itself inside another block).
163
- node.compact_child_nodes.each do |child|
165
+ node.rigor_each_child do |child|
164
166
  scope = walk_for_outer_mutations(child, scope)
165
167
  end
166
168
  scope
@@ -216,6 +218,30 @@ module Rigor
216
218
  return nil unless HASH_MUTATORS.include?(method_name)
217
219
 
218
220
  widen_hash_shape(type)
221
+ when Type::Difference
222
+ widen_difference(type, method_name)
223
+ end
224
+ end
225
+
226
+ # `non-empty-array[T]` / `non-empty-hash[K, V]` → the bare base nominal. These refinement
227
+ # carriers are what `empty?` / `any?` narrowing writes (ADR-47 §4-4), and they are just as
228
+ # invalidated by an in-place mutator as a `Tuple` is: `arr.clear` makes `arr` empty, so a
229
+ # surviving `Difference[Array[T], Tuple[]]` would project `arr.size` to `positive-int` and
230
+ # fold `arr.size == 0` to `Constant[false]` — a false `flow.always-falsey-condition` on
231
+ # correct code.
232
+ #
233
+ # Only the EMPTY-witness differences over Array / Hash are widened, and only for that
234
+ # base's mutator table. The other catalogued refinements are unreachable from these tables
235
+ # by construction: `non-empty-string` and `non-zero-int` bind String / Integer receivers,
236
+ # whose mutators (`String#<<` and friends) appear in neither `ARRAY_MUTATORS` nor
237
+ # `HASH_MUTATORS` — and none of them can empty a non-empty string anyway.
238
+ def widen_difference(difference, method_name)
239
+ return nil unless difference.removes_empty_witness?
240
+
241
+ base = difference.base
242
+ case base.class_name
243
+ when "Array" then ARRAY_MUTATORS.include?(method_name) ? base : nil
244
+ when "Hash" then HASH_MUTATORS.include?(method_name) ? base : nil
219
245
  end
220
246
  end
221
247
 
@@ -248,16 +274,24 @@ module Rigor
248
274
  Type::Combinator.nominal_of("Hash", type_args: [key_type, value_type])
249
275
  end
250
276
 
251
- # Maps the literal Ruby key set (`Symbol` / `String`) to a union of the corresponding type
252
- # carriers. We deliberately do NOT fold to a `Constant<:k1> | Constant<:k2>` union that
253
- # would be a precision improvement that complicates the widening contract; the goal here
254
- # is to LOSE precision, not to record a new fact set.
277
+ # Maps the literal Ruby key set to a union of the corresponding type carriers. Symbol / String /
278
+ # Integer / Float keys widen to their class nominal; the `true` / `false` / `nil` singleton keys
279
+ # keep their constant carrier (the constant IS the class's whole value set, and `nil` reads better
280
+ # than `NilClass` in a widened `Hash[K, V]`). We deliberately do NOT fold the widenable kinds to a
281
+ # `Constant<:k1> | Constant<:k2>` union — that would be a precision improvement that complicates
282
+ # the widening contract; the goal here is to LOSE precision, not to record a new fact set.
255
283
  def key_union_for(keys)
256
- kinds = keys.map { |k| k.is_a?(Symbol) ? "Symbol" : "String" }.uniq
257
- carriers = kinds.map { |name| Type::Combinator.nominal_of(name) }
284
+ carriers = keys.map { |k| key_widening_carrier(k) }.uniq
258
285
  carriers.size == 1 ? carriers.first : Type::Combinator.union(*carriers)
259
286
  end
260
287
 
288
+ def key_widening_carrier(key)
289
+ case key
290
+ when true, false, nil then Type::Combinator.constant_of(key)
291
+ else Type::Combinator.nominal_of(key.class.name)
292
+ end
293
+ end
294
+
261
295
  # ----------------------------------------------------------------
262
296
  # ADR-56 slice C — receiver-content element-type JOIN.
263
297
  #
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../reflection"
6
+ require_relative "../source/constant_path"
6
7
  require_relative "../type"
7
8
  require_relative "../environment"
8
9
  require_relative "../rbs_extended"
@@ -41,7 +42,13 @@ module Rigor
41
42
  TRUSTED_EQUALITY_LITERAL_CLASSES = [String, Symbol, Integer, TrueClass, FalseClass, NilClass].freeze
42
43
  SINGLETON_LITERAL_CLASSES = [TrueClass, FalseClass, NilClass].freeze
43
44
  ClassNarrowingContext = Data.define(:exact, :polarity, :environment)
44
- private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext
45
+ # A recognised `=~` regex pattern operand: the pattern `source` string and whether it was
46
+ # compiled in extended (`//x`) mode. Extraction from a literal `RegularExpressionNode` and from
47
+ # a value-pinned `Constant[Regexp]` share this carrier so the participation walk and the
48
+ # extended-mode bail read one shape.
49
+ RegexMatchPattern = Data.define(:source, :extended)
50
+ private_constant :TRUSTED_EQUALITY_LITERAL_CLASSES, :SINGLETON_LITERAL_CLASSES, :ClassNarrowingContext,
51
+ :RegexMatchPattern
45
52
 
46
53
  module_function
47
54
 
@@ -962,8 +969,8 @@ module Rigor
962
969
  end
963
970
 
964
971
  def simple_dispatch_name?(name)
965
- %i[nil? ! is_a? kind_of? instance_of? == != === =~ key? has_key? empty? any? none?
966
- respond_to?].include?(name)
972
+ %i[nil? ! is_a? kind_of? instance_of? == != === =~ match? key? has_key? empty? any?
973
+ none? respond_to?].include?(name)
967
974
  end
968
975
 
969
976
  def dispatch_call_simple(node, scope, name)
@@ -974,6 +981,7 @@ module Rigor
974
981
  when :==, :!= then analyse_equality_predicate(node, scope, equality: name)
975
982
  when :=== then analyse_case_equality_predicate(node, scope)
976
983
  when :=~ then analyse_regex_match_predicate(node, scope)
984
+ when :match? then analyse_whole_regex_match_predicate(node, scope)
977
985
  when :key?, :has_key? then analyse_key_presence_predicate(node, scope)
978
986
  when :empty?, :any?, :none? then analyse_array_emptiness_predicate(node, scope, name)
979
987
  when :respond_to? then analyse_respond_to_predicate(node, scope)
@@ -1199,30 +1207,123 @@ module Rigor
1199
1207
  # - Falsey edge (`=~` returned nil — no match): `$~` and every numbered / back-reference
1200
1208
  # global bound to `Constant<nil>`.
1201
1209
  #
1202
- # Returns nil (no narrowing) when the receiver / argument pair does not include a
1203
- # `RegularExpressionNode` literal we can count.
1210
+ # Returns nil (no narrowing) when the receiver / argument pair does not resolve to exactly one
1211
+ # regex pattern we can count (see {#regex_match_pattern}).
1204
1212
  def analyse_regex_match_predicate(node, scope)
1205
1213
  return nil if node.arguments.nil?
1206
1214
  return nil unless node.arguments.arguments.size == 1
1207
1215
 
1208
- regex_node = regex_match_literal(node.receiver, node.arguments.arguments.first)
1209
- return nil if regex_node.nil?
1216
+ arg = node.arguments.arguments.first
1217
+ pattern = regex_match_pattern(node.receiver, arg, scope)
1218
+ return nil if pattern.nil?
1219
+ # Extended mode (`//x`) lets the pattern carry free whitespace and `#` comments, and a
1220
+ # comment may contain a literal `(` — which the light char-scan walker would miscount as a
1221
+ # capturing group, shifting `$N` indices and narrowing the wrong global. That is a
1222
+ # false-negative-class misnarrowing, so bail rather than risk it. The literal path shares
1223
+ # this bail: `RegularExpressionNode` carries the same latent miscount.
1224
+ return nil if pattern.extended
1225
+
1226
+ unconditional = unconditional_capture_groups(pattern.source)
1227
+ truthy, falsey = regex_match_predicate_scopes(scope, unconditional)
1228
+ # #164 — layer whole-receiver refinement onto the truthy edge without disturbing the
1229
+ # match-global logic above. A successful `str =~ /\A\d+\z/` proves the WHOLE string
1230
+ # matched, so the string operand narrows to the imported refinement the anchored pattern
1231
+ # names. The falsey edge is untouched: a failed match proves nothing about the shape.
1232
+ truthy = apply_whole_receiver_refinement(truthy, node.receiver, arg, pattern.source, scope)
1233
+ [truthy, falsey]
1234
+ end
1235
+
1236
+ # `str.match?(/\A\d+\z/)` — `String#match?` is a pure boolean predicate whose truthy edge
1237
+ # proves a property of the ENTIRE receiver. When the argument is a fully `\A…\z`-anchored
1238
+ # single-char-class pattern recognised by {RegexRefinement.for_whole_pattern}, narrow the
1239
+ # receiver local to the matching imported refinement on the truthy edge; the falsey edge
1240
+ # passes through unchanged (failing one pattern proves nothing about the string's shape).
1241
+ # A non-literal / unanchored / `\Z` / line-anchored / `//x` pattern falls through to no
1242
+ # narrowing.
1243
+ def analyse_whole_regex_match_predicate(node, scope)
1244
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
1245
+ return nil if node.arguments.nil? || node.arguments.arguments.size != 1
1210
1246
 
1211
- unconditional = unconditional_capture_groups(regex_node.unescaped)
1212
- regex_match_predicate_scopes(scope, unconditional)
1247
+ pattern = regex_operand_pattern(node.arguments.arguments.first, scope)
1248
+ return nil unless pattern.is_a?(RegexMatchPattern)
1249
+ return nil if pattern.extended
1250
+
1251
+ refinement = ::Rigor::Builtins::RegexRefinement.for_whole_pattern(pattern.source)
1252
+ return nil if refinement.nil?
1253
+
1254
+ local_name = node.receiver.name
1255
+ return nil if scope.local(local_name).nil?
1256
+
1257
+ [scope.with_local(local_name, refinement), scope]
1213
1258
  end
1214
1259
 
1215
- def regex_match_literal(left, right)
1216
- return left if left.is_a?(Prism::RegularExpressionNode)
1217
- return right if right.is_a?(Prism::RegularExpressionNode)
1260
+ # Narrows the string operand of a `=~` predicate to a whole-receiver refinement when the
1261
+ # regex source is a recognised `\A…\z`-anchored pattern. Returns the (possibly unchanged)
1262
+ # truthy scope; the falsey edge is never touched by this layer.
1263
+ def apply_whole_receiver_refinement(truthy, receiver, arg, source, scope)
1264
+ refinement = ::Rigor::Builtins::RegexRefinement.for_whole_pattern(source)
1265
+ return truthy if refinement.nil?
1218
1266
 
1219
- nil
1267
+ # `regex_match_pattern` already resolved the regex operand from a literal or a
1268
+ # `Constant[Regexp]` constant read — never a bare local — so the string subject is
1269
+ # unambiguously the local-read operand, if either operand is one.
1270
+ subject = [receiver, arg].find { |operand| operand.is_a?(Prism::LocalVariableReadNode) }
1271
+ return truthy if subject.nil? || scope.local(subject.name).nil?
1272
+
1273
+ truthy.with_local(subject.name, refinement)
1220
1274
  end
1221
1275
 
1222
- # Curated set of back-reference globals bound by every
1223
- # `=~`. Numbered references (`$1..$N`) are handled
1224
- # separately because N depends on the regex source.
1225
- REGEX_MATCH_GLOBALS = %i[$~ $& $` $' $+].freeze
1276
+ # Recognises the regex pattern operand of a `=~` predicate. An operand resolves to a regex when
1277
+ # it is either a syntactic `RegularExpressionNode` literal OR a constant read whose type is a
1278
+ # value-pinned `Constant[Regexp]` (`RE = /.../` and `RE = Regexp.new(...)` both fold to the
1279
+ # carrier in the whole-program constant pre-pass). Returns a single {RegexMatchPattern}, or nil
1280
+ # (no narrowing) when NEITHER operand resolves to a regex, when BOTH do (`/a/ =~ /b/` — no
1281
+ # string subject to bind), or when a constant operand is ambiguous — typed as a `Union` from a
1282
+ # twice-assigned constant, where no single source can be pinned.
1283
+ def regex_match_pattern(left, right, scope)
1284
+ left_pattern = regex_operand_pattern(left, scope)
1285
+ right_pattern = regex_operand_pattern(right, scope)
1286
+ return nil if left_pattern == :ambiguous || right_pattern == :ambiguous
1287
+
1288
+ patterns = [left_pattern, right_pattern].grep(RegexMatchPattern)
1289
+ patterns.size == 1 ? patterns.first : nil
1290
+ end
1291
+
1292
+ # Classifies one `=~` operand: a {RegexMatchPattern} when it resolves to a regex (literal or
1293
+ # `Constant[Regexp]`), `:ambiguous` when a constant operand types as a `Union` (a twice-assigned
1294
+ # constant), or nil when the operand is not a regex at all.
1295
+ def regex_operand_pattern(node, scope)
1296
+ case node
1297
+ when Prism::RegularExpressionNode
1298
+ RegexMatchPattern.new(source: node.unescaped, extended: node.extended?)
1299
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1300
+ regex_operand_from_constant(node, scope)
1301
+ end
1302
+ end
1303
+
1304
+ # Resolves a constant-reference operand to a regex pattern via the shared lexical-constant
1305
+ # resolution (`Reflection.resolve_constant_type`). `Regexp#options` gives the real
1306
+ # extended-mode flag; `Regexp#source` the pattern. Returns `:ambiguous` for a `Union`-typed
1307
+ # constant so the caller bails, and nil when the constant does not resolve to a `Constant[Regexp]`.
1308
+ def regex_operand_from_constant(node, scope)
1309
+ name = Source::ConstantPath.qualified_name_or_nil(node)
1310
+ return nil if name.nil?
1311
+
1312
+ type = Reflection.resolve_constant_type(name, scope: scope)
1313
+ return :ambiguous if type.is_a?(Type::Union)
1314
+ return nil unless type.is_a?(Type::Constant)
1315
+
1316
+ value = type.value
1317
+ return nil unless value.is_a?(Regexp)
1318
+
1319
+ RegexMatchPattern.new(source: value.source, extended: value.options.anybits?(Regexp::EXTENDED))
1320
+ end
1321
+
1322
+ # Curated set of match globals bound non-nil on every successful `=~` regardless of grouping:
1323
+ # `$~` (the MatchData), `$&` (whole match), `` $` `` (pre-match), `$'` (post-match). Numbered
1324
+ # references (`$1..$N`) and `$+` (the last matched group) are handled separately because they
1325
+ # depend on the regex source — `$+` is gated on the same participation set as `$N` (#177).
1326
+ REGEX_MATCH_GLOBALS = %i[$~ $& $` $'].freeze
1226
1327
  private_constant :REGEX_MATCH_GLOBALS
1227
1328
 
1228
1329
  # `unconditional` is the Set of 1-based numbered-capture indices whose group is
@@ -1251,6 +1352,15 @@ module Rigor
1251
1352
  truthy = truthy.with_global(name, string_t)
1252
1353
  falsey = falsey.with_global(name, nil_t)
1253
1354
  end
1355
+ # `$+` is the LAST matched group, so it is nil on a successful match whose groups are all
1356
+ # optional (`"b" =~ /(a)?b/` matches yet leaves `$+` nil) or absent (no capture group). It
1357
+ # narrows to `String` only when some group is guaranteed to participate — the same
1358
+ # participation gate `$N` uses (#177). With no unconditional group it is left unbound on both
1359
+ # edges (fall through to the RBS `String?` default), exactly as an optional `$N` is.
1360
+ unless unconditional.empty?
1361
+ truthy = truthy.with_global(:$+, string_t)
1362
+ falsey = falsey.with_global(:$+, nil_t)
1363
+ end
1254
1364
  [truthy, falsey]
1255
1365
  end
1256
1366