rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -24,12 +24,16 @@ module Rigor
24
24
  # body's last expression to derive an inferred return, looks up the project's existing RBS declaration (if
25
25
  # any), and emits one {MethodCandidate} per def.
26
26
  #
27
- # The MVP keeps the scope deliberately narrow:
28
- # - Only instance methods inside a `class` / `module` body are considered. Top-level / DSL-block / singleton
29
- # defs are skipped (`sig.skipped.complex-shape`).
30
- # - Parameter signatures are hard-coded to `untyped` per ADR-14 § "Robustness principle compliance" clause 2;
31
- # `--params=observed` arrives in slice 3.
32
- # - Optional / rest / keyword / block params disqualify the def (`sig.skipped.complex-shape`).
27
+ # The scope, as it stands after the slices that widened the MVP:
28
+ # - Instance and singleton methods inside a nameable `class` / `module` body are considered. Top-level /
29
+ # DSL-block defs have no nameable receiver and are not candidates.
30
+ # - Parameter types are `untyped` per ADR-14 § "Robustness principle compliance" clause 2 unless
31
+ # `--params=observed` supplies call-site observations; the parameter LIST always mirrors the def's runtime
32
+ # shape (required / optional / rest / trailing / keyword / keyword-rest / forwarding / block). Issue #778
33
+ # retired the slice-1 gate that skipped every shape beyond required positionals as
34
+ # `sig.skipped.complex-shape`: the body typer binds every parameter to `untyped` when no RBS declares the
35
+ # method, so the inferred return is the same clause-1 answer for every shape, and the renderer the
36
+ # `initialize` stub already used spells them all.
33
37
  # - A `Dynamic[top]` inferred return becomes `sig.skipped.untyped-return` — emitting `untyped` would obscure
34
38
  # rather than help.
35
39
  # - Tighter-return detection compares the RBS-erased spellings only when the existing declared return
@@ -41,7 +45,7 @@ module Rigor
41
45
  UnrenderableMethod = Data.define(:path, :class_name, :method_name, :rbs, :error)
42
46
 
43
47
  # @return [Array<UnrenderableMethod>] empty on a healthy run; read after {#run}.
44
- attr_reader :unrenderable
48
+ attr_reader :unrenderable, :unresolvable_superclasses
45
49
 
46
50
  # @param configuration [Rigor::Configuration]
47
51
  # @param paths [Array<String>] files / directories to scan.
@@ -65,6 +69,10 @@ module Rigor
65
69
  @meta_layouts = {}
66
70
  # Whole-run, NOT per-file: a rendering defect is reported once at the end of the run.
67
71
  @unrenderable = []
72
+ # Issue #735 — whole-run too: `{ class name => the superclass token that resolves nowhere }`.
73
+ @unresolvable_superclasses = {}
74
+ # Issue #722 — whole-run: `{ class name => the lexical nesting its header is written in }`.
75
+ @superclass_nestings = {}
68
76
  end
69
77
 
70
78
  # Lifts legacy plain-`Array[Type]` observation entries into {ObservedCall} carriers. Specs from the
@@ -80,11 +88,229 @@ module Rigor
80
88
  def run
81
89
  @environment = build_environment
82
90
  resolved = resolve_paths(@paths)
83
- resolved.flat_map { |path| analyse_file(path, @environment) }
91
+ candidates = resolved.flat_map { |path| analyse_file(path, @environment) }
92
+ demote_overridden_base_methods(
93
+ demote_unresolvable_superclasses(resolve_superclass_spellings(candidates))
94
+ )
84
95
  end
85
96
 
86
97
  private
87
98
 
