rigortype 0.4.4 → 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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  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 +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -1
@@ -10,6 +10,7 @@ require_relative "../source/constant_path"
10
10
  require_relative "../source/literals"
11
11
  require_relative "../source/node_children"
12
12
  require_relative "../inference/scope_indexer"
13
+ require_relative "../rooted_glob"
13
14
 
14
15
  module Rigor
15
16
  module SigGen
@@ -32,6 +33,7 @@ module Rigor
32
33
  # `let` / `subject` bindings ride on slice 5's optional `rigor-rspec` integration.
33
34
  # - Calls whose receiver does not type as a `Type::Nominal` (e.g. `(some_dynamic).bar(...)`) are skipped —
34
35
  # the collector cannot attribute them to a specific class.
36
+ # - A `T | nil` receiver with one `Nominal` / `Singleton` non-nil member counts as that member (#1389).
35
37
  # - Zero-argument calls give no observation; methods are matched by `(class_name, method_name)` only.
36
38
  class ObservationCollector # rubocop:disable Metrics/ClassLength
37
39
  # @param paths — observe paths (files /
@@ -62,6 +64,12 @@ module Rigor
62
64
  observations.transform_values(&:freeze).freeze
63
65
  end
64
66
 
67
+ # Whether `arg` names something {#collect} reads: a directory, or a `.rb` file. The CLI's
68
+ # `observation` report splits roots on this, so "observed" means what the collector does (#1398).
69
+ def self.readable_root?(arg)
70
+ File.directory?(arg) || (File.file?(arg) && arg.end_with?(".rb"))
71
+ end
72
+
65
73
  private
66
74
 
67
75
  # Issue #821 — same universe as the generator and as `rigor check`; see
@@ -74,8 +82,8 @@ module Rigor
74
82
  def resolve_paths(args)
75
83
  args.flat_map do |arg|
76
84
  if File.directory?(arg)
77
- Dir.glob(File.join(arg, "**/*.rb"), sort: true)
78
- elsif File.file?(arg) && arg.end_with?(".rb")
85
+ RootedGlob.glob(arg, "**/*.rb")
86
+ elsif self.class.readable_root?(arg)
79
87
  [arg]
80
88
  else
81
89
  []
@@ -92,8 +100,9 @@ module Rigor
92
100
  base_scope = base_scope.with_discovery(base_scope.discovery.with(discovered_classes: discovered_classes))
93
101
  scope_index = Inference::ScopeIndexer.index(parse_result.value, default_scope: base_scope)
94
102
  bindings = collect_rspec_bindings(parse_result.value, scope_index)
103
+ @before_ivars = collect_before_ivars(parse_result.value, scope_index)
95
104
 
96
- walk_calls(parse_result.value, scope_index, bindings, observations)
105
+ walk_calls(parse_result.value, scope_index, bindings, observations, [])
97
106
  end
98
107
 
99
108
  # Pre-walks `@source_paths` to collect every qualified class / module declaration. The result seeds the
@@ -145,22 +154,64 @@ module Rigor
145
154
  end
146
155
  end
147
156
 
148
- def walk_calls(node, scope_index, bindings, observations)
157
+ # `blocks` is the chain of enclosing RSpec group / example / hook blocks, innermost last — the blocks a
158
+ # `before` ivar can reach the call from ({#before_ivar_type}). See {#enclosing_blocks} for where it is cut.
159
+ def walk_calls(node, scope_index, bindings, observations, blocks)
149
160
  return unless node.is_a?(Prism::Node)
150
161
 
