rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -1040,9 +1040,10 @@ module Rigor
1040
1040
  # No type carrier has a method that changes it once built (a built type is frozen; one `Marshal.load`
1041
1041
  # restores is not, but nothing mutates it either), so a memoised key cannot go stale. The memo is weak
1042
1042
  # in both key and value: it keeps neither the type nor the string alive, and an entry whose string
1043
- # nothing else holds is dropped at the next GC. It is keyed by identity on purpose: `Constant[0.0]` and
1044
- # `Constant[-0.0]` are equal and hash alike, but render differently. A non-main Ractor may not read the
1045
- # module's ivar, so a pool worker renders the key every time, as every run did before.
1043
+ # nothing else holds is dropped at the next GC. It is keyed by identity on purpose: two equal members
1044
+ # need not render alike (`Constant[1..2]` and `Constant[1..2.0]`), so equality is not a safe key. A
1045
+ # non-main Ractor may not read the module's ivar, so a pool worker renders the key every time, as every
1046
+ # run did before.
1046
1047
  def sort_key(member)
1047
1048
  return member.describe(:short) unless Ractor.main?
1048
1049
 
@@ -79,7 +79,10 @@ module Rigor
79
79
  # Scalar carriers without RBS Literal support (Float, Range, Rational, Complex, Regexp, Pathname)
80
80
  # keep their pre-existing widen-to-class-name behaviour because RBS rejects their literal spellings
81
81
  # as syntax errors. `Range` is generic (`Range[out E]`), and a bare `Range` fails `rbs validate`, so
82
- # it carries the class of its endpoints, or `untyped` when both are nil (#1891).
82
+ # it carries the class of its endpoints, or `untyped` when both are nil (#1891). `Set[unchecked out A]` is
83
+ # generic too, and a set is mutable: the constant's elements are only what it held when it was built, and
84
+ # `x << "a"` adds a member they do not name. It erases to `Set[untyped]`, as an Array or Hash literal's
85
+ # element type is widened rather than pinned (#2177).
83
86
  def erase_to_rbs
84
87
  case value
85
88
  when true then "true"
@@ -88,6 +91,7 @@ module Rigor
88
91
  when Integer then value.to_s
89
92
  when Symbol, String then value.inspect
90
93
  when Range then "Range[#{range_element_rbs}]"
94
+ when ::Set then "Set[untyped]"
91
95
  else value.class.name
92
96
  end
93
97
  end
@@ -96,13 +100,51 @@ module Rigor
96
100
 
97
101
  include Rigor::Type::AcceptanceRouter
98
102
 
103
+ # Two carriers are equal when they pin the same value, which for a Float is stricter than Ruby's `==`
104
+ # (#1513): `0.0` and `-0.0` are distinct values (`1.0 / x` tells them apart), so `0.0 | -0.0` keeps both
105
+ # members, and a NaN equals another NaN, so `==` stays reflexive and a union never collects one NaN member
106
+ # per occurrence. A Complex compares its parts the same way.
99
107
  def ==(other)
100
- other.is_a?(Constant) && value.class == other.value.class && value == other.value
108
+ other.is_a?(Constant) && value.class == other.value.class && Constant.same_value?(value, other.value)
101
109
  end
102
110
  alias eql? ==
103
111
 
104
112
  def hash
105
- [Constant, value.class, value].hash
113
+ [Constant, value.class, Constant.value_key(value)].hash
114
+ end
115
+
116
+ # True when `left` and `right` (of one class) are the same value in the sense of {#==}.
117
+ def self.same_value?(left, right)
118
+ case left
119
+ when Float then same_float?(left, right)
120
+ when Complex then same_float?(left.real, right.real) && same_float?(left.imaginary, right.imaginary)
121
+ else left == right
122
+ end
123
+ end
124
+
125
+ def self.same_float?(left, right)
126
+ return left == right unless left.is_a?(Float) && right.is_a?(Float)
127
+ return right.nan? if left.nan?
128
+
129
+ left.eql?(right) && (!left.zero? || (1.0 / left).positive? == (1.0 / right).positive?)
130
+ end
131
+
132
+ # A key whose `eql?` / `hash` agree with {.same_value?}: `uniq { |v| Constant.value_key(v) }` keeps
133
+ # `0.0` and `-0.0` apart and folds every NaN into one. Any other value is its own key, so plain `uniq`
134
+ # semantics are unchanged for it.
135
+ def self.value_key(value)
136
+ case value
137
+ when Float then float_key(value)
138
+ when Complex then [Complex, float_key(value.real), float_key(value.imaginary)]
139
+ else value
140
+ end
141
+ end
142
+
143
+ def self.float_key(value)
144
+ return value unless value.is_a?(Float)
145
+ return :nan if value.nan?
146
+
147
+ value.zero? ? [Float, (1.0 / value)] : value
106
148
  end