99
+ # Issue #744 — a base class's method is NOT emitted when a project subclass overrides it and the
100
+ # override is not emitted itself.
101
+ #
102
+ # RBS resolves an undeclared subclass method through its ancestors, so a precise return written for
103
+ # the base becomes the subclass's answer. redmine's `FieldFormat::Base#target_class` honestly returns
104
+ # `nil`; `RecordList#target_class` overrides it with a real lookup that sig-gen could not type, and
105
+ # the emitted `def target_class: () -> nil` then produced four `undefined method … for nil` on the
106
+ # subclass's own working code, plus a `def.return-type-mismatch` telling the user their correct
107
+ # override was wrong.
108
+ #
109
+ # Emitting nothing puts both classes back on source inference, which answers correctly for each. The
110
+ # base keeps its signature whenever nothing overrides the method, and whenever the override IS
111
+ # emitted with a type of its own — the declaration is only dangerous when it is the only one.
112
+ #
113
+ # Whether an inherited declaration SHOULD outrank a class's own source `def` is a separate question
114
+ # (#744 half 2) and is not decided here: this pass only stops sig-gen manufacturing the conflict.
115
+ def demote_overridden_base_methods(candidates)
116
+ superclasses = candidates.each_with_object({}) do |candidate, acc|
117
+ (candidate.class_superclasses || {}).each { |name, sup| acc[name] = sup.sub(/\[.*\]\z/, "") }
118
+ end
119
+ return candidates if superclasses.empty?
120
+
121
+ overridden = unsigned_override_ancestors(candidates, superclasses)
122
+ return candidates if overridden.empty?
123
+
124
+ candidates.map do |candidate|
125
+ next candidate unless Classification::EMITTABLE.include?(candidate.classification)
126
+ next candidate unless overridden.include?([candidate.class_name, candidate.method_name])
127
+
128
+ demoted_candidate(candidate, :overridden_by_unsigned_subclass)
129
+ end
130
+ end
131
+
132
+ # `[class name, method name]` pairs an UNSIGNED override shadows: for every candidate that will not
133
+ # be emitted, every ancestor of its class paired with its method name.
134
+ def unsigned_override_ancestors(candidates, superclasses)
135
+ candidates.each_with_object(Set.new) do |candidate, acc|
136
+ next if Classification::EMITTABLE.include?(candidate.classification)
137
+ next if candidate.class_name.nil?
138
+
139
+ each_ancestor(candidate.class_name, superclasses) { |name| acc << [name, candidate.method_name] }
140
+ end
141
+ end
142
+
143
+ def each_ancestor(class_name, superclasses)
144
+ seen = {}
145
+ current = class_name
146
+ while (parent = superclasses[current]) && !seen[parent]
147
+ seen[parent] = true
148
+ yield parent
149
+ current = parent
150
+ end
151
+ end
152
+
153
+ def demoted_candidate(candidate, reason)
154
+ MethodCandidate.new(
155
+ path: candidate.path, class_name: candidate.class_name, method_name: candidate.method_name,
156
+ kind: candidate.kind, classification: Classification::SKIPPED, skip_reason: reason,
157
+ inferred_return: candidate.inferred_return, declared_return_rbs: candidate.declared_return_rbs,
158
+ namespace_kinds: candidate.namespace_kinds, class_shells: candidate.class_shells,
159
+ class_superclasses: candidate.class_superclasses
160
+ )
161
+ end
162
+
163
+ # Issue #722 — resolves each recorded superclass token to the class Ruby means by it, and rewrites the
164
+ # emitted spelling when the two differ.
165
+ #
166
+ # `record_superclass` kept the source token verbatim on the reasoning that "RBS resolves it relative to
167
+ # the emitted namespace, matching Ruby's lexical scope". That holds only while the emitted namespace
168
+ # equals the source nesting, and sig-gen FLATTENS: `module Admin; class Record2 < Record` is written
169
+ # out as the compact `class Admin::Record2 < Record`, and RBS resolves a compact header's superclass at
170
+ # the TOP level (verified against the rbs gem), so the emitted token silently re-points at `::Record`
171
+ # where the program means `Admin::Record`. The checker resolves the same declaration correctly, so the
172
+ # generated sidecar contradicted the analyzer that wrote it.
173
+ #
174
+ # Candidate order is Ruby's own for the superclass expression: the header's nesting innermost-first,
175
+ # then the bare name — the reading `Scope#ancestor_name_candidates` owns for the analyzer's class
176
+ # graph. The question here is about the EMITTED tree, so the "is this a class" test is this run's
177
+ # declarations plus the RBS environment rather than the discovery tables; keep the two orders in step.
178
+ def resolve_superclass_spellings(candidates)
179
+ return candidates if @superclass_nestings.empty?
180
+
181
+ known = candidates.each_with_object(Set.new) do |candidate, acc|
182
+ acc << candidate.class_name if candidate.class_name
183
+ acc.merge(candidate.class_shells || [])
184
+ end
185
+ rewrites = superclass_rewrites(known)
186
+ return candidates if rewrites.empty?
187
+
188
+ candidates.map do |candidate|
189
+ overlap = rewrites.slice(*candidate.class_superclasses.keys)
190
+ next candidate if overlap.empty?
191
+
192
+ rebuild_with_superclasses(candidate, candidate.class_superclasses.merge(overlap))
193
+ end
194
+ end
195
+
196
+ def superclass_rewrites(known)
197
+ @superclass_nestings.each_with_object({}) do |(full, (nesting, token)), acc|
198
+ next if token.nil? || nesting.empty?
199
+
200
+ resolved = resolve_superclass_token(token, nesting, known)
201
+ acc[full] = resolved if resolved && resolved != token
202
+ end
203
+ end
204
+
205
+ # The first candidate spelling that names a class this run declares or the RBS environment knows, or
206
+ # nil when none does — an unresolvable superclass is #735's business, not this pass's.
207
+ def resolve_superclass_token(token, nesting, known)
208
+ base = token.sub(/\[.*\]\z/, "")
209
+ args = token[base.length..] || ""
210
+ nesting.length.downto(1) do |i|
211
+ candidate = "#{nesting[0, i].join('::')}::#{base}"
212
+ return "#{candidate}#{args}" if known.include?(candidate) ||
213
+ Reflection.rbs_class_known?(candidate, environment: @environment)
214
+ end
215
+ token
216
+ end
217
+
218
+ def rebuild_with_superclasses(candidate, superclasses)
219
+ MethodCandidate.new(
220
+ path: candidate.path, class_name: candidate.class_name, method_name: candidate.method_name,
221
+ kind: candidate.kind, classification: candidate.classification,
222
+ inferred_return: candidate.inferred_return, declared_return_rbs: candidate.declared_return_rbs,
223
+ rbs: candidate.rbs, skip_reason: candidate.skip_reason,
224
+ namespace_kinds: candidate.namespace_kinds, class_shells: candidate.class_shells,
225
+ class_superclasses: superclasses
226
+ )
227
+ end
228
+
229
+ # Issue #735 — a generated declaration whose superclass chain does not terminate in a class the RBS
230
+ # environment knows is DROPPED rather than emitted.
231
+ #
232
+ # `record_superclass` is right that a subclass declaration must carry its superclass: without it the
233
+ # sidecar misrepresents the class and dispatch degrades. But emitting one the environment cannot
234
+ # resolve is worse than either — `RBS::NoSuperclassFoundError` collapses the class, so every call into
235
+ # it reads `Dynamic[top]` and the whole declaration is a liability. On redmine, `class Principal <
236
+ # ApplicationRecord` (nothing declares `ApplicationRecord`, and nothing could: the chain ends at
237
+ # `ActiveRecord::Base`, which no RBS in the environment carries) collapsed 98 classes on the very next
238
+ # run. A chain that LOOPS is dropped for the same reason, and is the shape that made an
239
+ # `AncestorBuilder` walk recurse until `SystemStackError` (#609).
240
+ #
241
+ # Dropping is the conservative end: no declaration leaves the class exactly as it was before sig-gen
242
+ # ran — source discovery and the plugins still type it. Emitting the class with the superclass merely
243
+ # OMITTED would be the harmful middle: the class becomes RBS-known with only the emitted methods, so
244
+ # every inherited call fires `call.undefined-method`.
245
+ def demote_unresolvable_superclasses(candidates)
246
+ superclasses = candidates.each_with_object({}) do |candidate, acc|
247
+ (candidate.class_superclasses || {}).each { |name, sup| acc[name] = sup }
248
+ end
249
+ # Everything this run declares, superclass or not: a chain that reaches `class Record` — emitted
250
+ # here, with no superclass of its own — terminates in a class the next run WILL know.
251
+ emitted = candidates.each_with_object(Set.new) do |candidate, acc|
252
+ acc << candidate.class_name if candidate.class_name
253
+ acc.merge(candidate.class_shells || [])
254
+ end
255
+ unresolvable = superclasses.each_with_object({}) do |(name, _sup), acc|
256
+ root = unresolved_superclass_root(name, superclasses, emitted)
257
+ acc[name] = root if root
258
+ end
259
+ return candidates if unresolvable.empty?
260
+
261
+ candidates.map do |candidate|
262
+ owner = unresolvable_for(candidate.class_name, unresolvable)
263
+ next candidate if owner.nil?
264
+
265
+ record_unresolvable_superclass(candidate, owner)
266
+ end
267
+ end
268
+
269
+ # A nested class or module goes with its wrapper. `WikiPage::Webhookable`'s declaration is emitted
270
+ # INSIDE `class WikiPage < ApplicationRecord`, so keeping the inner one while dropping the outer emits
271
+ # the unresolvable header anyway — the wrapper is not a candidate of its own and carries no methods,
272
+ # so nothing else would have demoted it.
273
+ def unresolvable_for(class_name, unresolvable)
274
+ return nil if class_name.nil?
275
+ return [class_name, unresolvable[class_name]] if unresolvable.key?(class_name)
276
+
277
+ unresolvable.each do |name, root|
278
+ return [name, root] if class_name.start_with?("#{name}::")
279
+ end
280
+ nil
281
+ end
282
+
283
+ # The first superclass token on `name`'s recorded chain that neither the RBS environment knows nor this
284
+ # run emits, or nil when the chain terminates in a known class. A chain that revisits a class returns
285
+ # that class's own superclass token — a cycle resolves nowhere.
286
+ def unresolved_superclass_root(name, superclasses, emitted, seen = {})
287
+ superclass = superclasses[name]
288
+ return nil if superclass.nil?
289
+ return superclass if seen[name]
290
+
291
+ seen[name] = true
292
+ # The recorded spelling may carry the type arguments {#generic_superclass_spelling} added; the
293
+ # resolvability question is about the class, so it is asked of the bare name.
294
+ base = superclass.sub(/\[.*\]\z/, "")
295
+ return nil if Reflection.rbs_class_known?(base, environment: @environment)
296
+ return superclass unless emitted.include?(base)
297
+
298
+ unresolved_superclass_root(base, superclasses, emitted, seen)
299
+ end
300
+
301
+ def record_unresolvable_superclass(candidate, owner)
302
+ class_name, root = owner
303
+ @unresolvable_superclasses[class_name] ||= root
304
+ MethodCandidate.new(
305
+ path: candidate.path, class_name: candidate.class_name, method_name: candidate.method_name,
306
+ kind: candidate.kind, classification: Classification::SKIPPED,
307
+ skip_reason: :unresolvable_superclass, inferred_return: candidate.inferred_return,
308
+ declared_return_rbs: candidate.declared_return_rbs,
309
+ namespace_kinds: candidate.namespace_kinds, class_shells: candidate.class_shells,
310
+ class_superclasses: candidate.class_superclasses
311
+ )
312
+ end
313
+
88
314
  def build_environment