151
- record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
152
- node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
162
+ record_call(node, scope_index, bindings, observations, blocks) if node.is_a?(Prism::CallNode)
163
+ node.rigor_each_child do |child|
164
+ walk_calls(child, scope_index, bindings, observations, enclosing_blocks(node, child, blocks))
165
+ end
166
+ end
167
+
168
+ # Group, example and hook calls whose block runs with the example's ivars. `RSpec.` may prefix a group call.
169
+ RSPEC_GROUP_CALLS = %i[
170
+ describe context feature shared_examples shared_examples_for shared_context
171
+ ].freeze
172
+ RSPEC_EXAMPLE_OR_HOOK_CALLS = %i[
173
+ it specify example scenario its before prepend_before append_before
174
+ ].freeze
175
+ private_constant :RSPEC_GROUP_CALLS, :RSPEC_EXAMPLE_OR_HOOK_CALLS
176
+
177
+ # The chain `child` of `parent` sees. A group / example / hook call's block extends it; every other block or
178
+ # lambda (`Class.new { … }`, `instance_eval { … }`, `define_method(:m) { … }`, `each { … }`), a class / module /
179
+ # singleton-class body, and a `def` that is not a direct statement of a group block cut it, since their `self`
180
+ # may be another object.
181
+ def enclosing_blocks(parent, child, blocks)
182
+ case child
183
+ when Prism::BlockNode then rspec_scope_call?(parent) ? [*blocks, child] : []
184
+ when Prism::LambdaNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode then []
185
+ when Prism::DefNode then group_statement?(child, blocks.last) ? blocks : []
186
+ else blocks
187
+ end
188
+ end
189
+
190
+ def rspec_scope_call?(node)
191
+ return false unless node.is_a?(Prism::CallNode)
192
+ return rspec_group_receiver?(node.receiver) if RSPEC_GROUP_CALLS.include?(node.name)
193
+
194
+ node.receiver.nil? && RSPEC_EXAMPLE_OR_HOOK_CALLS.include?(node.name)
153
195
  end
154
196
 
155
- def record_call(call_node, scope_index, bindings, observations)
197
+ def rspec_group_receiver?(receiver)
198
+ receiver.nil? || (receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :RSpec)
199
+ end
200
+
201
+ def group_statement?(node, block)
202
+ body = block&.body
203
+ body.is_a?(Prism::StatementsNode) && body.body.any? { |statement| statement.equal?(node) }
204
+ end
205
+
206
+ def record_call(call_node, scope_index, bindings, observations, blocks)
156
207
  receiver = call_node.receiver
157
208
  return if receiver.nil?
158
209
 
159
210
  scope = scope_index[call_node] || scope_index[receiver]
160
211
  return if scope.nil?
161
212
 
162
- receiver_type = resolve_receiver_type(receiver, scope, bindings)
163
- key = observation_key(call_node, receiver_type)
213
+ receiver_type = resolve_receiver_type(receiver, scope, bindings, blocks)
214
+ key = observation_key(call_node, observed_receiver(receiver_type, call_node, scope))
164
215
  return if key.nil?
165
216
 
166
217
  observation = collect_args(call_node, scope)
@@ -181,6 +232,36 @@ module Rigor
181
232
  end
182
233
  end
183
234
 
235
+ # Issue #1389 — a receiver typed `T | nil` whose one non-nil member is a `Nominal` or `Singleton` is observed as
236
+ # that member. A Minitest `setup` ivar read in a test method types as `Foo?` (setup is not `initialize`), and a
237
+ # call of a method `nil` does not answer can only have reached `Foo#bar` with its arguments: on `nil` it raises
238
+ # `NoMethodError`, and `&.` skips the call. A method `NilClass`'s RBS declares (`==`, `respond_to?`, `is_a?`)
239
+ # may have run on `nil` instead, so a plain `.` call of one keeps the union unobserved. The sole member still
240
+ # has to pass {#observation_key}'s `Nominal` / `Singleton` checks, so `untyped | nil` stays unobserved, and a
241
+ # union with two non-nil members (`Foo | Bar | nil`) passes through unchanged. Observation only: the
242
+ # receiver's inferred type elsewhere is untouched.
243
+ def observed_receiver(receiver_type, call_node, scope)
244
+ return receiver_type unless receiver_type.is_a?(Type::Union)
245
+
246
+ non_nil = receiver_type.members.reject { |member| nil_type?(member) }
247
+ return receiver_type unless non_nil.size == 1
248
+ return receiver_type if !call_node.safe_navigation? && nil_answers?(call_node.name, scope)
249
+
250
+ non_nil.first
251
+ end
252
+
253
+ # Whether `nil` may answer `method_name`: `NilClass`'s RBS declares it, or that RBS is not available to ask.
254
+ def nil_answers?(method_name, scope)
255
+ loader = scope.environment.rbs_loader
256
+ return true if loader.nil? || loader.instance_definition("NilClass").nil?
257
+
258
+ !loader.instance_method(class_name: "NilClass", method_name: method_name).nil?
259
+ end
260
+
261
+ def nil_type?(type)
262
+ (type.is_a?(Type::Constant) && type.value.nil?) || (type.is_a?(Type::Nominal) && type.class_name == "NilClass")
263
+ end
264
+
184
265
  # ADR-14 slice 5 — RSpec-aware receiver typing. Resolves a CallNode receiver against the collected
