rigortype 0.4.4 → 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 (233) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +100 -18
  4. data/docs/manual/02-cli-reference.md +85 -16
  5. data/docs/manual/03-configuration.md +12 -9
  6. data/docs/manual/04-diagnostics.md +17 -6
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +17 -2
  12. data/docs/manual/12-caching.md +8 -1
  13. data/docs/manual/18-removing-dead-code.md +18 -7
  14. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  15. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  16. data/docs/manual/plugins/rigor-devise.md +15 -0
  17. data/docs/manual/plugins/rigor-rspec.md +102 -1
  18. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  19. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  20. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  21. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  22. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
  23. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
  24. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  25. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  26. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  27. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  28. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  29. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
  30. data/lib/rigor/analysis/check_rules.rb +319 -21
  31. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  32. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  33. data/lib/rigor/analysis/edit_replay.rb +67 -0
  34. data/lib/rigor/analysis/incremental.rb +86 -0
  35. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  36. data/lib/rigor/analysis/incremental_session.rb +336 -46
  37. data/lib/rigor/analysis/path_expansion.rb +44 -1
  38. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  39. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  40. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  41. data/lib/rigor/analysis/rule_catalog.rb +22 -5
  42. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  43. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  44. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  48. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  49. data/lib/rigor/analysis/runner.rb +222 -111
  50. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  51. data/lib/rigor/analysis/worker_session.rb +24 -1
  52. data/lib/rigor/cache/descriptor.rb +40 -7
  53. data/lib/rigor/cache/engine_source.rb +3 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +49 -8
  55. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  56. data/lib/rigor/cache/store.rb +4 -3
  57. data/lib/rigor/cli/check_command.rb +93 -4
  58. data/lib/rigor/cli/command.rb +3 -1
  59. data/lib/rigor/cli/diff_command.rb +2 -1
  60. data/lib/rigor/cli/docs_command.rb +3 -2
  61. data/lib/rigor/cli/doctor_command.rb +1 -1
  62. data/lib/rigor/cli/explain_command.rb +3 -2
  63. data/lib/rigor/cli/lens_command.rb +5 -5
  64. data/lib/rigor/cli/mcp_command.rb +2 -1
  65. data/lib/rigor/cli/options.rb +20 -3
  66. data/lib/rigor/cli/plugin_command.rb +2 -1
  67. data/lib/rigor/cli/plugins_command.rb +7 -1
  68. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  69. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  70. data/lib/rigor/cli/sig_gen_command.rb +73 -13
  71. data/lib/rigor/cli/skill_command.rb +2 -1
  72. data/lib/rigor/cli/skill_describe.rb +3 -2
  73. data/lib/rigor/cli/type_of_command.rb +2 -1
  74. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  75. data/lib/rigor/cli/unused_command.rb +10 -7
  76. data/lib/rigor/cli.rb +3 -2
  77. data/lib/rigor/config_audit.rb +72 -16
  78. data/lib/rigor/configuration/severity_profile.rb +20 -2
  79. data/lib/rigor/configuration.rb +43 -7
  80. data/lib/rigor/effects/signature_sources.rb +3 -1
  81. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  82. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  83. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  84. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  85. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  86. data/lib/rigor/environment/rbs_loader.rb +136 -7
  87. data/lib/rigor/environment/required_features.rb +136 -29
  88. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  89. data/lib/rigor/environment.rb +4 -2
  90. data/lib/rigor/inference/block_call_timing.rb +7 -1
  91. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  92. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  93. data/lib/rigor/inference/captured_locals.rb +44 -13
  94. data/lib/rigor/inference/clause_widening.rb +90 -0
  95. data/lib/rigor/inference/deep_erasure.rb +138 -0
  96. data/lib/rigor/inference/def_return_typer.rb +159 -9
  97. data/lib/rigor/inference/definer_resolution.rb +101 -20
  98. data/lib/rigor/inference/error_info.rb +17 -3
  99. data/lib/rigor/inference/expression_typer.rb +97 -18
  100. data/lib/rigor/inference/global_write_census.rb +36 -1
  101. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  102. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  103. data/lib/rigor/inference/loop_value_record.rb +53 -0
  104. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  105. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  106. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
  107. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  108. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  109. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  110. data/lib/rigor/inference/method_dispatcher.rb +113 -19
  111. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  112. data/lib/rigor/inference/module_function_state.rb +149 -42
  113. data/lib/rigor/inference/mutation_widening.rb +5 -2
  114. data/lib/rigor/inference/narrowing.rb +116 -11
  115. data/lib/rigor/inference/object_mixins.rb +13 -1
  116. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  117. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  118. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  119. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  120. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  121. data/lib/rigor/inference/project_seed_tables.rb +121 -0
  122. data/lib/rigor/inference/project_yielder.rb +128 -0
  123. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  124. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  125. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  126. data/lib/rigor/inference/return_barrier.rb +16 -0
  127. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  128. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  129. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  130. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  131. data/lib/rigor/inference/scope_indexer.rb +1144 -197
  132. data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
  133. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  134. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  135. data/lib/rigor/inference/statement_evaluator.rb +1088 -213
  136. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  137. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  138. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  139. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  140. data/lib/rigor/language_server/buffer_table.rb +6 -0
  141. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  142. data/lib/rigor/language_server/project_context.rb +59 -3
  143. data/lib/rigor/language_server/server.rb +5 -3
  144. data/lib/rigor/lens/contract_reader.rb +45 -2
  145. data/lib/rigor/lens/document.rb +2 -1
  146. data/lib/rigor/lens/file_reader.rb +12 -2
  147. data/lib/rigor/lens/members.rb +16 -2
  148. data/lib/rigor/lens/run.rb +34 -7
  149. data/lib/rigor/mcp/server.rb +11 -3
  150. data/lib/rigor/plugin/base.rb +29 -13
  151. data/lib/rigor/plugin/io_boundary.rb +42 -6
  152. data/lib/rigor/plugin/isolation.rb +4 -3
  153. data/lib/rigor/plugin/manifest.rb +29 -3
  154. data/lib/rigor/plugin/registry.rb +38 -0
  155. data/lib/rigor/protection/discovery_seed.rb +64 -36
  156. data/lib/rigor/protection/mutation_cache.rb +8 -2
  157. data/lib/rigor/rooted_glob.rb +36 -0
  158. data/lib/rigor/scope/discovery_index.rb +5 -0
  159. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  160. data/lib/rigor/scope/resolution_chain.rb +40 -5
  161. data/lib/rigor/scope.rb +59 -7
  162. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  163. data/lib/rigor/sig_gen/classification.rb +4 -1
  164. data/lib/rigor/sig_gen/generator.rb +465 -49
  165. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  166. data/lib/rigor/sig_gen/observation_collector.rb +141 -12
  167. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  168. data/lib/rigor/sig_gen/renderer.rb +16 -155
  169. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  170. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  171. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  172. data/lib/rigor/sig_gen/write_result.rb +3 -1
  173. data/lib/rigor/sig_gen/writer.rb +5 -3
  174. data/lib/rigor/signature_path_audit.rb +4 -2
  175. data/lib/rigor/type/combinator.rb +4 -3
  176. data/lib/rigor/type/constant.rb +54 -3
  177. data/lib/rigor/type/hash_shape.rb +14 -9
  178. data/lib/rigor/type/struct_instance.rb +3 -2
  179. data/lib/rigor/version.rb +1 -1
  180. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  181. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
  182. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  183. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  184. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  185. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  186. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  187. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  188. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  189. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  190. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  191. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  192. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  193. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  194. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  195. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  196. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  197. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  198. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  199. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  200. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  201. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  202. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  203. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  204. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  205. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  206. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  207. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  208. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  209. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  210. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  211. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  212. data/sig/rigor/analysis/baseline.rbs +3 -2
  213. data/sig/rigor/effects/file_collection.rbs +5 -6
  214. data/sig/rigor/effects/registry.rbs +8 -6
  215. data/sig/rigor/effects/summary.rbs +6 -5
  216. data/sig/rigor/environment.rbs +5 -3
  217. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  218. data/sig/rigor/inference.rbs +6 -0
  219. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  220. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  221. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  222. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  223. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  224. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  225. data/sig/rigor/plugin/manifest.rbs +16 -18
  226. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  227. data/sig/rigor/plugin/registry.rbs +1 -1
  228. data/sig/rigor/rooted_glob.rbs +7 -0
  229. data/sig/rigor/scope.rbs +34 -52
  230. data/sig/rigor/type.rbs +0 -6
  231. data/skills/rigor-doctor/references/01-checks.md +23 -11
  232. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  233. metadata +32 -1