107
149
 
108
150
  def inspect
@@ -29,7 +29,7 @@ module Rigor
29
29
  EXTRA_KEY_POLICIES = %i[open closed].freeze
30
30
  POLICY_KEYWORDS = %i[required_keys optional_keys read_only_keys extra_keys].freeze
31
31
  # A Symbol whose text matches renders as a bare RBS record key (`lang:`) in {#erase_to_rbs};
32
- # anything else must be quoted with a fat arrow (`"data-contrast" =>`). See {#erase_key_prefix}.
32
+ # anything else is a quoted Symbol with a fat arrow (`:"data-contrast" =>`). See {#erase_key_prefix}.
33
33
  BARE_RECORD_KEY = /\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/
34
34
 
35
35
  attr_reader :pairs, :required_keys, :optional_keys, :read_only_keys, :extra_keys
@@ -72,7 +72,7 @@ module Rigor
72
72
  def erase_to_rbs
73
73
  return "{}" if pairs.empty? && closed?
74
74
  return hash_erasure unless closed?
75
- return hash_erasure if pairs.each_key.any? { |k| !k.is_a?(Symbol) }
75
+ return hash_erasure if pairs.each_key.any? { |k| !k.is_a?(Symbol) || !record_key_spellable?(k) }
76
76
 
77
77
  rendered = pairs.map { |k, v| "#{erase_key_prefix(k)} #{v.erase_to_rbs}" }
78
78
  "{ #{rendered.join(', ')} }"
@@ -221,21 +221,26 @@ module Rigor
221
221
 
222
222
  # An RBS record entry's key + separator, for the {#erase_to_rbs} path (which only ever sees
223
223
  # Symbol keys — string-keyed shapes degrade to `Hash[...]` before reaching here). A Symbol whose
224
- # text is a plain identifier is a bare key with a colon (`lang:`); a hyphenated / punctuated
225
- # Symbol MUST use the quoted `"data-contrast" =>` form, because RBS rejects both a bare
226
- # non-identifier (`data-contrast:`) and a quoted key with a colon (`"data-contrast":`). Getting
227
- # this wrong made sig-gen emit an unparseable Mastodon `html_attributes` shape that crashed the
228
- # whole RBS env build. Valid identifier keys (incl. Ruby keywords like `class`) keep the colon
229
- # form, so only genuinely non-identifier keys change. Optional keys carry a leading `?`.
224
+ # text is a plain identifier is a bare key with a colon (`lang:`, Ruby keywords like `class`
225
+ # included). Any other Symbol is a quoted Symbol literal with a fat arrow (`:"data-contrast" =>`):
226
+ # RBS rejects a bare non-identifier (`data-contrast:`) and a quoted key with a colon
227
+ # (`"data-contrast":`), and reads `"data-contrast" =>` as the String key, so the written record
228
+ # declared a key the body never has (#2176). Optional keys carry a leading `?`.
230
229
  def erase_key_prefix(key)
231
230
  optional = optional_key?(key) ? "?" : ""
232
231
  if BARE_RECORD_KEY.match?(key.to_s)
233
232
  "#{optional}#{key}:"
234
233
  else
235
- "#{optional}#{key.to_s.inspect} =>"
234
+ "#{optional}:#{key.to_s.inspect} =>"
236
235
  end
237
236
  end
238
237
 
238
+ # `String#inspect` escapes `#{`, `#$` and `#@` as `\#`, which RBS's parser does not read back as
239
+ # `#`. A key spelled that way would name a different Symbol, so the shape erases to `Hash[...]`.
240
+ def record_key_spellable?(key)
241
+ !key.to_s.inspect.include?("\\#")
242
+ end
243
+
239
244
  def hash_erasure
240
245
  return "Hash[top, top]" if open?
241
246
  return "Hash" if pairs.empty?
@@ -63,10 +63,11 @@ module Rigor
63
63
  end
64
64
 
65
65
  # Erases to the tagging class nominal (conservative: the structural members are not RBS-expressible
66
- # as a class instance). The anonymous case erases to the `Struct` supertype.
66
+ # as a class instance). The anonymous case erases to the `Struct` supertype with an `untyped`
67
+ # element: `Struct[Elem]` is generic, and a bare `Struct` fails `rbs validate` (#2177).
67
68
  def erase_to_rbs