185
266
  # `bindings` map (built by {#collect_rspec_bindings}) before falling back to ordinary `scope.type_of`. The
186
267
  # three RSpec-shaped receivers we recognise:
@@ -190,11 +271,23 @@ module Rigor
190
271
  # - `described_class.new(...)` chain — when the surrounding `describe Foo do … end` resolved `Foo`,
191
272
  # return `Type::Nominal[Foo]`.
192
273
  # - Anything else — pass through to `scope.type_of`, matching slice-3 behaviour.
193
- def resolve_receiver_type(receiver, scope, bindings)
274
+ def resolve_receiver_type(receiver, scope, bindings, blocks)
194
275
  return resolve_described_class_new(bindings) if described_class_new?(receiver)
195
276
  return bindings[receiver.name] if bound_call?(receiver, bindings)
196
277
 
197
- safe_type_of(scope, receiver)
278
+ type = safe_type_of(scope, receiver)
279
+ return type unless receiver.is_a?(Prism::InstanceVariableReadNode) && (type.nil? || type.is_a?(Type::Dynamic))
280
+
281
+ before_ivar_type(receiver.name, blocks) || type
282
+ end
283
+
284
+ # Issue #1389 — an ivar read inside an `it` block types as `Dynamic` (the block's ivars are not a class's), so
285
+ # a `before { @foo = Foo.new }` write stands in for it. Only a `before` whose example-group block encloses the
286
+ # read counts — a sibling `describe`, an un-included `shared_context` or a class body never reaches it — and
287
+ # when those `before`s write the name with more than one type, no type is answered.
288
+ def before_ivar_type(name, blocks)
289
+ types = blocks.flat_map { |block| @before_ivars.dig(block, name) || [] }.uniq
290
+ types.size == 1 ? types.first : nil
198
291
  end
199
292
 
200
293
  def bound_call?(receiver, bindings)
@@ -279,6 +372,39 @@ module Rigor
279
372
  bindings[name] = body_type if body_type
280
373
  end
281
374
 
375
+ # `before { @foo = Foo.new }` / `before(:each) do … end` inside an example-group block: records each ivar the
376
+ # `before` block's top-level statements write, with the written value's type, under that enclosing group
377
+ # block — `{ BlockNode => { :@foo => [Type] } }`, keyed by node identity. A `before` outside every group
378
+ # block ({#enclosing_blocks}), and `config.before`, record nothing.
379
+ def collect_before_ivars(root, scope_index)
380
+ index = {}.compare_by_identity
381
+ walk_before_ivars(root, [], index, scope_index)
382
+ index
383
+ end
384
+
385
+ def walk_before_ivars(node, blocks, index, scope_index)
386
+ return unless node.is_a?(Prism::Node)
387
+
388
+ record_before_ivars(node, blocks.last, index, scope_index) if blocks.last
389
+ node.rigor_each_child do |child|
390
+ walk_before_ivars(child, enclosing_blocks(node, child, blocks), index, scope_index)
391
+ end
392
+ end
393
+
394
+ def record_before_ivars(node, group, index, scope_index)
395
+ return unless node.is_a?(Prism::CallNode) && node.name == :before && node.receiver.nil?
396
+
397
+ body = block_body_node(node.block)
398
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body].compact
399
+ statements.each do |statement|
400
+ next unless statement.is_a?(Prism::InstanceVariableWriteNode)
401
+
402
+ scope = scope_index[statement.value] || scope_index[statement]
403
+ type = scope && safe_type_of(scope, statement.value)
404
+ ((index[group] ||= {})[statement.name] ||= []) << type if type
405
+ end
406
+ end
407
+
282
408
  def binding_name_for(call_node)
283
409
  first_arg = call_node.arguments&.arguments&.first
284
410
  return call_node.name == :subject ? :subject : nil if first_arg.nil?
@@ -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