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
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "classification"
6
+
7
+ module Rigor
8
+ module SigGen
9
+ # Reports a `rigor sig-gen --write` run (the per-target {WriteResult}s) and the `--check` verdict. Split out of
10
+ # {Renderer}, which formats candidates; this formats what the writer did with them.
11
+ class WriteReporter
12
+ def initialize(out:)
13
+ @out = out
14
+ end
15
+
16
+ # Renders the per-source-file outcomes of a `--write` run. Distinct from {Renderer#render} because the write
17
+ # path's reporting surface is action-oriented (created / updated / skipped) rather than candidate-oriented.
18
+ # @param refused — methods refused as `sig.skipped.inline-differs`; the JSON payload names them under
19
+ # `refused` (absent when there are none, so an ordinary payload is unchanged), and text mode leaves them
20
+ # to the command's stderr `REFUSED` lines.
21
+ def render_write(results:, format:, refused: [])
22
+ case format
23
+ when "json" then render_write_json(results, refused)
24
+ when "text" then render_write_text(results)
25
+ else raise ArgumentError, "unsupported format: #{format}"
26
+ end
27
+ end
28
+
29
+ # ADR-112 WD4 — `sig-gen --check`: the results of a dry-run `--write`. Only the targets `--write` would
30
+ # change (or refuse) are shown, each with the lines it would add; the verdict is the exit status, which
31
+ # the command derives from the same results ({.out_of_date}).
32
+ #
33
+ # @param refused — the methods the generator refused to reconcile (`sig.skipped.inline-differs`):
34
+ # `sig/` is not up to date while one stands, and `--write` cannot fix it.
35
+ def render_check(results:, format:, refused: [])
36
+ stale = self.class.out_of_date(results)
37
+ case format
38
+ when "json"
39
+ @out.puts(JSON.pretty_generate({ up_to_date: stale.empty? && refused.empty?,
40
+ results: stale.map { |r| check_entry(r) },
41
+ refused: refused.map(&:to_h) }))
42
+ when "text" then render_check_text(stale, refused)
43
+ else raise ArgumentError, "unsupported format: #{format}"
44
+ end
45
+ end
46
+
47
+ # One line per method sig-gen refused to reconcile with its `sig/` copy. Shared by `--write` (on stderr,
48
+ # next to the write report) and `--check`.
49
+ def self.refusal_lines(refused)
50
+ refused.map do |candidate|
51
+ separator = candidate.kind == :singleton ? "." : "#"
52
+ "REFUSED #{candidate.path}: #{candidate.class_name}#{separator}#{candidate.method_name} — its inline " \
53
+ "declaration and its sig/ declaration disagree, so neither was changed " \
54
+ "(#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}). Make them agree, or pass " \
55
+ "--overwrite to replace the sig/ member with the inline declaration; `rigor explain " \
56
+ "#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}` says when that is not enough."
57
+ end
58
+ end
59
+
60
+ # The results that make a `--check` fail: a target `--write` would create or change, and one it would
61
+ # refuse, since a write that cannot happen is not an up-to-date `sig/` either.
62
+ def self.out_of_date(results)
63
+ results.reject { |result| %i[noop skipped_outside_sig_root].include?(result.action) }
64
+ end
65
+
66
+ # Nothing was written, so the entry must not read like one that was: `created` / `updated` become
67
+ # `would_create` / `would_update`. A refusal keeps its action — `--write` would refuse the same way.
68
+ CHECK_ACTIONS = { created: "would_create", updated: "would_update" }.freeze
69
+ private_constant :CHECK_ACTIONS
70
+
71
+ private
72
+
73
+ def check_entry(result)
74
+ entry = result.to_h
75
+ entry[:action] = CHECK_ACTIONS.fetch(result.action, entry[:action])
76
+ entry
77
+ end
78
+
79
+ def render_check_text(stale, refused)
80
+ if stale.empty? && refused.empty?
81
+ @out.puts("sig/ is up to date")
82
+ return
83
+ end
84
+
85
+ self.class.refusal_lines(refused).each { |line| @out.puts(line) }
86
+
87
+ stale.each do |result|
88
+ case result.action
89
+ when :created, :updated then render_check_change(result)
90
+ when :skipped_invalid_rbs then render_write_invalid(result)
91
+ when :skipped_invalid_encoding then render_write_invalid_encoding(result)
92
+ end
93
+ end
94
+ end
95
+
96
+ def render_check_change(result)
97
+ counts = result.action == :created ? "#{result.applied.size} method(s)" : applied_counts(result)
98
+ verb = result.action == :created ? "would create" : "would update"
99
+ @out.puts("#{verb} #{result.target_path} (#{counts})")
100
+ result.applied.each do |candidate|
101
+ @out.puts(" - #{candidate.declared_rbs}") if candidate.declared_rbs
102
+ candidate.rbs_lines.each { |line| @out.puts(" + #{line}") }
103
+ end
104
+ end
105
+
106
+ def render_write_text(results)
107
+ if results.all? { |r| r.action == :noop && r.skipped.empty? }
108
+ @out.puts("No changes")
109
+ return
110
+ end
111
+
112
+ results.each do |result|
113
+ case result.action
114
+ when :created then render_write_created(result)
115
+ when :updated then render_write_updated(result)
116
+ when :noop then render_write_unchanged(result)
117
+ when :skipped_outside_sig_root then render_write_skipped(result)
118
+ when :skipped_invalid_rbs then render_write_invalid(result)
119
+ when :skipped_invalid_encoding then render_write_invalid_encoding(result)
120
+ end
121
+ end
122
+ end
123
+
124
+ # `+N` counts added lines; an existing line replaced (`--overwrite`, or an inline update) is counted apart,
125
+ # because "added 2" when one of them rewrote a line the project already had would understate the change.
126
+ def applied_counts(result)
127
+ added = "+#{result.applied.size - result.replaced.size}"
128
+ result.replaced.empty? ? added : "#{added}, replaced #{result.replaced.size}"
129
+ end
130
+
131
+ def render_write_created(result)
132
+ @out.puts("created #{result.target_path} (#{result.applied.size} method(s))")
133
+ end
134
+
135
+ def render_write_updated(result)
136
+ @out.puts("updated #{result.target_path} (#{applied_counts(result)}, " \
137
+ "skipped #{result.skipped.size} user-authored)")
138
+ render_write_skips(result)
139
+ render_left_unreadable(result)
140
+ end
141
+
142
+ # A target the writer opened and changed nothing in, because every proposal for it was a member the
143
+ # project already declares (#1975). Without this line the run read as "No changes" or said nothing about it.
144
+ def render_write_unchanged(result)
145
+ return if result.skipped.empty?
146
+
147
+ @out.puts("unchanged #{result.target_path} (skipped #{result.skipped.size} user-authored)")
148
+ render_write_skips(result)
149
+ end
150
+
151
+ # One line per proposal the writer declined, so a change `--diff` or `--print` showed is never dropped
152
+ # without a reason. The same entries are the `skipped` rows of the `--format=json` payload.
153
+ def render_write_skips(result)
154
+ result.skipped.each do |candidate, reason|
155
+ separator = candidate.kind == :singleton ? "." : "#"
156
+ label = "#{candidate.class_name}#{separator}#{candidate.method_name}"
157
+ @out.puts(" skipped #{label} (#{reason.to_s.tr('_', '-')}): #{candidate.rbs_lines.join(' ')}")
158
+ end
159
+ end
160
+
161
+ # ADR-103 WD9 — the declarations whose annotation region the writer refused to rewrite. Named per
162
+ # method rather than counted: the fix is a human reading one existing annotation and deciding what
163
+ # it should say, and there is no count of those a reader could act on.
164
+ def render_left_unreadable(result)
165
+ return if result.left_unreadable.empty?
166
+
167
+ @out.puts(" left #{result.left_unreadable.size} existing annotation(s) byte-untouched " \
168
+ "(sig.effect.left-unreadable):")
169
+ result.left_unreadable.each do |candidate|
170
+ @out.puts(" #{candidate.class_name}##{candidate.method_name} — " \
171
+ "would have emitted #{candidate.annotations.join(' ')}")
172
+ end
173
+ end
174
+
175
+ def render_write_skipped(result)
176
+ @out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
177
+ render_write_skips(result)
178
+ end
179
+
180
+ # The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
181
+ # consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
182
+ # including the user's own hand-written ones.
183
+ def render_write_invalid(result)
184
+ @out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
185
+ @out.puts(" #{result.error}")
186
+ @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
187
+ end
188
+
189
+ # The EXISTING target file is not valid UTF-8, so the writer will not merge into it — the opposite
190
+ # attribution from {#render_write_invalid}: this one is the file's problem, and the fix is the user's.
191
+ def render_write_invalid_encoding(result)
192
+ @out.puts("REFUSED #{result.target_path} — the existing file is not valid UTF-8, so it was not updated")
193
+ @out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
194
+ end
195
+
196
+ def render_write_json(results, refused)
197
+ payload = { results: results.map(&:to_h) }
198
+ payload[:refused] = refused.map(&:to_h) unless refused.empty?
199
+ @out.puts(JSON.pretty_generate(payload))
200
+ end
201
+ end
202
+ end
203
+ end
@@ -16,7 +16,9 @@ module Rigor
16
16
  # - `replaced` — the subset of `applied` that replaced an existing declaration rather than adding one
