rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -37,8 +37,11 @@ module Rigor
37
37
  # returned instead of the 1:1 mirror.
38
38
  # @return absolute path of the target `.rbs`
39
39
  # file for the candidate.
40
- def target_for(source_path, class_name: nil)
41
- existing = existing_target_for(class_name)
40
+ #
41
+ # A member the signature tree already declares (`method_name` / `kind`, ADR-122 WD2) is routed to the file
42
+ # that declares it, even when another file declares its class first (#1488).
43
+ def target_for(source_path, class_name: nil, method_name: nil, kind: nil)
44
+ existing = existing_member_target_for(class_name, method_name, kind) || existing_target_for(class_name)
42
45
  return existing if existing
43
46
 
44
47
  rel_to_root = source_relative_to_root(source_path)
@@ -46,6 +49,19 @@ module Rigor
46
49
  sig_root_dir / "#{stripped.sub_ext('')}.rbs"
47
50
  end
48
51
 
52
+ # `kind` may be `:singleton_instance` (a `def self?.` candidate), which routes to whichever file declares
53
+ # either half.
54
+ def existing_member_target_for(class_name, method_name, kind)
55
+ return nil if class_name.nil? || method_name.nil? || @layout_index.nil?
56
+
57
+ kinds = kind == :singleton_instance ? %i[instance singleton] : [kind]
58
+ kinds.each do |k|
59
+ file = @layout_index.member_file_for(class_name, method_name, k)
60
+ return file if file
61
+ end
62
+ nil
63
+ end
64
+
49
65
  def existing_target_for(class_name)
50
66
  return nil if class_name.nil? || @layout_index.nil?
51
67
 
@@ -4,6 +4,7 @@ require "json"
4
4
 
5
5
  require_relative "classification"
6
6
  require_relative "superclass_spelling"
7
+ require_relative "write_reporter"
7
8
 
8
9
  module Rigor
9
10
  module SigGen
@@ -26,9 +27,11 @@ module Rigor
26
27
  # @param selection — subset of
27
28
  # {Classification} constants to include; an empty
28
29
  # array means "all emittable classifications".
29
- def render(candidates:, mode:, format:, selection:)
30
+ # @param observation — `{ paths:, missing: }` under `--params=observed`, else `nil`; JSON carries it
31
+ # as the top-level `observation` key (#1398).
32
+ def render(candidates:, mode:, format:, selection:, observation: nil)
30
33
  case format
31
- when "json" then render_json(filter(candidates, selection, with_skipped: true))
34
+ when "json" then render_json(filter(candidates, selection, with_skipped: true), observation)
32
35
  when "text"
33
36
  filtered = filter(candidates, selection)
34
37
  mode == :diff ? render_diff(filtered) : render_print(filtered)
@@ -121,175 +124,30 @@ module Rigor
121
124
  end
122
125
  end
123
126
 
124
- def render_json(candidates)
127
+ def render_json(candidates, observation = nil)
125
128
  payload = { candidates: candidates.map(&:to_h) }
129
+ payload[:observation] = observation if observation
126
130
  @out.puts(JSON.pretty_generate(payload))
127
131
  end
128
132
 
129
133
  public
130
134
 
131
- # Renders the per-source-file outcomes of a `--write` run. Distinct from {#render} because the write
132
- # path's reporting surface is action-oriented (created / updated / skipped) rather than candidate-oriented.
133
- # @param refused — methods refused as `sig.skipped.inline-differs`; the JSON payload names them under
134
- # `refused` (absent when there are none, so an ordinary payload is unchanged), and text mode leaves them
135
- # to the command's stderr `REFUSED` lines.
135
+ # The per-source-file outcomes of a `--write` run, and the `--check` verdict, are reported by
136
+ # {WriteReporter}; these delegate so the command keeps one renderer.
136
137
  def render_write(results:, format:, refused: [])
137
- case format
138
- when "json" then render_write_json(results, refused)
139
- when "text" then render_write_text(results)
140
- else raise ArgumentError, "unsupported format: #{format}"
141
- end
138
+ WriteReporter.new(out: @out).render_write(results: results, format: format, refused: refused)
142
139
  end
143
140
 