89
315
  Environment.for_project(
90
316
  libraries: @configuration.libraries,
@@ -193,7 +419,7 @@ module Rigor
193
419
 
194
420
  full = (prefix + [name]).join("::")
195
421
  @namespace_kinds[full] = node.is_a?(Prism::ClassNode) ? :class : :module
196
- record_superclass(node, full)
422
+ record_superclass(node, full, prefix)
197
423
  walk_namespace_body(node, prefix + [name], out)
198
424
  true
199
425
  end
@@ -207,11 +433,38 @@ module Rigor
207
433
  # superclass (`Class.new`, any other `CallNode`) is left unrecorded — a `Data.define` / `Struct.new` one is
208
434
  # already carried by {#register_meta_classes}, and the rest are un-representable, where guessing would
209
435
  # misfold.
210
- def record_superclass(node, full)
436
+ def record_superclass(node, full, prefix)
211
437
  return unless node.is_a?(Prism::ClassNode)
212
438
 
213
439
  superclass = qualified_constant_path(node.superclass)
214
- @class_superclasses[full] = superclass if superclass
440
+ return if superclass.nil?
441
+
442
+ # Issue #722 — the nesting the header is WRITTEN in, kept whole-run (not per-file scratch) because
443
+ # the name it resolves to may be a class another file declares. Resolved once every file has been
444
+ # walked, in {#resolve_superclass_spellings}.
445
+ spelling = generic_superclass_spelling(superclass)
446
+ # The token rides along because `@class_superclasses` is per-FILE scratch, reset before the next
447
+ # file, while this table is whole-run.
448
+ @superclass_nestings[full] = [prefix.dup, spelling]
449
+ @class_superclasses[full] = spelling
450
+ end
451
+
452
+ # Issue #735 — a GENERIC superclass has to be written with its arguments. `class Entries < Array`
453
+ # yields `RBS::InvalidTypeApplicationError: ::Array expects parameters [unchecked out E], but given
454
+ # args []`, which fails the build for that class exactly as an unresolvable superclass does — redmine
455
+ # subclasses `Array` five times. The element type is not something this pass can infer, so it is
456
+ # `untyped`: the subclass's OWN methods are what sig-gen is here to record, and `Array[untyped]` keeps
457
+ # the inherited surface resolving instead of collapsing the class.
458
+ def generic_superclass_spelling(superclass)
459
+ loader = @environment&.rbs_loader
460
+ return superclass if loader.nil?
461
+
462
+ arity = loader.class_type_param_names(superclass).size
463
+ return superclass if arity.zero?
464
+
465
+ "#{superclass}[#{(['untyped'] * arity).join(', ')}]"
466
+ rescue StandardError
467
+ superclass
215
468
  end
216
469
 
217
470
  # The ADR-48 member layouts for this file, in one table keyed by qualified class name. Reading the engine's
@@ -401,8 +654,8 @@ module Rigor
401
654
  end
402
655
 
403
656
  # Emits `def initialize: (<shape>) -> void`. The return is always `void` because Ruby's `initialize`
404
- # return value is never meaningful. The parameter list mirrors the runtime shape (required / optional /
405
- # rest / keyword / keyword-rest / block).
657
+ # return value is never meaningful. The parameter list mirrors the runtime shape through the same
658
+ # {#render_param_list} every other def renders with.
406
659
  #
407
660
  # When `--params=observed` populates `@observations` for `[class_name, :initialize]` (via the
408
661
  # `ObservationCollector`'s `.new` → `:initialize` routing), positional and keyword arg types come from the
@@ -410,7 +663,7 @@ module Rigor
410
663
  # clause 2.
411
664
  def initialize_stub_candidate(path, def_node, class_name)
412
665
  params = def_node.parameters
413
- rbs = "def initialize: (#{render_initialize_param_list(params, class_name)})" \
666
+ rbs = "def initialize: (#{render_param_list(params, class_name, :initialize)})" \
414
667
  "#{block_signature_suffix(params)} -> void"
415
668
  build_candidate(
416
669
  path: path, class_name: class_name, method_name: :initialize,
@@ -419,57 +672,81 @@ module Rigor
419
672
  )
420
673
  end
421
674
 
422
- def render_initialize_param_list(params, class_name)
675
+ # The RBS parameter list of a def, mirroring its runtime shape position by position: `untyped` per
676
+ # required positional, `?untyped` per optional, `*untyped` for a rest, one `untyped` per trailing
677
+ # positional after the rest, `name: untyped` / `?name: untyped` per keyword, `**untyped` for a keyword
678
+ # rest, and `*untyped, **untyped` for `...` forwarding (its block half is {#block_signature_suffix}'s).
679
+ # `**nil` declares that no keywords are accepted, which RBS cannot spell, so it renders nothing.
680
+ #
681
+ # Per ADR-5 clause 2 every position is `untyped` unless `--params=observed` supplied call-site
682
+ # observations, in which case each leading positional and each keyword carries the union of the types the
683
+ # arity-compatible observations passed there ({#arity_matched_observations}). The rest, the trailing
684
+ # positionals and the keyword rest stay `untyped`: an observation does not say which of its arguments the
685
+ # splat absorbed. Before #778 only `initialize` rendered through this path and every other def with a
686
+ # shape beyond required positionals was skipped as `sig.skipped.complex-shape`.
687
+ def render_param_list(params, class_name, method_name)
423
688
  return "" unless params.is_a?(Prism::ParametersNode)
424
689
 
425
- observations = initialize_observations(class_name, params)
426
- offset = 0
690
+ observations = arity_matched_observations(class_name, method_name, params)
427
691
  parts = []
428
-
429
- params.requireds.each_with_index do |_, i|
430
- parts << initialize_positional_type(observations, offset + i, "")
431
- end
432
- offset += params.requireds.size
433
-
434
- params.optionals.each_with_index do |_, i|
435
- parts << initialize_positional_type(observations, offset + i, "?")
436
- end
437
-
692
+ params.requireds.each_index { |i| parts << positional_type(observations, i, "") }
693
+ offset = params.requireds.size
694
+ params.optionals.each_index { |i| parts << positional_type(observations, offset + i, "?") }
438
695
  parts << "*untyped" if params.rest
696
+ parts.concat(Array.new(params.posts.size, "untyped"))
439
697
  params.keywords.each { |kw| parts << render_keyword_param(kw, observations) }
440
- parts << "**untyped" if params.keyword_rest
698
+ parts.concat(keyword_rest_parts(params))
441
699
  parts.join(", ")
442
700
  end
443
701
 
702
+ def keyword_rest_parts(params)
703
+ case params.keyword_rest
704
+ when Prism::ForwardingParameterNode
705
+ # `...` forwards positionals as well as keywords; a rest already rendered covers the former.
706
+ params.rest ? ["**untyped"] : ["*untyped", "**untyped"]
707
+ when Prism::KeywordRestParameterNode then ["**untyped"]
708
+ else []
709
+ end
710
+ end
711
+
444
712
  # The RBS block suffix for a `def` that takes a `&block` parameter, e.g. ` ?{ (*untyped) -> untyped }`.
445
713
  # A block belongs AFTER the parameter parens in RBS (`(params) ?{ block } -> ret`), not inside them —
446
714
  # emitting it as a comma-joined member produced `(**untyped, ?{ (?) -> void })`, which RBS rejects
447
715
  # (`optional keyword argument type is expected`) and which then collapsed the whole env build. sig-gen
448
716
  # never observes the block's own signature, so it is rendered maximally lenient (ADR-5): an optional
449
- # block (`?{`, since a `&block` need not be passed) taking `*untyped` and returning `untyped`. Returns
450
- # "" when the method takes no block.
717
+ # block (`?{`, since a `&block` need not be passed) taking `*untyped` and returning `untyped`. `...`
718
+ # forwards a block too and gets the same suffix. Returns "" when the method takes no block.
451
719
  def block_signature_suffix(params)
452
720
  return "" unless params.is_a?(Prism::ParametersNode)
453
- return "" if params.block.nil?
721
+ return "" unless params.block || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
454
722
 
455
723
  " ?{ (*untyped) -> untyped }"
456
724
  end
457
725
 
458
- # Picks observations under `[class_name, :initialize]` whose positional arity matches the def's accepted
459
- # range (required..required+optional). Looser arities don't get used because they describe a different
460
- # overload the stub cannot express.
461
- def initialize_observations(class_name, params)
726
+ # Observations under `[class_name, method_name]` whose positional arity the def accepts: at least its
727
+ # required count (leading plus trailing), and, unless a rest or `...` absorbs the surplus, at most
728
+ # required plus optional. An arity outside that window describes a different overload the signature
729
+ # cannot express, so it says nothing about these positions. #778 widened the window for ordinary defs
730
+ # from the exact required count — `def optional(text = "x")` called as `optional` AND as `optional("y")`
731
+ # now credits both call sites.
732
+ def arity_matched_observations(class_name, method_name, params)
462
733
  return [] if @observations.empty?
463
734
 
464
- list = @observations[[class_name, :initialize]] || []
465
- min = params.requireds.size
466
- max = min + params.optionals.size
467
- list.select { |obs| (min..max).cover?(obs.positional.size) }
735
+ list = @observations[[class_name, method_name]] || []
736
+ min = params.requireds.size + params.posts.size
737
+ max = unbounded_positionals?(params) ? Float::INFINITY : min + params.optionals.size
738
+ list.select { |obs| obs.positional.size.between?(min, max) }
739
+ end
740
+
741
+ def unbounded_positionals?(params)
742
+ !params.rest.nil? || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
468
743
  end
469
744
 
470
- def initialize_positional_type(observations, index, prefix)
745
+ # A bare union is a valid positional type in RBS (`(String | Integer, ?String | Integer)`), so unlike the
746
+ # return position it is not parenthesised.
747
+ def positional_type(observations, index, prefix)
471
748
  types = observations.filter_map { |obs| obs.positional[index] }
472
- "#{prefix}#{types.empty? ? 'untyped' : paren_wrap_union(union_erase(types))}"
749
+ "#{prefix}#{union_erase(types)}"
473
750
  end
474
751
 
475
752
  def render_keyword_param(keyword, observations)
@@ -497,10 +774,6 @@ module Rigor
497
774
  return nil if initialize_excludes?(def_node, kind)
498
775
  return initialize_stub_candidate(path, def_node, class_name) if non_trivial_initialize?(def_node, kind)
499
776
 
500
- unless simple_parameter_shape?(def_node.parameters)
501
- return skipped(path, def_node, class_name, kind, :complex_shape)
502
- end
503
-
504
777
  inferred = infer_return_type(def_node, scope_index)
505
778
  return skipped(path, def_node, class_name, kind, :untyped_return) if inferred.nil? || dynamic_top?(inferred)
506
779
 
@@ -514,20 +787,6 @@ module Rigor
514
787
  end
515
788
  end
516
789
 
517
- # Required positionals only; the MVP's body-typing path gives well-defined returns for that shape.
518
- # Optional / rest / keyword / block parameters route through the `sig.skipped.complex-shape` reason until
519
- # slices 3+ widen the param policy.
520
- def simple_parameter_shape?(params)
521
- return true if params.nil?
522
- return false unless params.is_a?(Prism::ParametersNode)
523
-
524
- params.optionals.empty? &&
525
- params.rest.nil? &&
526
- params.keywords.empty? &&
527
- params.keyword_rest.nil? &&
528
- params.block.nil?
529
- end
530
-
531
790
  # Mirrors the `def.return-type-mismatch` rule's body-type extraction: type the implicit-return expression
532
791
  # under the scope the indexer associated with the body. The parameter bindings (typed `untyped` per the
533
792
  # indexer's default) come from `with_local` inside `StatementEvaluator`; the result is the carrier the
@@ -742,8 +1001,8 @@ module Rigor
742
1001
  end
743
1002
 
744
1003
  def render_rbs_line(def_node, inferred, class_name, kind)
745
- arity = required_arity(def_node)
746
- head = arity.zero? ? "()" : "(#{render_param_list(class_name, def_node.name, arity)})"
1004
+ params = def_node.parameters
1005
+ head = "(#{render_param_list(params, class_name, def_node.name)})#{block_signature_suffix(params)}"
747
1006
  prefix = method_def_prefix(class_name, def_node.name, kind)
748
1007
  "#{prefix}#{def_node.name}: #{head} -> #{paren_wrap_union(elaborated_rbs(inferred))}"
749
1008
  end
@@ -779,30 +1038,6 @@ module Rigor
779
1038
  false
780
1039
  end
781
1040
 
782
- def required_arity(def_node)
783
- params = def_node.parameters
784
- params.is_a?(Prism::ParametersNode) ? params.requireds.size : 0
785
- end
786
-
787
- # Per ADR-5 clause 2 the default is `untyped` for every position. Observed-policy callers
788
- # (`--params=observed`) pass an `observations:` map at construction time; the generator unions
789
- # per-position arg types whose tuple arity matches the def's required-positional count. Observations from
790
- # arities other than the def's count are discarded — they describe a different overload the MVP does not
791
- # emit.
792
- def render_param_list(class_name, method_name, arity)
793
- tuples = matching_observations(class_name, method_name, arity)
794
- return Array.new(arity, "untyped").join(", ") if tuples.empty?
795
-
796
- Array.new(arity) { |i| union_erase(tuples.map { |obs| obs.positional[i] }) }.join(", ")
797
- end
798
-
799
- def matching_observations(class_name, method_name, arity)
800
- return [] if @observations.empty?
801
-
802
- list = @observations[[class_name, method_name]] || []
803
- list.select { |obs| obs.positional.size == arity }
804
- end
805
-
806
1041
  def union_erase(types)
807
1042
  return "untyped" if types.empty?
808
1043
  return elaborated_rbs(types.first) if types.size == 1
@@ -14,7 +14,8 @@ module Rigor
14
14
  # - `:diff` — a unified-style diff comparing the existing RBS spelling (if any) against the inferred
15
15
  # spelling. The MVP renders a minimal "- declared / + inferred" block; full per-file diffing arrives with
16
16
  # slice 2's `--write` merge.
17
- # - `:json` — machine-readable payload with the same classification table as `:print`.
17
+ # - `:json` — machine-readable payload with the same classification table as `:print`, plus every `skipped`
18
+ # row with its `skip_reason` (#778).
18
19
  class Renderer
19
20
  def initialize(out:)
20
21
  @out = out
@@ -27,11 +28,10 @@ module Rigor
27
28
  # {Classification} constants to include; an empty
28
29
  # array means "all emittable classifications".
29
30
  def render(candidates:, mode:, format:, selection:)
30
- filtered = filter(candidates, selection)
31
-
32
31
  case format
33
- when "json" then render_json(filtered)
32
+ when "json" then render_json(filter(candidates, selection, with_skipped: true))
34
33
  when "text"
34
+ filtered = filter(candidates, selection)
35
35
  mode == :diff ? render_diff(filtered) : render_print(filtered)
36
36
  else
37
37
  raise ArgumentError, "unsupported format: #{format}"
@@ -40,14 +40,16 @@ module Rigor
40
40
 
41
41
  private
42
42
 
43
- EMITTABLE = [Classification::NEW_FILE,
44
- Classification::NEW_METHOD,
45
- Classification::TIGHTER_RETURN].freeze
46
- private_constant :EMITTABLE
47
-
48
- def filter(candidates, selection)
49
- active = selection.empty? ? EMITTABLE : selection
50
- candidates.select { |c| active.include?(c.classification) }
43
+ # The emittable rows the selection asks for. JSON also carries every `skipped` row whatever the selection:
44
+ # ADR-14 makes the JSON payload the surface where `sig.skipped.*` is reported, and a consumer asking why a
45
+ # method is missing from its `sig/` needs the reason next to the rows that did emit (#778 — the rows were
46
+ # built with their `skip_reason` and then dropped here). `equivalent` rows stay out: nothing to do,
47
+ # nothing to explain.
48
+ def filter(candidates, selection, with_skipped: false)
49
+ active = selection.empty? ? Classification::EMITTABLE : selection
50
+ candidates.select do |c|
51
+ active.include?(c.classification) || (with_skipped && c.classification == Classification::SKIPPED)
52
+ end
51
53
  end
52
54
 
53
55
  def render_print(candidates)
@@ -90,6 +90,9 @@ module Rigor
90
90
  target.exist? ? update_existing(source_path, target, candidates) : create_new(source_path, target, candidates)
91
91
  end
92
92
 
93
+ # Deliberately NARROWER than {Classification::EMITTABLE}: the write path has never produced a
94
+ # `:new_file` row (the generator does not classify one), and admitting it here would let a
95
+ # classification with no writer behind it decide that a target file must be created.
93
96
  EMITTABLE = [Classification::NEW_METHOD, Classification::TIGHTER_RETURN].freeze
94
97
  private_constant :EMITTABLE
95
98