@@ -159,13 +159,18 @@ module Rigor
159
159
 
160
160
  # Project-relative paths, the spelling `#template_units_for_file` is handed and the spelling the
161
161
  # compiled cache is keyed by.
162
+ #
163
+ # #1803 — listed through the boundary's `#glob`, which records each root's membership: a helper
164
+ # added under `app/helpers`, or a template under a `view_search_paths:` root the template globs do
165
+ # not claim, is analysed by nobody, and without the row the run-result caches kept serving the
166
+ # index built before it appeared.
162
167
  def self.source_files(io_boundary, roots, pattern)
163
168
  root_prefix = "#{Dir.pwd}#{File::SEPARATOR}"
164
169
  roots.flat_map do |root|
165
170
  absolute = File.expand_path(root)
166
171
  next [] unless io_boundary.directory?(absolute)
167
172
 
168
- Dir.glob(File.join(absolute, "**", pattern)).map { |path| path.delete_prefix(root_prefix) }
173
+ io_boundary.glob(absolute, "**/#{pattern}").map { |path| path.delete_prefix(root_prefix) }
169
174
  end
170
175
  end
171
176
 
@@ -116,7 +116,7 @@ module Rigor
116
116
  absolute = File.expand_path(root)
117
117
  next [] unless @io_boundary.directory?(absolute)