144
- # ADR-112 WD4 — `sig-gen --check`: the results of a dry-run `--write`. Only the targets `--write` would
145
- # change (or refuse) are shown, each with the lines it would add; the verdict is the exit status, which
146
- # the command derives from the same results ({.out_of_date}).
147
- #
148
- # @param refused — the methods the generator refused to reconcile (`sig.skipped.inline-differs`):
149
- # `sig/` is not up to date while one stands, and `--write` cannot fix it.
150
141
  def render_check(results:, format:, refused: [])
151
- stale = self.class.out_of_date(results)
152
- case format
153
- when "json"
154
- @out.puts(JSON.pretty_generate({ up_to_date: stale.empty? && refused.empty?,
155
- results: stale.map { |r| check_entry(r) },
156
- refused: refused.map(&:to_h) }))
157
- when "text" then render_check_text(stale, refused)
158
- else raise ArgumentError, "unsupported format: #{format}"
159
- end
142
+ WriteReporter.new(out: @out).render_check(results: results, format: format, refused: refused)
160
143
  end
161
144
 
162
- # One line per method sig-gen refused to reconcile with its `sig/` copy. Shared by `--write` (on stderr,
163
- # next to the write report) and `--check`.
164
145
  def self.refusal_lines(refused)
165
- refused.map do |candidate|
166
- separator = candidate.kind == :singleton ? "." : "#"
167
- "REFUSED #{candidate.path}: #{candidate.class_name}#{separator}#{candidate.method_name} — its inline " \
168
- "declaration and its sig/ declaration disagree, so neither was changed " \
169
- "(#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}). Make them agree, or pass " \
170
- "--overwrite to replace the sig/ member with the inline declaration; `rigor explain " \
171
- "#{Classification::SKIP_DIAGNOSTIC_IDS.fetch(candidate.skip_reason)}` says when that is not enough."
172
- end
146
+ WriteReporter.refusal_lines(refused)
173
147
  end
174
148
 
175
- # The results that make a `--check` fail: a target `--write` would create or change, and one it would
176
- # refuse, since a write that cannot happen is not an up-to-date `sig/` either.
177
149
  def self.out_of_date(results)