68
69
  name = class_name
69
- return "Struct" if name.nil?
70
+ return "Struct[untyped]" if name.nil?
70
71
 
71
72
  name
72
73
  end
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.4.5"
4
+ VERSION = "0.4.6"
5
5
  end
@@ -216,9 +216,10 @@ module Rigor
216
216
  # - `initialize`
217
217
  # - methods starting with `_` (Ruby convention for private/internal)
218
218
  # - `def self.<name>` (singleton-side)
219
- # - methods after a bare `private` (or `public` → `private` transition) — these are internal
220
- # helpers, not actions
221
- # - methods named as a `private :foo` argument
219
+ # - methods that are not public at the end of the class body, read in source order: after a bare
220
+ # `private` / `protected` (until a `public` switches back), named by `private :foo` /
221
+ # `protected :foo`, or defined inline (`private def foo`) — Rails builds `action_methods` from the
222
+ # public instance methods. `public :foo` after a `protected` section makes `foo` an action again.
222
223
  # - methods named as a callback target (`before_action :name`, `after_action`, `around_action`)
223
224
  #
224
225
  # Pre-fix, Mastodon's `AdminMailer#process_params` / `set_instance` / `set_locale` /
@@ -227,64 +228,80 @@ module Rigor
227
228
  CALLBACK_DECLARATIONS = %i[before_action after_action around_action].freeze
228
229
  private_constant :CALLBACK_DECLARATIONS
229
230
 
231
+ VISIBILITY_KEYWORDS = %i[public private protected].freeze
232
+ private_constant :VISIBILITY_KEYWORDS
233
+
230
234
  def collect_action_defs(body)
231
235
  return [] if body.nil?
232
236
 
233
- private_names, callback_names = collect_visibility_and_callbacks(body)
234
- visibility = :public
237
+ callback_names = collect_callback_names(body)
238
+ def_nodes, final_visibility = scan_visibility(body)
235
239
 
236
- body.compact_child_nodes.flat_map do |node|
237
- visibility = next_visibility(node, visibility)
238
- next [] unless node.is_a?(Prism::DefNode)
239
- next [] if node.receiver.is_a?(Prism::SelfNode)
240
- next [] if node.name == :initialize
241
- next [] if node.name.to_s.start_with?("_")
242
- next [] if visibility == :private
243
- next [] if private_names.include?(node.name)
244
- next [] if callback_names.include?(node.name)
240
+ def_nodes.select do |node|
241
+ next false if node.receiver.is_a?(Prism::SelfNode)
242
+ next false if node.name == :initialize
243
+ next false if node.name.to_s.start_with?("_")
244
+ next false if callback_names.include?(node.name)
245
245
 
246
- [node]
246
+ # Rails builds `action_methods` from the PUBLIC instance methods, so only a method whose final
247
+ # visibility is public is an action.
248
+ final_visibility[node.name] == :public
247
249
  end
248
250
  end
249
251
 
250
- # First pass over the class body: collect (a) names passed to `private :foo` / `protected :foo`
251
- # (explicit visibility-on-existing-method form), and (b) Symbol arguments to callback
252
- # declarations (`before_action :setup`, etc.).
253
- def collect_visibility_and_callbacks(body)
254
- private_names = []
255
- callback_names = []
256
-
252
+ # Symbol arguments to callback declarations (`before_action :setup`, etc.).
253
+ def collect_callback_names(body)
254
+ names = []
257
255
  body.rigor_each_child do |node|
258
- next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
256
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil? && CALLBACK_DECLARATIONS.include?(node.name)
259
257
 
260
- args = (node.arguments&.arguments || []).filter_map do |arg|
261
- Rigor::Source::Literals.symbol(arg)
258
+ (node.arguments&.arguments || []).each do |arg|
259
+ symbol = Rigor::Source::Literals.symbol(arg)
260
+ names << symbol if symbol
262
261
  end
263
- next if args.empty?
262
+ end
263
+ names.to_set
264
+ end
264
265
 