17
17
  # (`--overwrite`'s tighter returns, and inline updates).
18
18
  # - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
19
- # entry pairs the candidate with a skip reason keyword (`:user_authored`).
19
+ # entry pairs the candidate with a skip reason keyword: `:user_authored` (the target already declares the
20
+ # member, and the writer does not replace it) or `:outside_sig_root` (the member routes to a file outside
21
+ # the writer's sig root, so nothing was attempted).
20
22
  # - `left_unreadable` — the {MethodCandidate}s whose effect annotation was NOT written because the
21
23
  # target declaration already carries annotations, so its bytes were left alone
22
24
  # (`sig.effect.left-unreadable`; see {Writer#splice_annotations}). The signature line itself was
@@ -98,7 +98,8 @@ module Rigor
98
98
  source_path ||= candidates.first&.path
99
99
  unless inside_sig_root?(target)
100
100
  return WriteResult.new(source_path: source_path, target_path: target,
101
- action: :skipped_outside_sig_root)
101
+ action: :skipped_outside_sig_root,
102
+ skipped: candidates.map { |c| [c, :outside_sig_root] })
102
103
  end
103
104
 
104
105
  target.exist? ? update_existing(source_path, target, candidates) : create_new(source_path, target, candidates)
@@ -636,7 +637,8 @@ module Rigor
636
637
  end