178
- results.reject { |result| %i[noop skipped_outside_sig_root].include?(result.action) }
179
- end
180
-
181
- # Nothing was written, so the entry must not read like one that was: `created` / `updated` become
182
- # `would_create` / `would_update`. A refusal keeps its action — `--write` would refuse the same way.
183
- CHECK_ACTIONS = { created: "would_create", updated: "would_update" }.freeze
184
- private_constant :CHECK_ACTIONS
185
-
186
- private
187
-
188
- def check_entry(result)
189
- entry = result.to_h
190
- entry[:action] = CHECK_ACTIONS.fetch(result.action, entry[:action])
191
- entry
192
- end
193
-
194
- def render_check_text(stale, refused)
195
- if stale.empty? && refused.empty?
196
- @out.puts("sig/ is up to date")
197
- return
198
- end
199
-
200
- self.class.refusal_lines(refused).each { |line| @out.puts(line) }
201
-
202
- stale.each do |result|
203
- case result.action
204
- when :created, :updated then render_check_change(result)
205
- when :skipped_invalid_rbs then render_write_invalid(result)
206
- when :skipped_invalid_encoding then render_write_invalid_encoding(result)
207
- end
208
- end
209
- end
210
-
211
- def render_check_change(result)
212
- counts = result.action == :created ? "#{result.applied.size} method(s)" : applied_counts(result)
213
- verb = result.action == :created ? "would create" : "would update"
214
- @out.puts("#{verb} #{result.target_path} (#{counts})")
215
- result.applied.each do |candidate|
216
- @out.puts(" - #{candidate.declared_rbs}") if candidate.declared_rbs
217
- candidate.rbs_lines.each { |line| @out.puts(" + #{line}") }
218
- end
219
- end
220
-
221
- def render_write_text(results)
222
- if results.all? { |r| r.action == :noop }
223
- @out.puts("No changes")
224
- return
225
- end
226
-
227
- results.each do |result|
228
- case result.action
229
- when :created then render_write_created(result)
230
- when :updated then render_write_updated(result)
231
- when :skipped_outside_sig_root then render_write_skipped(result)
232
- when :skipped_invalid_rbs then render_write_invalid(result)
233
- when :skipped_invalid_encoding then render_write_invalid_encoding(result)
234
- end
235
- end
236
- end
237
-
238
- # `+N` counts added lines; an existing line replaced (`--overwrite`, or an inline update) is counted apart,
239
- # because "added 2" when one of them rewrote a line the project already had would understate the change.
240
- def applied_counts(result)
241
- added = "+#{result.applied.size - result.replaced.size}"
242
- result.replaced.empty? ? added : "#{added}, replaced #{result.replaced.size}"
243
- end
244
-
245
- def render_write_created(result)
246
- @out.puts("created #{result.target_path} (#{result.applied.size} method(s))")
247
- end
248
-
249
- def render_write_updated(result)
250
- @out.puts("updated #{result.target_path} (#{applied_counts(result)}, " \
251
- "skipped #{result.skipped.size} user-authored)")
252
- render_left_unreadable(result)
253
- end
254
-
255
- # ADR-103 WD9 — the declarations whose annotation region the writer refused to rewrite. Named per
256
- # method rather than counted: the fix is a human reading one existing annotation and deciding what
257
- # it should say, and there is no count of those a reader could act on.
258
- def render_left_unreadable(result)
259
- return if result.left_unreadable.empty?
260
-
261
- @out.puts(" left #{result.left_unreadable.size} existing annotation(s) byte-untouched " \
262
- "(sig.effect.left-unreadable):")
263
- result.left_unreadable.each do |candidate|
264
- @out.puts(" #{candidate.class_name}##{candidate.method_name} — " \
265
- "would have emitted #{candidate.annotations.join(' ')}")
266
- end
267
- end
268
-
269
- def render_write_skipped(result)
270
- @out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
271
- end
272
-
273
- # The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
274
- # consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
275
- # including the user's own hand-written ones.
276
- def render_write_invalid(result)
277
- @out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
278
- @out.puts(" #{result.error}")
279
- @out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
280
- end
281
-
282
- # The EXISTING target file is not valid UTF-8, so the writer will not merge into it — the opposite
283
- # attribution from {#render_write_invalid}: this one is the file's problem, and the fix is the user's.
284
- def render_write_invalid_encoding(result)
285
- @out.puts("REFUSED #{result.target_path} — the existing file is not valid UTF-8, so it was not updated")
286
- @out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
287
- end
288
-
289
- def render_write_json(results, refused)
290
- payload = { results: results.map(&:to_h) }
291
- payload[:refused] = refused.map(&:to_h) unless refused.empty?
292
- @out.puts(JSON.pretty_generate(payload))
150
+ WriteReporter.out_of_date(results)
293
151
  end
294
152
  end
295
153
  end
@@ -147,6 +147,17 @@ module Rigor
147
147
  "not reported.",
148
148
  next_step: "Nothing is required. To type the method, move it into a module (`module_function` or " \
149
149
  "`extend self` keeps the call sites), or write the `Object` declaration by hand in `sig/`."
150
+ ),
151
+ "sig.skipped.uncertain-module-function" => Entry.new(
152
+ id: "sig.skipped.uncertain-module-function",
153
+ summary: "Whether the method is a module function depends on a visibility call that may not run.",
154
+ explanation: "A bare `module_function` makes the module's later methods module functions, and a later " \
155
+ "bare `public`, `private` or `protected` ends that. Here such a call sits in control " \
156
+ "flow or a block between the two, so it may or may not run, and the method is either a " \
157
+ "module function (`def self?.name`) or an instance method only (`def name`). sig-gen " \
158
+ "writes neither rather than guess (#1968).",
159
+ next_step: "Move the visibility call to the module body's own statements, or name the methods it " \
160
+ "should cover (`public :name`, `module_function :name`), so the reading is certain."
150
161
  )
151
162
  }.freeze
152
163
 
@@ -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
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "rbs"
5
5
 
6
6
  require_relative "classification"
7
+ require_relative "layout_index"
7
8
  require_relative "superclass_spelling"
8
9
  require_relative "write_result"
9
10
 
@@ -62,8 +63,10 @@ module Rigor
62
63
 
63
64
  @global_namespace_kinds = build_namespace_kinds(candidates)
64
65
  @global_superclasses = build_superclasses(candidates)