265
- case node.name
266
- when :private, :protected then private_names.concat(args)
267
- when *CALLBACK_DECLARATIONS then callback_names.concat(args)
266
+ # Walks the class body in source order and returns `[def_nodes, final_visibility]`: every `def` the body
267
+ # spells (a direct statement or the inline argument of a visibility call, `private def x`), and the
268
+ # visibility each method name ends with. A bare `public` / `private` / `protected` sets the default for
269
+ # the defs after it; `private :x` / `public :x` / `protected def x` set that one method, whichever side of
270
+ # its `def` they fall on in the source, and the last statement to name a method wins.
271
+ def scan_visibility(body)
272
+ def_nodes = []
273
+ final_visibility = {}
274
+ default = :public
275
+
276
+ body.compact_child_nodes.each do |node|
277
+ case node
278
+ when Prism::DefNode
279
+ def_nodes << node
280
+ final_visibility[node.name] = default
281
+ when Prism::CallNode
282
+ default = apply_visibility_call(node, default, def_nodes, final_visibility)
268
283
  end
269
284
  end
270
285
 
271
- [private_names.to_set, callback_names.to_set]
286
+ [def_nodes, final_visibility]
272
287
  end
273
288
 
274
- # Returns the new visibility scope state after observing `node`. Bare `private` / `protected` /
275
- # `public` switch state; the `private :foo` arg-bearing form does NOT (already handled by
276
- # `collect_visibility_and_callbacks`).
277
- def next_visibility(node, current)
278
- return current unless node.is_a?(Prism::CallNode)
279
- return current unless node.receiver.nil?
280
- return current unless (args = node.arguments&.arguments).nil? || args.empty?
281
-
282
- case node.name
283
- when :private then :private
284
- when :protected then :protected
285
- when :public then :public
286
- else current
289
+ # Applies one receiverless `public` / `private` / `protected` call and returns the (possibly new) default.
290
+ def apply_visibility_call(node, default, def_nodes, final_visibility)
291
+ return default unless node.receiver.nil? && VISIBILITY_KEYWORDS.include?(node.name)
292
+
293
+ args = node.arguments&.arguments || []
294
+ return node.name if args.empty?
295
+
296
+ args.flat_map { |arg| arg.is_a?(Prism::ArrayNode) ? arg.elements : [arg] }.each do |arg|
297
+ if arg.is_a?(Prism::DefNode)
298
+ def_nodes << arg
299
+ final_visibility[arg.name] = node.name
300
+ elsif (name = Rigor::Source::Literals.symbol_or_string(arg))
301
+ final_visibility[name] = node.name
302
+ end
287
303
  end
304
+ default
288
305
  end
289
306
 
290
307
  def build_class_entry(class_name, file_path, def_nodes, includes = [], module_actions = {})
@@ -52,13 +52,32 @@ module Rigor
52
52
  # Bumped 2026-09-02 (#621) — mailer keys are de-rooted at the producer and a reopened class's
53
53
  # actions are UNIONed rather than clobbered, so a cached 0.4.0 index can be missing actions this
54
54
  # version knows about.
55
- version: "0.5.0",
55
+ #
56
+ # Bumped 2026-10-23 (#2308) — a method after a bare `protected` is no longer an action, so a cached 0.5.0
57
+ # index can list protected helpers as actions (and as missing-view candidates).
58
+ version: "0.5.1",
56
59
  description: "Validates ActionMailer call shape and view template existence.",
57
60
  config_schema: {
58
61
  "mailer_search_paths" => { kind: :array, default: ["app/mailers"] },
59
62
  "mailer_base_classes" => { kind: :array, default: %w[ApplicationMailer ActionMailer::Base] },
60
63
  "views_root" => { kind: :string, default: "app/views" }
61
64
  },
65
+ # ADR-125 (#575, #2308) — a mailer instance serves exactly one action (`Mailer.welcome(...)` builds a
66
+ # fresh instance and runs that one method), so ivar writes in two different public actions are never live
67
+ # together and `def.ivar-write-mismatch` stops comparing them. `ActionMailer::Base` has no signature here;
68
+ # the rule matches an undeclared superclass by its written name.
69
+ per_invocation_classes: ["ActionMailer::Base"],
70
+ # A public method these macros name is a callback or a view helper, not an action: it runs inside
71
+ # whichever action triggers it (AbstractController::Callbacks / Helpers), so its writes stay compared with
72
+ # every action's. The actionpack list, plus the `*_deliver` callbacks (ActionMailer::Callbacks), which run
73
+ # on the action's own instance.
74
+ per_invocation_non_action_macros: %i[
75
+ before_action prepend_before_action append_before_action skip_before_action
76
+ after_action prepend_after_action append_after_action skip_after_action
77
+ around_action prepend_around_action append_around_action skip_around_action
78
+ before_deliver after_deliver around_deliver
79
+ helper_method
80
+ ],
62
81
  # ADR-103 WD10 (#387) — see {Effects} for what each row is and why.