118
118
 
119
- Dir.glob(File.join(absolute, "**", "*.rb"))
119
+ @io_boundary.glob(absolute, "**/*.rb")
120
120
  end
121
121
  end
122
122
 
@@ -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[
@@ -127,7 +127,7 @@ module Rigor
127
127
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
128
128
  next [] unless @io_boundary.directory?(absolute)
129
129
 
130
- Dir.glob(File.join(absolute, "**", "*.rb"))
130
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
131
131
  end
132
132
  end
133
133
 
@@ -146,11 +146,12 @@ module Rigor
146
146
 
147
147
  # ADR-45 WD1b (#613) — `config/application.rb` is probed at a fixed path, so its ABSENCE is what
148
148
  # shapes "no adapter" on a project that has none yet; the probe goes through the boundary to record
149
- # that. The globbed `config/environments/*.rb` entries exist by enumeration and only pick up the
150
- # (immediately superseded) presence row.
149
+ # that. The `config/environments/*.rb` listing goes through `#glob` (#1803), whose row records the
150
+ # directory's membership; each listed entry only picks up the (immediately superseded) presence row.
151
151
  def config_files(io_boundary, root)
152
152
  application = File.join(root, CONFIG_PATHS.first)
153
- environments = Dir.glob(File.join(root, CONFIG_PATHS.last, "*.rb"))
153
+ environments_dir = File.join(root, CONFIG_PATHS.last)
154
+ environments = io_boundary.glob(environments_dir, "*.rb")
154
155
  ([application] + environments).select { |path| io_boundary.file?(path) }
155
156
  rescue StandardError
156
157
  []
@@ -92,7 +92,7 @@ module Rigor
92
92
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
93
93
  next [] unless @io_boundary.directory?(absolute)
94
94
 
95
- Dir.glob(File.join(absolute, "**", "*.rb"))
95
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
96
96
  end
97
97
  end
98
98
 
@@ -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
@@ -375,7 +375,7 @@ module Rigor
375
375
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
376
376
  next [] unless @io_boundary.directory?(absolute)
377
377
 
378
- Dir.glob(File.join(absolute, "**", "*.rb"))
378
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
379
379
  end
380
380
  end
381
381
 
@@ -69,7 +69,7 @@ module Rigor
69
69
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
70
70
  next [] unless @io_boundary.directory?(absolute)
71
71
 
72
- Dir.glob(File.join(absolute, "**", "*.rb"))
72
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
73
73
  end
74
74
  end
75
75
 
@@ -65,7 +65,7 @@ module Rigor
65
65
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
66
66
  next [] unless @io_boundary.directory?(absolute)
67
67
 
68
- Dir.glob(File.join(absolute, "**", "*.rb"))
68
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
69
69
  end
70
70
  end
71
71
  end
@@ -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
  #
@@ -109,7 +109,7 @@ module Rigor
109
109
  def scannable_paths
110
110
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
111
111
  if io_boundary.directory?(entry)
112
- Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
112
+ Dir.glob("**/*.rb", base: entry).map { |path| File.join(entry, path) }
113
113
  elsif io_boundary.file?(entry) && entry.end_with?(".rb")
114
114
  [entry]
115
115
  else
@@ -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
@@ -54,7 +54,7 @@ module Rigor
54
54
  if @io_boundary.file?(absolute)
55
55
  [absolute]
56
56
  elsif @io_boundary.directory?(absolute)
57
- Dir.glob(File.join(absolute, "**", "*.rb"))
57
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
58
58
  else
59
59
  []
60
60
  end
@@ -11,6 +11,8 @@ module Rigor
11
11
  # carried no row at all and was served after any edit, an analysed file's included, until `--no-cache`.
12
12
  class Discoverer
13
13
  RUBY_GLOB = "**/*.rb"
14
+ RUBY_FILE = "*.rb"
15
+ SKIPPED_DIRECTORIES = %w[vendor .git].freeze
14
16
 
15
17
  def self.discover(root:, io:)
16
18
  new(root: root, io: io).discover
@@ -67,20 +69,47 @@ module Rigor
67
69
 
68
70
  private
69
71
 
72
+ # #1959 — one `:kinds` listing row over the root's top-level entries, then one `:names` `**/*.rb` row per
73
+ # real directory among them other than `vendor/`. A single `**/*.rb` row over the root re-walked an in-tree
74
+ # `vendor/bundle` on every warm validation and read stale on every `bundle install`. The listing carries
75
+ # each entry's kind and not just its name: the walk descends a directory but not a symlinked one (`**` never
76
+ # followed it), so a file `gen` that becomes a directory `gen/`, or a symlink that becomes a real
77
+ # directory, changes what it reads while every name stays put.
78
+ #
79
+ # The answer is `Dir.glob("**/*.rb")` minus the skipped paths and minus every match that is not a regular
80
+ # file (#2113), in its order: entry by entry, a top-level name matching `*.rb`, then that directory's
81
+ # matches. A directory so named is walked, never read. The boundary answers absolute paths; they are spelled
82
+ # back under `@root` as `Dir.glob` spells them.
70
83
  def ruby_files
71
84
  return [] unless @root && @io.directory?(@root.to_s)
72
85
 
73
- boundary_glob.reject { |p| p.include?("/vendor/") || p.include?("/.git/") }
74
- end
75
-
76
- # One `:names` `**/*.rb` row over the root, `vendor/` included: a per-entry walk that skipped it recorded
77
- # names only, so a top-level entry that changed kind under the same name went unseen. The boundary
78
- # answers absolute paths; they are spelled back under `@root` as `Dir.glob` spells them, so the filter
79
- # above sees the same strings it always did.
80
- def boundary_glob
81
86
  root = @root.to_s
82
87
  prefix = "#{File.absolute_path(root)}/"
83
- @io.glob(root, RUBY_GLOB).map { |path| File.join(root, path.delete_prefix(prefix)) }
88
+ @io.list_directory_kinds(root).flat_map do |path, kind|
89
+ name = path.delete_prefix(prefix)
90
+ own = File.fnmatch?(RUBY_FILE, name) && readable_file?(path, kind) ? [File.join(root, name)] : []
91
+ next own unless kind == :directory && !SKIPPED_DIRECTORIES.include?(name)
92
+
93
+ own + @io.glob(path, RUBY_GLOB).filter_map do |nested|
94
+ relative = nested.delete_prefix(prefix)
95
+ File.join(root, relative) if !skipped?(relative) && @io.file?(nested)
96
+ end
97
+ end
98
+ end
99
+
100
+ # #2113 — `*.rb` names a directory too, and a fifo, socket or device can carry the name. Only a regular file
101
+ # is read: `read_file` on a directory raises `EISDIR` and aborts the whole walk, and on a fifo it blocks
102
+ # until a writer appears. A top-level entry is judged by its `lstat` kind, and a symlink by what it
103
+ # resolves to; a nested match is judged by `file?` (which follows a symlink as `File.file?` does).
104
+ def readable_file?(path, kind)
105
+ kind == :file || (kind == :symlink && @io.file?(path))
106
+ end
107
+
108
+ # #1956 — the skip reads the path relative to the root, a `vendor` or `.git` directory segment at any
109
+ # depth under it. Matching `/vendor/` in the whole path also dropped every file of a project that itself
110
+ # lives under some `…/vendor/…` directory, and the catalog came out empty.
111
+ def skipped?(relative)
112
+ relative.split("/")[0...-1].intersect?(SKIPPED_DIRECTORIES)
84
113
  end
85
114
 
86
115
  # The glob's rows record which files match; this read's row carries an edit.
@@ -26,7 +26,7 @@ module Rigor
26
26
  def extconf_uses_ffx?(root, io = nil)
27
27
  return false if root.nil?
28
28
 
29
- extconfs = io ? io.glob(root.to_s, EXTCONF_GLOB) : Dir.glob(File.join(root.to_s, EXTCONF_GLOB))
29
+ extconfs = io ? io.glob(root.to_s, EXTCONF_GLOB) : Dir.glob(EXTCONF_GLOB, base: root.to_s).map { |path| File.join(root.to_s, path) }
30
30
  extconfs.any? do |extconf|
31
31
  read_text(extconf, io).include?("FFX.create_makefile")
32
32
  rescue SystemCallError
@@ -72,7 +72,7 @@ module Rigor
72
72
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
73
73
  next [] unless @io_boundary.directory?(absolute)
74
74
 
75
- Dir.glob(File.join(absolute, "**", "*.rb"))
75
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
76
76
  end
77
77
  end
78
78
 
@@ -60,7 +60,7 @@ module Rigor
60
60
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
61
61
  next [] unless @io_boundary.directory?(absolute)
62
62
 
63
- Dir.glob(File.join(absolute, "**", "*.rb"))
63
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
64
64
  end
65
65
  end
66
66
 
@@ -34,12 +34,17 @@ module Rigor
34
34
  end
35
35
  end
36
36
 
37
- attr_reader :entries, :locales
37
+ attr_reader :entries, :locales, :load_errors
38
38
 
39
39
  # @param locales — all locale names that contributed at least one key.
40
- def initialize(entries, locales:)
40
+ # @param load_errors — the {LocaleLoader::LoadError}s of the files that did not parse (#1954). They
41
+ # ride in the index, not beside it, because the index is the cached producer value: a warm run
42
+ # serves it without re-reading a single locale file, so an error kept anywhere else is lost exactly
43
+ # when the cache hits.
44
+ def initialize(entries, locales:, load_errors: [])
41
45
  @entries = entries.freeze
42
46
  @locales = locales.dup.freeze
47
+ @load_errors = load_errors.map(&:freeze).freeze
43
48
  @by_key = entries.to_h { |e| [e.dotted_key, e] }.freeze
44
49
  freeze
45
50
  end
@@ -15,9 +15,9 @@ module Rigor
15
15
  # flattened into dotted keys (`users.welcome`, `errors.messages.blank`, …). For each leaf string,
16
16
  # `%{var}` placeholders are extracted via a simple regex.
17
17
  #
18
- # Files that fail to parse are skipped with a load-error diagnostic surfaced through the plugin's
19
- # error channel. Non-Hash YAML roots (e.g. a top-level sequence) are also skipped — the format is
20
- # locale-keyed by convention.
18
+ # Files that fail to parse are skipped and recorded in the returned index's `load_errors`, which the
19
+ # plugin discloses as a run-scoped load-error row. Non-Hash YAML roots (e.g. a top-level sequence) are
20
+ # also skipped — the format is locale-keyed by convention.
21
21
  class LocaleLoader
22
22
  PLACEHOLDER_RE = /%\{(?<name>[^}]+)\}/