65
- emittable.group_by { |c| @path_mapper.target_for(c.path, class_name: c.class_name) }
66
- .map { |target, group| write_target(target, group) }
66
+ grouped = emittable.group_by do |c|
67
+ @path_mapper.target_for(c.path, class_name: c.class_name, method_name: c.method_name, kind: c.rbs_kind)
68
+ end
69
+ grouped.map { |target, group| write_target(target, group) }
67
70
  end
68
71
 
69
72
  # @param candidates — only
@@ -95,7 +98,8 @@ module Rigor
95
98
  source_path ||= candidates.first&.path
96
99
  unless inside_sig_root?(target)
97
100
  return WriteResult.new(source_path: source_path, target_path: target,
98
- action: :skipped_outside_sig_root)
101
+ action: :skipped_outside_sig_root,
102
+ skipped: candidates.map { |c| [c, :outside_sig_root] })
99
103
  end
100
104
 
101
105
  target.exist? ? update_existing(source_path, target, candidates) : create_new(source_path, target, candidates)
@@ -562,7 +566,10 @@ module Rigor
562
566
  state.applied.concat(methods)
563
567
  insert_namespace_chain(state, class_name, kinds, supers, methods)
564
568
  else
565
- state.source = merge_into_existing_class(state.source, decl, methods, state)
569
+ # A class reopened in one file answers for every member of every opening, not just the first one's
570
+ # (#1488): a member another opening declares is user-authored, not new.
571
+ declared = find_class_decls(state.decls, class_name).flat_map { |d| collect_member_pairs(d) }
572
+ state.source = merge_into_existing_class(state.source, decl, methods, state, declared)
566
573
  state.decls = parse_signature(state.source) || state.decls
567
574
  end
568
575
  end
@@ -589,12 +596,25 @@ module Rigor
589
596
  nil
590
597
  end
591
598
 
599
+ # Every declaration of `qualified_name` in the file, in {#find_class_decl}'s order (so the first is the one
600
+ # it returns).
601
+ def find_class_decls(decls, qualified_name, prefix = [], found = [])
602
+ decls.each do |decl|
603
+ next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
604
+
605
+ local = decl.name.to_s.sub(/\A::/, "")
606
+ full = prefix.empty? ? local : "#{prefix.join('::')}::#{local}"
607
+ found << decl if full == qualified_name
608
+ find_class_decls(decl.members, qualified_name, prefix + [local], found)
609
+ end
610
+ found
611
+ end
612
+
592
613
  def ends_with_newline?(source)
593
614
  source.end_with?("\n")
594
615
  end
595
616
 
596
- def merge_into_existing_class(source, decl, methods, state)
597
- existing_pairs = collect_member_pairs(decl)
617
+ def merge_into_existing_class(source, decl, methods, state, existing_pairs)
598
618
  new_methods, conflicting = partition_against_existing(methods, existing_pairs)
599
619
 
600
620
  source = insert_into_class(source, decl, new_methods)
@@ -617,31 +637,21 @@ module Rigor
617
637
  end
618
638
 
619
639
  # Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
620
- # declaration. ADR-14 slice 4 recognises `MethodDefinition`'s `:instance` / `:singleton` kind plus the
621
- # three `attr_*` declaration kinds so a source-side `attr_reader :name` and an RBS-side
622
- # `attr_reader name: T` are treated as the same member (i.e. user-authored).
640
+ # declaration ({LayoutIndex.member_keys}); an `alias` declares its new name (#1975). ADR-14 slice 4
641
+ # recognises the three `attr_*` declaration kinds
642
+ # so a source-side `attr_reader :name` and an RBS-side `attr_reader name: T` are treated as the same member
643
+ # (i.e. user-authored); a `def self?.f` counts as both an instance and a singleton `f` (#1488).
623
644
  def collect_member_pairs(decl)
624
- pairs = []
625
- decl.members.each { |m| collect_pairs_for_member(m, pairs) }
626
- pairs
627
- end
628
-
629
- def collect_pairs_for_member(member, pairs)
630
- case member
631
- when RBS::AST::Members::MethodDefinition
632
- pairs << [member.name, member.kind]
633
- when RBS::AST::Members::AttrReader
634
- pairs << [member.name, :instance]
635
- when RBS::AST::Members::AttrWriter
636
- pairs << [:"#{member.name}=", :instance]
637
- when RBS::AST::Members::AttrAccessor
638
- pairs << [member.name, :instance]
639
- pairs << [:"#{member.name}=", :instance]
640
- end
645
+ decl.members.flat_map { |m| LayoutIndex.member_keys(m) }
641
646
  end