637
638
 
638
639
  # Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
639
- # declaration ({LayoutIndex.member_keys}). ADR-14 slice 4 recognises the three `attr_*` declaration kinds
640
+ # declaration ({LayoutIndex.member_keys}); an `alias` declares its new name (#1975). ADR-14 slice 4
641
+ # recognises the three `attr_*` declaration kinds
640
642
  # so a source-side `attr_reader :name` and an RBS-side `attr_reader name: T` are treated as the same member
641
643
  # (i.e. user-authored); a `def self?.f` counts as both an instance and a singleton `f` (#1488).
642
644
  def collect_member_pairs(decl)
@@ -754,7 +756,7 @@ module Rigor
754
756
 
755
757
  def find_attr_member(decl, method_name, kind)
756
758
  decl.members.find do |m|
757
- next false if m.is_a?(RBS::AST::Members::MethodDefinition)
759
+ next false if m.is_a?(RBS::AST::Members::MethodDefinition) || m.is_a?(RBS::AST::Members::Alias)
758
760
 
759
761
  LayoutIndex.member_keys(m).include?([method_name, kind])
760
762
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "rooted_glob"
4
+
3
5
  module Rigor
4
6
  # Classifies each configured `signature_paths:` entry by what it actually contributes to
5
7
  # the RBS environment, so a caller can warn when a configured path resolves to nothing.