23
23
 
@@ -66,7 +66,7 @@ module Rigor
66
66
  value_kinds: per_key_kinds[dotted_key].freeze
67
67
  )
68
68
  end
69
- LocaleIndex.new(entries, locales: locales.to_a.sort)
69
+ LocaleIndex.new(entries, locales: locales.to_a.sort, load_errors: @load_errors)
70
70
  end
71
71
 
72
72
  private
@@ -90,7 +90,7 @@ module Rigor
90
90
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
91
91
  next [] unless @io_boundary.directory?(absolute)
92
92
 
93
- Dir.glob(File.join(absolute, "**", "*.{yml,yaml}"))
93
+ Dir.glob("**/*.{yml,yaml}", base: absolute).map { |path| File.join(absolute, path) }
94
94
  end.sort
95
95
  end
96
96
 
@@ -57,7 +57,11 @@ module Rigor
57
57
  target_gems: ["i18n"],
58
58
  # Bumped 2026-06-23 — view template lazy-key scanning (`t('.key')` inside ERB / Haml / Slim under
59
59
  # `view_search_paths`).
60
- version: "0.3.0",
60
+ # Bumped 2026-10-17 (#1954) — the cached `:locale_index` value now carries the YAML load errors. A
61
+ # belt-and-braces key change: a released build already drops pre-change entries with the cache ABI
62
+ # (`Rigor::VERSION`) and a checkout with its engine-source identity, and the read below tolerates
63
+ # an index without errors either way.
64
+ version: "0.3.1",
61
65
  description: "Validates I18n `t(key)` calls against `config/locales/*.yml`.",