63
82
  effect_root: "rails",
64
83
  effect_labels: ["rails.actionmailer.deliver"],
@@ -146,6 +146,19 @@ module Rigor
146
146
  # `sig/action_view.rbs` so the name resolves, open so the helper surface stays lenient.
147
147
  "ActionView::Base"
148
148
  ],
149
+ # ADR-125 (#575) — Rails instantiates a fresh controller per request and runs exactly one action on
150
+ # it, so ivar writes in two different public actions are never live together and
151
+ # `def.ivar-write-mismatch` stops comparing them. Neither root has a signature (see `sig/action_controller.rbs`
152
+ # for why); the rule matches an undeclared superclass by its written name.
153
+ per_invocation_classes: ["ActionController::Base", "ActionController::API"],
154
+ # A public method these macros name is a callback or a view helper, not an action: it runs inside
155
+ # whichever action triggers it, so its writes stay compared with every action's (#575's A1 decision).
156
+ per_invocation_non_action_macros: %i[
157
+ before_action prepend_before_action append_before_action skip_before_action
158
+ after_action prepend_after_action append_after_action skip_after_action
159
+ around_action prepend_around_action append_around_action skip_around_action
160
+ helper_method
161
+ ],
149
162
  # ADR-103 WD10 / WD14 (#387) — see {Effects} for what each row is and why.
150
163
  effect_root: "rails",