642
647
 
648
+ # A `def self?.f` candidate conflicts with a declared instance OR singleton `f`: inserting it beside a lone
649
+ # `def self.f` (or `def f`) would declare that half twice (#1488).
643
650
  def partition_against_existing(methods, existing_pairs)
644
- methods.partition { |c| !existing_pairs.include?([c.method_name, c.kind]) }
651
+ methods.partition do |c|
652
+ kinds = c.rbs_kind == :singleton_instance ? %i[instance singleton] : [c.kind]
653
+ kinds.none? { |kind| existing_pairs.include?([c.method_name, kind]) }
654
+ end
645
655
  end
646
656
 
647
657
  # Inserts each new method line one column before the class declaration's `end` keyword. The insertion
@@ -710,7 +720,7 @@ module Rigor
710
720
  # part of the contract. A candidate that keeps `void` is still eligible, which is what case 2 is for —
711
721
  # the `initialize` stub is always spelled `-> void`.
712
722
  def replaces_declared_void?(candidate, decl)
713
- member = find_method_member(decl, candidate.method_name, candidate.kind)
723
+ member = find_method_member(decl, candidate)
714
724
  return false if member.nil?
715
725
 
716
726
  declared_void = member.overloads.any? do |overload|
@@ -722,7 +732,7 @@ module Rigor
722
732
  # Compares the existing member's source-side RBS text against the candidate's proposed RBS text
723
733
  # ({.fewer_untyped?}).
724
734
  def tightens_untyped?(candidate, decl, source)
725
- member = find_method_member(decl, candidate.method_name, candidate.kind)
735
+ member = find_method_member(decl, candidate)
726
736
  return false if member.nil?
727
737
 
728
738
  self.class.fewer_untyped?(candidate.rbs, source[member.location.start_pos...member.location.end_pos])
@@ -737,7 +747,7 @@ module Rigor
737
747
  # the same method (ADR-112 WD4), which it replaces in the inline attribute's own spelling; an
738
748
  # `overloading?` member (`| ...`) is never its target.
739
749
  def replaceable_member(decl, candidate)
740
- member = find_method_member(decl, candidate.method_name, candidate.kind)
750
+ member = find_method_member(decl, candidate)
741
751
  return member unless candidate.classification == Classification::INLINE_OVERWRITE
742
752
 
743
753
  member = nil if member.respond_to?(:overloading?) && member.overloading?
@@ -746,15 +756,20 @@ module Rigor
746
756
 
747
757
  def find_attr_member(decl, method_name, kind)
748
758
  decl.members.find do |m|
749
- pairs = []
750
- collect_pairs_for_member(m, pairs) unless m.is_a?(RBS::AST::Members::MethodDefinition)
751
- m.respond_to?(:kind) && m.kind == kind && pairs.any? { |name, _| name == method_name }
759
+ next false if m.is_a?(RBS::AST::Members::MethodDefinition) || m.is_a?(RBS::AST::Members::Alias)
760
+
761
+ LayoutIndex.member_keys(m).include?([method_name, kind])
752
762
  end
753
763
  end
754
764
 
755
- def find_method_member(decl, method_name, kind)
765
+ # Matches on the candidate's {MethodCandidate#rbs_kind}, so a `def self?.f` proposal finds only a
766
+ # `def self?.f` member: a `def f` beside a `def self.f`, or a `def self?.f` under a plain `def f`
767
+ # proposal, is left as the user wrote it. An `overloading?` member (`def f: ... | ...`) adds to a
768
+ # declaration elsewhere rather than being one, so it is never replaced (#1488).
769
+ def find_method_member(decl, candidate)
756
770
  decl.members.find do |m|
757
- m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == method_name && m.kind == kind
771
+ m.is_a?(RBS::AST::Members::MethodDefinition) && m.name == candidate.method_name &&
772
+ m.kind == candidate.rbs_kind && !m.overloading?
758
773
  end
759
774
  end
760
775
 
@@ -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