62
66
  config_schema: {
63
67
  "locale_search_paths" => { kind: :array, default: ["config/locales"] },
@@ -72,17 +76,12 @@ module Rigor
72
76
  )
73
77
 
74
78
  # `watch:` covers every `.yml` / `.yaml` file under the locale search paths so the cache invalidates
75
- # when locale files are added, removed, or edited (ADR-60 WD3). `@load_errors` is a producer-side
76
- # capture: it is populated only when the block runs (a cache miss / a watched file changed), which is
77
- # exactly when a malformed YAML must re-surface.
79
+ # when locale files are added, removed, or edited (ADR-60 WD3). The YAML load errors travel inside the
80
+ # returned index (`LocaleIndex#load_errors`), not in an instance variable the block sets (#1954): a
81
+ # warm producer-cache hit skips the block, so a block-side capture lost the load-error row on every
82
+ # run whose result was not itself served from the run-result cache.
78
83
  producer :locale_index, watch: -> { [[@locale_search_paths, "**/*.yml", "**/*.yaml"]] } do |_params|
79
- loader = LocaleLoader.new(
80
- io_boundary: io_boundary,
81
- search_paths: @locale_search_paths
82
- )
83
- index = loader.load
84
- @load_errors = loader.load_errors
85
- index
84
+ LocaleLoader.new(io_boundary: io_boundary, search_paths: @locale_search_paths).load
86
85
  end