151
164
  effect_labels: %w[
@@ -97,8 +97,10 @@ module Rigor
97
97
 
98
98
  # Cached separately from `:job_index` because the two invalidate on different files: editing a
99
99
  # schedule changes which jobs are reached without touching `app/jobs` at all. The `watch:` roots the
100
- # glob at the working directory so that CREATING a schedule file — not just editing one — is seen.
101
- producer :recurring_jobs, watch: -> { [[".", *@recurring_paths]] } do |_params|
100
+ # glob at the working directory so that CREATING a schedule file — not just editing one — is seen. Each
101
+ # configured path is escaped to a literal name, so `recurring[1].yml` is watched as that file.
102
+ producer :recurring_jobs,
103
+ watch: -> { [[".", *@recurring_paths.map { |path| Rigor::RootedGlob.literal(path) }]] } do |_params|
102
104
  RecurringScan.new(
103
105
  io_boundary: io_boundary,
104
106
  recurring_paths: @recurring_paths
@@ -33,9 +33,11 @@ module Rigor
33
33
  # - `:authenticatable` — always-included base module
34
34
  #
35
35
  # The substrate's pre-pass scanner consults each module's RBS via `Environment::RbsLoader#instance_definition`
36
- # to enumerate method names. A user project providing real Devise via Bundler will see methods like
37
- # `valid_password?`, `send_reset_password_instructions`, etc. resolve through the synthetic-method
38
- # tier without `call.undefined-method`.
36
+ # to enumerate method names, so it names only what the project's RBS declares. The `devise` gem ships no
37
+ # RBS and Rigor bundles none for `Devise::Models::*`: a project supplies those signatures (`signature_paths:`
38
+ # or an `rbs collection`), and only then do `valid_password?`, `send_reset_password_instructions`, etc.
39
+ # resolve through the synthetic-method tier. A name the model defines itself, or a project module it
40
+ # includes or prepends defines, is left to the project's definition.
39
41
  #
40
42
  # ## Precision tier
41
43
  #
@@ -120,16 +120,17 @@ module Rigor
120
120
 
121
121
  # The `watch:` glob tuples covering the scan's entire input — one `[root, pattern]` per project `paths:`
122
122
  # entry. A directory entry watches its whole `**/*.rb` subtree; a single-file `.rb` entry watches
123
- # exactly that file (`[dir, basename]`). Roots are expanded to absolute paths by
124
- # `Plugin::Base#watch_glob_entries`, so a relative CLI path (`app/models`) re-validates against the same
125
- # tree from the project root on the next run. Co-extensive with {#scannable_paths}: every `.rb` the scan
126
- # would read is watched, so any edit / addition / removal under those paths invalidates the cache.
123
+ # exactly that file (`[dir, basename]`, with the basename escaped so `[`, `{`, `*` or `?` match literally).
124
+ # Roots are expanded to absolute paths by `Plugin::Base#watch_glob_entries`, so a relative CLI path
125
+ # (`app/models`) re-validates against the same tree from the project root on the next run. Co-extensive
126
+ # with {#scannable_paths}: every `.rb` the scan would read is watched, so any edit / addition / removal
127
+ # under those paths invalidates the cache.
127
128
  def alias_watch_globs
128
129
  services.configuration.paths.filter_map do |entry|
129
130
  if io_boundary.directory?(entry)
130
131
  [entry, "**/*.rb"]
131
132
  elsif io_boundary.file?(entry) && entry.end_with?(".rb")
132
- [File.dirname(entry), File.basename(entry)]
133
+ [File.dirname(entry), Rigor::RootedGlob.literal(File.basename(entry))]
133
134
  end
134
135
  end
135
136
  end
@@ -35,7 +35,8 @@ module Rigor
35
35
  # LocalVariableReadNode. Composite receivers (`expect(foo.bar)`, `expect { ... }.to raise_error`)
36
36
  # fall through.
37
37
  # 3. The matcher is one of the recognised forms above (`be_a` / `be_kind_of` / `be_instance_of` /
38
- # `be_an_instance_of` / `be_nil` / `eq(literal)` / `eql(literal)` / `match(/regex/)`).
38
+ # `be_an_instance_of` / `be_nil` / `eq(literal)` / `eql(literal)` / `match(/regex/)`, the last a meet
39
+ # with the local's members; see `regex_match_type`).
39
40
  #
40
41
  # ## Equality is the local's own method
41
42
  #
@@ -152,10 +153,7 @@ module Rigor
152
153
  when :eq, :eql
153
154
  equality_type_for_literal_arg(matcher, target_local, negative: negative, scope: scope)
154
155
  when :match
155
- # `match(/regex/)` narrows x to String. `match("...")` or `match(arbitrary_object)` falls
156
- # through — the broader matcher dispatch needs the receiver to be a String, but we can only
157
- # assert that for a literal regex.
158
- string_type_for_regex_arg(matcher)
156
+ regex_match_type(matcher, target_local, negative: negative, scope: scope)
159
157
  end
160
158
  end
161
159
 
@@ -293,15 +291,108 @@ module Rigor
293
291
  end
294
292
  end
295
293
 
296
- def string_type_for_regex_arg(matcher)
294
+ # --- `match(/regex/)` (#2033) ---
295
+ #
296
+ # RSpec's `match` passes when `expected === actual`, when `actual == expected`, or when `actual` responds to
297
+ # `match` and `actual.match(expected)` is truthy. `Regexp#===` matches a Symbol by its name, so a Symbol
298
+ # passes too, and keeps being a Symbol. The positive fact is therefore a meet with the local's members, as
299
+ # `eq`'s is: a String or Symbol member is kept as it is, and a member of a core class that has no `match` or
300
+ # `to_str` and whose `==` never equals a Regexp (`nil`, `true`, `false`, the numerics) is dropped, unless the
301
+ # project defines one of those methods on it or a core ancestor. An `Object`, `BasicObject`, `Kernel` or
302
+ # `Comparable` member reads `String | Symbol` ({#module_match_member}); any other module means no fact. Any other member (a user class, `Dynamic`) may pass, and
303
+ # the analyzer emits no fact; so does a subject none of whose members can pass (a `nil` local stays `nil`). The negative form emits none either: a String that does not match passes
304
+ # `not_to match`, so nothing leaves the local.
305
+ REGEX_MATCH_KEPT_CLASSES = %w[String Symbol].freeze
306
+ REGEX_MATCH_METHODS = %i[match to_str].freeze
307
+ Ractor.make_shareable(REGEX_MATCH_KEPT_CLASSES)
308
+ Ractor.make_shareable(REGEX_MATCH_METHODS)
309
+ private_constant :REGEX_MATCH_KEPT_CLASSES, :REGEX_MATCH_METHODS
310
+
311
+ def regex_match_type(matcher, target_local, negative:, scope:)
312
+ return nil if negative || scope.nil?
313
+ return nil unless regex_literal_arg?(matcher)
314
+
315
+ current = scope.local(target_local)
316
+ return nil if current.nil?
317
+
318
+ members = current.is_a?(Rigor::Type::Union) ? current.members : [current]
319
+ kept = []
320
+ members.each do |member|
321
+ met = regex_match_member(member, scope)
322
+ return nil if met.equal?(UNDECIDED)
323
+
324
+ kept << met unless met.nil?
325
+ end
326
+ kept.empty? ? nil : Rigor::Type::Combinator.union(*kept)
327
+ end
328
+
329
+ def regex_literal_arg?(matcher)
297
330
  args = matcher.arguments&.arguments || []
298
- return nil unless args.size == 1
331
+ args.size == 1 &&
332
+ (args.first.is_a?(Prism::RegularExpressionNode) || args.first.is_a?(Prism::InterpolatedRegularExpressionNode))
333
+ end
299
334
 
300
- arg = args.first
301
- return nil unless arg.is_a?(Prism::RegularExpressionNode) ||
302
- arg.is_a?(Prism::InterpolatedRegularExpressionNode)
335
+ # The member itself when it may pass, nil when it cannot, UNDECIDED when Rigor cannot tell.
336
+ def regex_match_member(member, scope)
337
+ module_member = module_match_member(member, scope)
338
+ return module_member unless module_member.nil?
303
339
 
304
- Rigor::Type::Combinator.nominal_of("String")
340
+ class_name = member_class_name(member)
341
+ return UNDECIDED if class_name.nil?
342
+ return member if REGEX_MATCH_KEPT_CLASSES.include?(class_name)
343
+ return member if project_defines?(class_name, :==, scope)
344
+ return member if REGEX_MATCH_METHODS.any? { |name| project_defines_on_chain?(class_name, name, scope) }
345
+
346
+ nil
347
+ end
348
+
349
+ OPEN_SURFACE_CLASS_NAMES = %w[Object BasicObject].freeze
350
+ # The modules String's or Symbol's own ancestry includes (`Kernel`, `Comparable`), read from the running Ruby.
351
+ STRING_SYMBOL_MODULES = (String.ancestors + Symbol.ancestors).grep_v(Class).filter_map(&:name).uniq.freeze
352
+ Ractor.make_shareable(OPEN_SURFACE_CLASS_NAMES)
353
+ Ractor.make_shareable(STRING_SYMBOL_MODULES)
354
+ private_constant :OPEN_SURFACE_CLASS_NAMES, :STRING_SYMBOL_MODULES
355
+
356
+ # The meet for an `Object`, `BasicObject` or module member, or nil for any other member. Its instances may be
357
+ # anything, a `to_str` or `match` object included. A member that declares `match` or `to_str` itself (its
358
+ # signature, a core ancestor, or the project's source) is kept whole. `Object`, `BasicObject` and a module
359
+ # String or Symbol includes otherwise read `String | Symbol`, as the String they most often are, so a String
360
+ # method called after the matcher reports nothing. Any other module is UNDECIDED: a passing value may include
361
+ # it without being a String. (`approval-queue.d/regexp-match-open-surface-subject.md`, shared with #2256.)
362
+ def module_match_member(member, scope)
363
+ return nil unless member.is_a?(Rigor::Type::Nominal) && member.type_args.empty?
364
+
365
+ name = member.class_name
366
+ string_like = OPEN_SURFACE_CLASS_NAMES.include?(name) || STRING_SYMBOL_MODULES.include?(name)
367
+ return nil unless string_like || scope.environment&.rbs_module?(name)
368
+ return member if REGEX_MATCH_METHODS.any? { |method_name| declares_method?(name, method_name, scope) }
369
+ return UNDECIDED unless string_like
370
+
371
+ Rigor::Type::Combinator.union(Rigor::Type::Combinator.nominal_of("String"),
372
+ Rigor::Type::Combinator.nominal_of("Symbol"))
373
+ end
374
+
375
+ def declares_method?(name, method_name, scope)
376
+ project_defines_on_chain?(name, method_name, scope) ||
377
+ !Rigor::Reflection.instance_method_definition(name, method_name, scope: scope).nil?
378
+ end
379
+
380
+ # Each core class's (and `Object` / `BasicObject` / `Kernel`'s) runtime ancestor chain. The project's
381
+ # reopenings are looked up along it by name, since the scope's ancestry walk does not cross core classes:
382
+ # `class Numeric; def to_str` reaches a Float member, `module Kernel; def match` a nil one.
383
+ CORE_ANCESTOR_CHAINS = [*EQUALITY_CORE_CLASSES, Object, BasicObject, Kernel].to_h do |mod|
384
+ [mod.name, mod.ancestors.filter_map(&:name).freeze]
385
+ end.freeze
386
+ Ractor.make_shareable(CORE_ANCESTOR_CHAINS)
387
+ private_constant :CORE_ANCESTOR_CHAINS
388
+
389
+ # Whether the project's source defines `method_name` on `class_name` or an ancestor, core ones included: a
390
+ # `def`, an included module, or a `define_method`.
391
+ def project_defines_on_chain?(class_name, method_name, scope)
392
+ CORE_ANCESTOR_CHAINS.fetch(class_name, [class_name]).any? do |name|
393
+ !scope.user_def_through_ancestors(name, method_name).first.nil? ||
394
+ scope.discovered_method_through_ancestors?(name, method_name, :instance)
395
+ end
305
396
  end
306
397
 
307
398
  NO_LITERAL = Object.new.freeze
@@ -97,8 +97,10 @@ module Rigor
97
97
 
98
98
  # Cached separately from `:worker_index` because the two invalidate on different files: editing a
99
99
  # schedule changes which workers are reached without touching `app/workers` at all. The `watch:` roots
100
- # the glob at the working directory so that CREATING a schedule file — not just editing one — is seen.
101
- producer :scheduled_workers, watch: -> { [[".", *@schedule_paths]] } do |_params|
100
+ # the glob at the working directory so that CREATING a schedule file — not just editing one — is seen. Each
101
+ # configured path is escaped to a literal name, so `sidekiq[1].yml` is watched as that file.
102
+ producer :scheduled_workers,
103
+ watch: -> { [[".", *@schedule_paths.map { |path| Rigor::RootedGlob.literal(path) }]] } do |_params|
102
104
  ScheduleScan.new(
103
105
  io_boundary: io_boundary,
104
106
  schedule_paths: @schedule_paths
@@ -62,7 +62,7 @@ module Rigor
62
62
  if io_boundary.directory?(File.expand_path(entry))
63
63
  [entry, "**/*.rb"]
64
64
  elsif entry.end_with?(".rb") && io_boundary.file?(File.expand_path(entry))
65
- [File.dirname(entry), File.basename(entry)]
65
+ [File.dirname(entry), Rigor::RootedGlob.literal(File.basename(entry))]
66
66
  end
67
67
  end
68
68
  end
@@ -66,8 +66,9 @@ module Rigor
66
66
 
67
67
  # Always an Array of Bucket — `initialize` freezes what it is given and every caller hands it
68
68
  # Bucket instances (parse_row rows, from_diagnostics groups, `without`'s reject, or `[]`).
69
- # sig-gen gap: #1154 — an `attr_reader` over an ivar assigned in `initialize` from a parameter
70
- # is skipped (untyped return); the element type is hand-written.
69
+ # sig-gen gap: #2321 — `initialize` assigns `buckets.freeze`, an expression derived from the
70
+ # parameter rather than a bare read of it, so the reader is skipped (untyped return) and the element type
71
+ # is hand-written.
71
72
  attr_reader buckets: Array[Bucket]
72
73
 
73
74
  # `project_root` is `Pathname.new`'d, so a `Pathname` is accepted as well as a `String`
@@ -45,16 +45,15 @@ module Rigor
45
45
  # The collection a file with nothing to say contributes.
46
46
  def self.empty: (?String? path) -> FileCollection
47
47
 
48
- # sig-gen gap: #1154 — `initialize` freezes the kwarg tables rather than assigning them
49
- # verbatim, so sig-gen proposes the default constant's shape for each reader.
50
48
  attr_reader path: String?
51
- # sig-gen gap: #1154 — `{ "Class#m" => Summary }`.
49
+ # sig-gen gap: #2312 — `{ "Class#m" => Summary }`, through the private `freeze_table` helper, whose parameters
50
+ # are undeclared.
52
51
  attr_reader summaries: Hash[String, Summary]
53
- # sig-gen gap: #1154 — `{ "Class#m" => [Edge] }`, sorted and de-duplicated.
52
+ # sig-gen gap: #2312 — `{ "Class#m" => [Edge] }`, sorted and de-duplicated.
54
53
  attr_reader edges: Hash[String, Array[Edge]]
55
- # sig-gen gap: #1154 — `{ class_name => superclass_name }`, `OPAQUE_ANCESTOR` included.
54
+ # sig-gen gap: #2312 — `{ class_name => superclass_name }`, `OPAQUE_ANCESTOR` included.
56
55
  attr_reader superclasses: Hash[String, String]
57
- # sig-gen gap: #1154 — `{ class_name => [module_name] }`, include/prepend merged.
56
+ # sig-gen gap: #2312 — `{ class_name => [module_name] }`, include/prepend merged.
58
57
  attr_reader includes: Hash[String, Array[String]]
59
58
 
60
59
  def initialize: (?path: String?, ?summaries: Hash[String, Summary],