@@ -90,7 +92,7 @@ module Rigor
90
92
  return Entry.new(path: path, status: :missing, rbs_file_count: 0) unless File.exist?(path)
91
93
  return Entry.new(path: path, status: :not_directory, rbs_file_count: 0) unless File.directory?(path)
92
94
 
93
- count = Dir.glob(File.join(path, "**", "*.rbs")).size
95
+ count = RootedGlob.glob(path, "**/*.rbs").size
94
96
  Entry.new(path: path, status: count.zero? ? :empty : :ok, rbs_file_count: count)
95
97
  end
96
98
 
@@ -204,7 +206,7 @@ module Rigor
204
206
  private_class_method :listed?
205
207
 
206
208
  def self.canonical_rbs_files(dir)
207
- Dir.glob(File.join(dir, "**", "*.rbs")).filter_map { |file| canonical_path(file) }
209
+ RootedGlob.glob(dir, "**/*.rbs").filter_map { |file| canonical_path(file) }
208
210
  end
209
211
  private_class_method :canonical_rbs_files
210
212
 
@@ -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
 
@@ -78,7 +78,11 @@ module Rigor
78
78
  # `RbsTypeTranslator#translate_literal` already maps the parsed RBS Literal back to `Constant`.
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
- # as syntax errors.
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). `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).
82
86
  def erase_to_rbs
83
87
  case value
84
88
  when true then "true"
@@ -86,6 +90,8 @@ module Rigor
86
90
  when nil then "nil"
87
91
  when Integer then value.to_s
88
92
  when Symbol, String then value.inspect
93
+ when Range then "Range[#{range_element_rbs}]"
94
+ when ::Set then "Set[untyped]"
89
95
  else value.class.name
90
96
  end
91
97
  end
@@ -94,18 +100,63 @@ module Rigor
94
100
 
95
101
  include Rigor::Type::AcceptanceRouter
96
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.
97
107
  def ==(other)
98
- 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)
99
109
  end
100
110
  alias eql? ==
101
111
 
102
112
  def hash
103
- [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
104
148
  end
105
149
 
106
150
  def inspect
107
151
  "#<Rigor::Type::Constant #{describe(:short)}>"
108
152
  end
153
+
154
+ private
155
+
156
+ def range_element_rbs
157
+ classes = [value.begin, value.end].compact.map { |endpoint| endpoint.class.name }.uniq.sort
158
+ classes.empty? ? "untyped" : classes.join(" | ")
159
+ end
109
160
  end
110
161
  end
111
162
  end
@@ -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.4"
4
+ VERSION = "0.4.6"
5
5
  end
@@ -99,7 +99,7 @@ module Rigor
99
99
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
100
100
  next [] unless @io_boundary.directory?(absolute)
101
101
 
102
- Dir.glob(File.join(absolute, "**", "*.rb"))
102
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
103
103
  end
104
104
  end
105
105
 
@@ -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, "**", "*.rb"))
93
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
94
94
  end
95
95
  end
96
96
 
@@ -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"],
@@ -58,7 +58,7 @@ module Rigor
58
58
  # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
59
59
  next [] unless @io_boundary.directory?(absolute)
60
60
 
61
- Dir.glob(File.join(absolute, "**", "*.rb"))
61
+ Dir.glob("**/*.rb", base: absolute).map { |path| File.join(absolute, path) }
62
62
  end
63
63
  end
64
64