87
86
 
88
87
  # Scans view templates under `view_search_paths` for lazy `t('.key')` / `I18n.translate('.key')`
@@ -102,7 +101,6 @@ module Rigor
102
101
  @locale_search_paths = Array(config.fetch("locale_search_paths")).map(&:to_s)
103
102
  @view_search_paths = Array(config.fetch("view_search_paths")).map(&:to_s)
104
103
  @configured_locales = Array(config.fetch("configured_locales")).map(&:to_s)
105
- @load_errors = []
106
104
  end
107
105
 
108
106
  # Returns no rows of its own: every file-level outcome here is run-scoped. The YAML load errors and the
@@ -114,8 +112,11 @@ module Rigor
114
112
  # are lazily loaded + memoised by `producer_value`, so the repeat registrations are no-ops.
115
113
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
116
114
  index = producer_value(:locale_index)
117
- disclose_load_errors unless @load_errors.empty?
118
- disclose_locale_runtime_error if index.nil? && producer_error(:locale_index)
115
+ if index
116
+ disclose_load_errors(Array(index.load_errors))
117
+ elsif producer_error(:locale_index)
118
+ disclose_locale_runtime_error
119
+ end
119
120
  view_diags = producer_value(:view_diagnostics) || []
120
121
  if (view_err = producer_error(:view_diagnostics))
121
122
  disclose_view_runtime_error(view_err)
@@ -175,7 +176,7 @@ module Rigor
175
176
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
176
177
  next [] unless io_boundary.directory?(absolute)
177
178
 
178
- Dir.glob(File.join(absolute, "**", "*.{erb,haml,slim}"))
179
+ Dir.glob("**/*.{erb,haml,slim}", base: absolute).map { |path| File.join(absolute, path) }
179
180
  end.sort
180
181
  end
181
182
 
@@ -209,8 +210,8 @@ module Rigor
209
210
  # be pinned to the first analysed Ruby file — once per plugin INSTANCE, so once per fork-pool worker
210
211
  # (#1051). They go through the run-scoped channel instead: keyed by the locale path that failed, so a
211
212
  # project with two broken YAML files still discloses both, each exactly once, at `.rigor.yml:1:1`.
212
- def disclose_load_errors
213
- @load_errors.each do |err|
213
+ def disclose_load_errors(load_errors)
214
+ load_errors.each do |err|
214
215
  disclose_once(
215
216
  "locale-parse:#{err.path}",
216
217
  message: "rigor-rails-i18n: failed to parse `#{err.path}`: #{err.message}",
@@ -168,7 +168,7 @@ module Rigor
168
168
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
169
169
  next unless io_boundary.directory?(absolute)
170
170
 
171
- Dir.glob(File.join(absolute, "**", "*.rb")).each(&)
171
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }.each(&)
172
172
  end
173
173
  end
174
174
 
@@ -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
@@ -92,7 +92,7 @@ module Rigor
92
92
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
93
93
  next [] unless @io_boundary.directory?(absolute)
94
94
 
95
- Dir.glob(File.join(absolute, "**", "*.rb"))
95
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
96
96
  end
97
97
  end
98
98
 
@@ -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
@@ -29,7 +29,9 @@ module Rigor
29
29
  # ## Reach
30
30
  #
31
31
  # All nine class-level HTTP verb methods Sinatra exposes: `get`, `post`, `put`, `delete`, `head`,
32
- # `options`, `patch`, `link`, `unlink` (`lib/sinatra/base.rb:1531-1553`). Both modular-style subclasses
32
+ # `options`, `patch`, `link`, `unlink` (`lib/sinatra/base.rb:1531-1553`), plus the `before` / `after`
33
+ # filters (`add_filter`) and the `error` / `not_found` handlers, whose blocks `compile!` turns into
34
+ # instance methods through the same `generate_method` (issue #1768). Both modular-style subclasses
33
35
  # of `Sinatra::Base` and `Sinatra::Application` (classic top-level style, when used via `class App <
34
36
  # Sinatra::Application`) match because the receiver constraint accepts every subclass.
35
37
  #
@@ -57,7 +59,7 @@ module Rigor
57
59
  block_as_methods: [
58
60
  Rigor::Plugin::Macro::BlockAsMethod.new(
59
61
  receiver_constraint: "Sinatra::Base",
60
- method_names: %i[get post put delete head options patch link unlink]
62
+ method_names: %i[get post put delete head options patch link unlink before after error not_found]
61
63
  )
62
64
  ]
63
65
  )
@@ -504,8 +504,8 @@ module Rigor
504
504
  # must not vary by machine / run. `Dir.glob` sorts its results by default on Ruby 3.0+, so the fold
505
505
  # order is already stable (the slice-1 comment's "filesystem order" caveat predates that default);
506
506
  # this walk relies on it rather than re-sorting.
507
- Dir.glob(File.join(absolute, "**", "*#{ext}")).each do |path|
508
- harvest_file(canonicalize(path), catalog, sigil_by_path, parse_errors_by_path)
507
+ Dir.glob("**/*#{ext}", base: absolute).each do |relative|
508
+ harvest_file(canonicalize(File.join(absolute, relative)), catalog, sigil_by_path, parse_errors_by_path)
509
509
  end
510
510
  end
511
511
  elsif io_boundary.file?(absolute) && extensions.any? { |ext| absolute.end_with?(ext) }