rigortype 0.4.5 → 0.4.7

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 (452) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/gem_overlay/activesupport/core_ext.rbs +16 -13
  4. data/data/vendored_gem_sigs/bundler/bundler.rbs +1 -1
  5. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +4 -3
  6. data/docs/handbook/01-getting-started.md +5 -2
  7. data/docs/handbook/11-sig-gen.md +61 -5
  8. data/docs/handbook/appendix-phpstan.md +4 -4
  9. data/docs/manual/02-cli-reference.md +192 -27
  10. data/docs/manual/03-configuration.md +38 -4
  11. data/docs/manual/04-diagnostics.md +1 -1
  12. data/docs/manual/06-baseline.md +6 -4
  13. data/docs/manual/09-editor-integration.md +13 -2
  14. data/docs/manual/10-mcp-server.md +47 -3
  15. data/docs/manual/12-caching.md +9 -1
  16. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  17. data/docs/manual/plugins/rigor-actionpack.md +49 -11
  18. data/docs/manual/plugins/rigor-activesupport-core-ext.md +20 -1
  19. data/docs/manual/plugins/rigor-devise.md +15 -0
  20. data/docs/manual/plugins/rigor-rspec.md +2 -1
  21. data/lib/rigor/analysis/baseline.rb +24 -27
  22. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +5 -6
  24. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +2 -2
  25. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +4 -4
  26. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +2 -2
  27. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +105 -12
  28. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +143 -15
  29. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -1
  30. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -8
  31. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +1 -1
  32. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  33. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  34. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  35. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +1 -2
  36. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  37. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -1
  38. data/lib/rigor/analysis/check_rules/rule_walk.rb +9 -7
  39. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +3 -4
  40. data/lib/rigor/analysis/check_rules/special_global_setters.rb +3 -3
  41. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +53 -2
  42. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  43. data/lib/rigor/analysis/check_rules.rb +217 -52
  44. data/lib/rigor/analysis/crash_signature.rb +2 -2
  45. data/lib/rigor/analysis/dependency_recorder.rb +15 -4
  46. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +14 -3
  47. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +1 -1
  48. data/lib/rigor/analysis/dependency_source_inference/index.rb +6 -8
  49. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  50. data/lib/rigor/analysis/edit_replay.rb +154 -0
  51. data/lib/rigor/analysis/fact_store.rb +3 -3
  52. data/lib/rigor/analysis/incremental.rb +37 -0
  53. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -2
  54. data/lib/rigor/analysis/incremental_run_slot.rb +2 -2
  55. data/lib/rigor/analysis/incremental_session.rb +202 -43
  56. data/lib/rigor/analysis/path_expansion.rb +41 -0
  57. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  58. data/lib/rigor/analysis/rule_catalog.rb +7 -3
  59. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  60. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  61. data/lib/rigor/analysis/run_stats.rb +2 -2
  62. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +8 -4
  63. data/lib/rigor/analysis/runner/declaration_position.rb +1 -1
  64. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +204 -96
  65. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
  66. data/lib/rigor/analysis/runner/pool_coordinator.rb +157 -320
  67. data/lib/rigor/analysis/runner/project_pre_passes.rb +48 -6
  68. data/lib/rigor/analysis/runner/run_level_facts.rb +304 -0
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +44 -54
  70. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  71. data/lib/rigor/analysis/runner/worker_payload.rb +36 -0
  72. data/lib/rigor/analysis/runner.rb +233 -120
  73. data/lib/rigor/analysis/self_call_resolution_recorder.rb +1 -1
  74. data/lib/rigor/analysis/template_unit_positions.rb +2 -3
  75. data/lib/rigor/analysis/template_units.rb +9 -1
  76. data/lib/rigor/analysis/worker_session.rb +38 -33
  77. data/lib/rigor/bleeding_edge.rb +3 -6
  78. data/lib/rigor/builtins/imported_refinements.rb +6 -8
  79. data/lib/rigor/builtins/predefined_constant_refinements.rb +1 -1
  80. data/lib/rigor/builtins/regex_refinement.rb +4 -6
  81. data/lib/rigor/cache/descriptor.rb +6 -5
  82. data/lib/rigor/cache/incremental_snapshot.rb +60 -18
  83. data/lib/rigor/cache/rbs_cache_producer.rb +2 -2
  84. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  85. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -2
  86. data/lib/rigor/cache/rbs_constant_table.rb +1 -1
  87. data/lib/rigor/cache/rbs_descriptor.rb +54 -3
  88. data/lib/rigor/cache/rbs_environment.rb +4 -1
  89. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +2 -2
  90. data/lib/rigor/cache/rbs_known_class_names.rb +1 -1
  91. data/lib/rigor/cache/store.rb +11 -10
  92. data/lib/rigor/ci_detector.rb +1 -1
  93. data/lib/rigor/cli/annotate_command.rb +24 -19
  94. data/lib/rigor/cli/baseline_command.rb +145 -55
  95. data/lib/rigor/cli/catalogue.rb +256 -0
  96. data/lib/rigor/cli/check_command.rb +188 -93
  97. data/lib/rigor/cli/check_runner_factory.rb +2 -2
  98. data/lib/rigor/cli/command.rb +15 -1
  99. data/lib/rigor/cli/coverage_command.rb +21 -8
  100. data/lib/rigor/cli/coverage_mutation.rb +5 -7
  101. data/lib/rigor/cli/coverage_renderer.rb +2 -1
  102. data/lib/rigor/cli/coverage_scan.rb +3 -5
  103. data/lib/rigor/cli/diff_command.rb +27 -21
  104. data/lib/rigor/cli/docs_command.rb +1 -2
  105. data/lib/rigor/cli/doctor_command.rb +12 -12
  106. data/lib/rigor/cli/effects_command.rb +50 -40
  107. data/lib/rigor/cli/effects_diff_renderer.rb +18 -17
  108. data/lib/rigor/cli/effects_explain_renderer.rb +13 -12
  109. data/lib/rigor/cli/effects_renderer.rb +2 -1
  110. data/lib/rigor/cli/effects_snapshot_command.rb +18 -17
  111. data/lib/rigor/cli/explain_command.rb +18 -8
  112. data/lib/rigor/cli/fused_protection_renderer.rb +2 -1
  113. data/lib/rigor/cli/json_document.rb +41 -0
  114. data/lib/rigor/cli/lens_command.rb +17 -13
  115. data/lib/rigor/cli/lsp_command.rb +250 -23
  116. data/lib/rigor/cli/mcp_command.rb +14 -8
  117. data/lib/rigor/cli/mutation_fork_scan.rb +1 -1
  118. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -2
  119. data/lib/rigor/cli/options.rb +60 -4
  120. data/lib/rigor/cli/plugin_command.rb +93 -46
  121. data/lib/rigor/cli/plugins_command.rb +22 -18
  122. data/lib/rigor/cli/plugins_renderer.rb +34 -8
  123. data/lib/rigor/cli/protection_fork_scan.rb +2 -2
  124. data/lib/rigor/cli/protection_renderer.rb +2 -1
  125. data/lib/rigor/cli/show_bleedingedge_command.rb +23 -16
  126. data/lib/rigor/cli/sig_gen_command.rb +130 -34
  127. data/lib/rigor/cli/skill_command.rb +32 -3
  128. data/lib/rigor/cli/skill_deep_probe.rb +1 -1
  129. data/lib/rigor/cli/skill_describe.rb +1 -1
  130. data/lib/rigor/cli/trace_command.rb +14 -9
  131. data/lib/rigor/cli/triage_command.rb +10 -11
  132. data/lib/rigor/cli/triage_renderer.rb +2 -1
  133. data/lib/rigor/cli/type_of_command.rb +18 -12
  134. data/lib/rigor/cli/type_of_project_scope.rb +24 -0
  135. data/lib/rigor/cli/type_of_renderer.rb +4 -1
  136. data/lib/rigor/cli/type_scan_command.rb +14 -9
  137. data/lib/rigor/cli/type_scan_renderer.rb +2 -1
  138. data/lib/rigor/cli/unused_command.rb +27 -17
  139. data/lib/rigor/cli/upgrade_command.rb +5 -0
  140. data/lib/rigor/cli.rb +209 -54
  141. data/lib/rigor/config_audit.rb +17 -6
  142. data/lib/rigor/configuration/severity_profile.rb +5 -5
  143. data/lib/rigor/configuration.rb +86 -34
  144. data/lib/rigor/effects/definition_context.rb +0 -2
  145. data/lib/rigor/effects/envelope.rb +1 -1
  146. data/lib/rigor/effects/framework_units.rb +1 -1
  147. data/lib/rigor/effects/mutation_classifier.rb +4 -7
  148. data/lib/rigor/effects/propagator.rb +1 -1
  149. data/lib/rigor/effects/registry.rb +2 -2
  150. data/lib/rigor/effects/snapshot.rb +1 -1
  151. data/lib/rigor/effects/taint_cause.rb +0 -2
  152. data/lib/rigor/effects/unit_scan.rb +9 -13
  153. data/lib/rigor/environment/bundle_sig_discovery.rb +5 -9
  154. data/lib/rigor/environment/class_registry.rb +3 -3
  155. data/lib/rigor/environment/core_root.rb +77 -0
  156. data/lib/rigor/environment/default_libraries.rb +1 -1
  157. data/lib/rigor/environment/deferred_signature_plan.rb +283 -0
  158. data/lib/rigor/environment/gated_signature_guard.rb +1 -1
  159. data/lib/rigor/environment/gated_signature_plan.rb +5 -2
  160. data/lib/rigor/environment/lockfile_resolver.rb +28 -12
  161. data/lib/rigor/environment/member_consistency/comparator.rb +18 -1
  162. data/lib/rigor/environment/member_consistency.rb +56 -7
  163. data/lib/rigor/environment/missing_gem_constant_index.rb +3 -2
  164. data/lib/rigor/environment/project_path_gems.rb +124 -0
  165. data/lib/rigor/environment/rbs_hierarchy.rb +6 -15
  166. data/lib/rigor/environment/rbs_loader.rb +592 -70
  167. data/lib/rigor/environment/reflection.rb +7 -7
  168. data/lib/rigor/environment/required_features.rb +136 -32
  169. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  170. data/lib/rigor/environment/vendored_signature_plan.rb +153 -0
  171. data/lib/rigor/environment.rb +44 -11
  172. data/lib/rigor/flow_contribution/merger.rb +4 -4
  173. data/lib/rigor/inference/acceptance.rb +93 -32
  174. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  175. data/lib/rigor/inference/block_run_count.rb +178 -0
  176. data/lib/rigor/inference/budget_trace.rb +8 -2
  177. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -2
  178. data/lib/rigor/inference/builtins/numeric_catalog.rb +2 -5
  179. data/lib/rigor/inference/builtins/random_catalog.rb +2 -2
  180. data/lib/rigor/inference/callee_mutation_chain.rb +126 -0
  181. data/lib/rigor/inference/callee_write_census.rb +815 -0
  182. data/lib/rigor/inference/construction_escape.rb +217 -0
  183. data/lib/rigor/inference/declaration_walk/traversal.rb +1 -1
  184. data/lib/rigor/inference/deep_erasure.rb +170 -0
  185. data/lib/rigor/inference/def_handle.rb +1 -1
  186. data/lib/rigor/inference/def_node_resolver.rb +1 -1
  187. data/lib/rigor/inference/def_return_typer.rb +204 -12
  188. data/lib/rigor/inference/definer_resolution.rb +21 -5
  189. data/lib/rigor/inference/definers_index.rb +106 -0
  190. data/lib/rigor/inference/direct_ivar_writes.rb +300 -0
  191. data/lib/rigor/inference/element_read_widening.rb +49 -3
  192. data/lib/rigor/inference/error_info.rb +17 -3
  193. data/lib/rigor/inference/expression_typer.rb +173 -57
  194. data/lib/rigor/inference/external_ancestor_resolution.rb +3 -4
  195. data/lib/rigor/inference/global_write_census.rb +110 -1
  196. data/lib/rigor/inference/global_writer_reach.rb +260 -0
  197. data/lib/rigor/inference/hkt_body.rb +1 -2
  198. data/lib/rigor/inference/hkt_body_parser.rb +3 -5
  199. data/lib/rigor/inference/hkt_reducer.rb +2 -2
  200. data/lib/rigor/inference/hkt_registry.rb +3 -5
  201. data/lib/rigor/inference/indexed_narrowing.rb +4 -7
  202. data/lib/rigor/inference/intervening_ivar_reset.rb +26 -4
  203. data/lib/rigor/inference/ivar_writer_census.rb +246 -0
  204. data/lib/rigor/inference/key_presence_guard.rb +2 -1
  205. data/lib/rigor/inference/loop_value_record.rb +57 -0
  206. data/lib/rigor/inference/match_rebinding/frame.rb +65 -0
  207. data/lib/rigor/inference/match_rebinding/yielding_match.rb +45 -0
  208. data/lib/rigor/inference/match_rebinding.rb +11 -1
  209. data/lib/rigor/inference/memo_audit.rb +55 -0
  210. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +1 -1
  211. data/lib/rigor/inference/method_dispatcher/argument_distribution.rb +40 -0
  212. data/lib/rigor/inference/method_dispatcher/block_folding.rb +1 -1
  213. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +113 -12
  214. data/lib/rigor/inference/method_dispatcher/contested_overloads.rb +89 -0
  215. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +1 -1
  216. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -3
  217. data/lib/rigor/inference/method_dispatcher/no_match_fallback.rb +54 -0
  218. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +39 -27
  219. data/lib/rigor/inference/method_dispatcher/random_folding.rb +1 -1
  220. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +203 -17
  221. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +1 -1
  222. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +2 -2
  223. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +2 -2
  224. data/lib/rigor/inference/method_dispatcher/set_folding.rb +59 -24
  225. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +83 -20
  226. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +0 -1
  227. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +50 -17
  228. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -5
  229. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +4 -6
  230. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  231. data/lib/rigor/inference/method_dispatcher.rb +109 -29
  232. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  233. data/lib/rigor/inference/module_function_state.rb +23 -1
  234. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  235. data/lib/rigor/inference/mutation_rejoin.rb +240 -4
  236. data/lib/rigor/inference/mutation_widening.rb +162 -29
  237. data/lib/rigor/inference/narrowing.rb +113 -38
  238. data/lib/rigor/inference/object_mixins.rb +13 -1
  239. data/lib/rigor/inference/optimistic_origin.rb +1 -1
  240. data/lib/rigor/inference/out_buffer_mutation.rb +72 -0
  241. data/lib/rigor/inference/parameter_inference_collector.rb +5 -6
  242. data/lib/rigor/inference/pre_eval_constants.rb +1 -1
  243. data/lib/rigor/inference/project_method_ownership.rb +6 -3
  244. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  245. data/lib/rigor/inference/project_yielder.rb +122 -0
  246. data/lib/rigor/inference/receiver_alias.rb +20 -0
  247. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +3 -1
  248. data/lib/rigor/inference/refined_dispatch/own_method.rb +2 -1
  249. data/lib/rigor/inference/remover_holder_scan.rb +233 -0
  250. data/lib/rigor/inference/remover_narrowing.rb +224 -0
  251. data/lib/rigor/inference/rewrite_mutation.rb +53 -2
  252. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -1
  253. data/lib/rigor/inference/scope_indexer/constant_ivar_value.rb +93 -0
  254. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  255. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +205 -0
  256. data/lib/rigor/inference/scope_indexer/derived_param_value.rb +112 -0
  257. data/lib/rigor/inference/scope_indexer/empty_seed.rb +99 -0
  258. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +135 -0
  259. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  260. data/lib/rigor/inference/scope_indexer.rb +614 -146
  261. data/lib/rigor/inference/self_call_ivar_writes.rb +101 -17
  262. data/lib/rigor/inference/set_mutation.rb +189 -0
  263. data/lib/rigor/inference/singleton_hook_decline.rb +1 -2
  264. data/lib/rigor/inference/statement_evaluator.rb +3036 -278
  265. data/lib/rigor/inference/struct_fold_safety.rb +4 -6
  266. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  267. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  268. data/lib/rigor/inference/synthetic_method_scanner.rb +73 -14
  269. data/lib/rigor/inference/toplevel_def_census.rb +59 -0
  270. data/lib/rigor/inference/unknown_store_widening.rb +103 -4
  271. data/lib/rigor/inference/unthreaded_rebinds.rb +2 -3
  272. data/lib/rigor/inference/void_tail_summary.rb +2 -1
  273. data/lib/rigor/language_server/buffer_table.rb +9 -3
  274. data/lib/rigor/language_server/completion_provider.rb +26 -20
  275. data/lib/rigor/language_server/config_warning_announcer.rb +28 -0
  276. data/lib/rigor/language_server/diagnostic_publisher.rb +28 -9
  277. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  278. data/lib/rigor/language_server/folding_range_provider.rb +1 -2
  279. data/lib/rigor/language_server/hover_provider.rb +1 -7
  280. data/lib/rigor/language_server/hover_renderer.rb +6 -8
  281. data/lib/rigor/language_server/project_context.rb +98 -4
  282. data/lib/rigor/language_server/publish_batcher.rb +3 -4
  283. data/lib/rigor/language_server/selection_range_provider.rb +2 -4
  284. data/lib/rigor/language_server/server.rb +12 -6
  285. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  286. data/lib/rigor/lens/plugins.rb +1 -1
  287. data/lib/rigor/mcp/server.rb +82 -3
  288. data/lib/rigor/mcp.rb +1 -1
  289. data/lib/rigor/plugin/access_denied_error.rb +1 -2
  290. data/lib/rigor/plugin/base.rb +26 -11
  291. data/lib/rigor/plugin/bundled_catalog.rb +1 -12
  292. data/lib/rigor/plugin/io_boundary.rb +1 -1
  293. data/lib/rigor/plugin/loader.rb +25 -8
  294. data/lib/rigor/plugin/macro/heredoc_template.rb +7 -8
  295. data/lib/rigor/plugin/macro/trait_registry.rb +6 -9
  296. data/lib/rigor/plugin/manifest.rb +38 -16
  297. data/lib/rigor/plugin/registry.rb +41 -3
  298. data/lib/rigor/plugin/services.rb +2 -3
  299. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -0
  300. data/lib/rigor/plugin/trust_policy.rb +2 -2
  301. data/lib/rigor/plugin/type_node_resolver.rb +2 -4
  302. data/lib/rigor/plugin.rb +6 -8
  303. data/lib/rigor/plugin_gap_advisory.rb +5 -1
  304. data/lib/rigor/project_environment.rb +16 -2
  305. data/lib/rigor/protection/analysis_guard.rb +1 -1
  306. data/lib/rigor/protection/discovery_seed.rb +96 -2
  307. data/lib/rigor/protection/mutator.rb +1 -1
  308. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  309. data/lib/rigor/rbs_extended/hkt_directives.rb +6 -10
  310. data/lib/rigor/rbs_extended/reporter.rb +27 -3
  311. data/lib/rigor/rbs_extended.rb +2 -3
  312. data/lib/rigor/reflection/constant_ancestors.rb +5 -3
  313. data/lib/rigor/rooted_glob.rb +25 -2
  314. data/lib/rigor/run_scoped_slots.rb +38 -0
  315. data/lib/rigor/runtime/jit.rb +1 -1
  316. data/lib/rigor/scope/discovery_index.rb +19 -6
  317. data/lib/rigor/scope/resolution_chain.rb +3 -1
  318. data/lib/rigor/scope.rb +379 -36
  319. data/lib/rigor/sig_gen/classification.rb +4 -0
  320. data/lib/rigor/sig_gen/effect_annotation.rb +3 -3
  321. data/lib/rigor/sig_gen/generator.rb +668 -84
  322. data/lib/rigor/sig_gen/generic_header_scan.rb +189 -0
  323. data/lib/rigor/sig_gen/inline_declarations.rb +2 -2
  324. data/lib/rigor/sig_gen/layout_index.rb +3 -3
  325. data/lib/rigor/sig_gen/method_candidate.rb +23 -4
  326. data/lib/rigor/sig_gen/observation_collector.rb +7 -6
  327. data/lib/rigor/sig_gen/observed_call.rb +1 -1
  328. data/lib/rigor/sig_gen/path_mapper.rb +6 -0
  329. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  330. data/lib/rigor/sig_gen/renderer.rb +21 -12
  331. data/lib/rigor/sig_gen/skip_reason_catalog.rb +15 -2
  332. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  333. data/lib/rigor/sig_gen/type_name_qualification.rb +69 -0
  334. data/lib/rigor/sig_gen/write_reporter.rb +63 -14
  335. data/lib/rigor/sig_gen/write_result.rb +23 -12
  336. data/lib/rigor/sig_gen/writer.rb +217 -37
  337. data/lib/rigor/type/acceptance_router.rb +2 -2
  338. data/lib/rigor/type/accepts_result.rb +1 -1
  339. data/lib/rigor/type/app.rb +4 -6
  340. data/lib/rigor/type/combinator.rb +13 -16
  341. data/lib/rigor/type/constant.rb +48 -7
  342. data/lib/rigor/type/hash_shape.rb +26 -40
  343. data/lib/rigor/type/struct_instance.rb +3 -2
  344. data/lib/rigor/type/union.rb +1 -1
  345. data/lib/rigor/type_node/identifier.rb +1 -2
  346. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  347. data/lib/rigor/type_node/string_literal.rb +1 -2
  348. data/lib/rigor/type_node/symbol_literal.rb +1 -2
  349. data/lib/rigor/type_node/union.rb +3 -6
  350. data/lib/rigor/value_semantics.rb +1 -10
  351. data/lib/rigor/version.rb +1 -1
  352. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +2 -2
  353. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +2 -3
  354. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +1 -1
  355. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +89 -70
  356. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +4 -2
  357. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +25 -4
  358. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -1
  359. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -5
  360. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +0 -1
  361. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +2 -1
  362. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  363. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +79 -19
  364. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -1
  365. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +6 -3
  366. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +3 -2
  367. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +2 -2
  368. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +21 -31
  369. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +10 -16
  370. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +2 -3
  371. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +0 -1
  372. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +5 -4
  373. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +3 -4
  374. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +1 -1
  375. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +7 -12
  376. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +1 -1
  377. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +1 -1
  378. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/symbolize_keys.rb +102 -0
  379. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +19 -6
  380. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +26 -13
  381. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +3 -3
  382. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  383. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +1 -3
  384. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +6 -6
  385. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +5 -6
  386. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
  387. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +2 -3
  388. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -8
  389. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +0 -1
  390. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  391. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +10 -18
  392. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +0 -1
  393. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +0 -2
  394. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -5
  395. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +0 -2
  396. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +6 -7
  397. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +2 -4
  398. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +2 -5
  399. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -2
  400. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +6 -6
  401. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  402. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +3 -8
  403. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +2 -3
  404. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -8
  405. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +1 -3
  406. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -3
  407. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +1 -1
  408. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +4 -11
  409. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  410. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +2 -1
  411. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  412. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -2
  413. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +3 -4
  414. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +6 -8
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +5 -2
  416. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +4 -5
  417. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +6 -7
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +1 -1
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +2 -2
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +17 -21
  421. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +2 -3
  422. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +3 -2
  423. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +4 -4
  424. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  425. data/sig/rigor/analysis/baseline.rbs +0 -2
  426. data/sig/rigor/analysis/fact_store.rbs +1 -0
  427. data/sig/rigor/analysis/reachability/scan_cache.rbs +1 -1
  428. data/sig/rigor/cli/diff_command.rbs +1 -1
  429. data/sig/rigor/cli/explain_command.rbs +1 -1
  430. data/sig/rigor/effects/file_collection.rbs +5 -6
  431. data/sig/rigor/effects/registry.rbs +8 -6
  432. data/sig/rigor/effects/summary.rbs +6 -5
  433. data/sig/rigor/environment.rbs +5 -3
  434. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  435. data/sig/rigor/inference.rbs +10 -1
  436. data/sig/rigor/plugin/additional_initializer.rbs +0 -5
  437. data/sig/rigor/plugin/base.rbs +1 -0
  438. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  439. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  440. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  441. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  442. data/sig/rigor/plugin/io_boundary.rbs +6 -5
  443. data/sig/rigor/plugin/manifest.rbs +16 -18
  444. data/sig/rigor/plugin/protocol_contract.rbs +6 -9
  445. data/sig/rigor/plugin/registry.rbs +1 -1
  446. data/sig/rigor/scope.rbs +51 -50
  447. data/sig/rigor/type.rbs +0 -6
  448. data/skills/rigor-plugin-author/SKILL.md +4 -4
  449. data/skills/rigor-plugin-author/references/02-walker-and-types.md +5 -5
  450. data/skills/rigor-plugin-review/SKILL.md +3 -3
  451. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +12 -1
  452. metadata +55 -3
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "json"
4
4
 
5
+ require_relative "../cli/json_document"
6
+
5
7
  require_relative "classification"
6
8
 
7
9
  module Rigor
@@ -18,10 +20,13 @@ module Rigor
18
20
  # @param refused — methods refused as `sig.skipped.inline-differs`; the JSON payload names them under
19
21
  # `refused` (absent when there are none, so an ordinary payload is unchanged), and text mode leaves them
20
22
  # to the command's stderr `REFUSED` lines.
21
- def render_write(results:, format:, refused: [])
23
+ # @param left_bare — the generic classes whose bare headers the writer held back from the repair (#2605,
24
+ # {Writer#left_bare}); the JSON payload names them under `left_bare` (absent when there are none), and text
25
+ # mode prints one `generic header left bare` line each.
26
+ def render_write(results:, format:, refused: [], left_bare: [])
22
27
  case format
23
- when "json" then render_write_json(results, refused)
24
- when "text" then render_write_text(results)
28
+ when "json" then render_write_json(results, refused, left_bare)
29
+ when "text" then render_write_text_with_left_bare(results, left_bare)
25
30
  else raise ArgumentError, "unsupported format: #{format}"
26
31
  end
27
32
  end
@@ -32,14 +37,19 @@ module Rigor
32
37
  #
33
38
  # @param refused — the methods the generator refused to reconcile (`sig.skipped.inline-differs`):
34
39
  # `sig/` is not up to date while one stands, and `--write` cannot fix it.
35
- def render_check(results:, format:, refused: [])
40
+ #
41
+ # @param left_bare — as for {#render_write}; reported, but it does not make `sig/` out of date.
42
+ def render_check(results:, format:, refused: [], left_bare: [])
36
43
  stale = self.class.out_of_date(results)
37
44
  case format
38
45
  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)
46
+ payload = { up_to_date: stale.empty? && refused.empty?, results: stale.map { |r| check_entry(r) },
47
+ refused: refused.map(&:to_h) }
48
+ payload[:left_bare] = left_bare unless left_bare.empty?
49
+ @out.puts(JSON.pretty_generate(CLI::JsonDocument.stamp(payload)))
50
+ when "text"
51
+ render_check_text(stale, refused)
52
+ render_left_bare(left_bare)
43
53
  else raise ArgumentError, "unsupported format: #{format}"
44
54
  end
45
55
  end
@@ -89,6 +99,7 @@ module Rigor
89
99
  when :created, :updated then render_check_change(result)
90
100
  when :skipped_invalid_rbs then render_write_invalid(result)
91
101
  when :skipped_invalid_encoding then render_write_invalid_encoding(result)
102
+ when :skipped_unparseable then render_write_unparseable(result)
92
103
  end
93
104
  end
94
105
  end
@@ -103,12 +114,17 @@ module Rigor
103
114
  end
104
115
  end
105
116
 
106
- def render_write_text(results)
117
+ # "No changes" only when nothing at all is reported: a header held back is news even when no file changed.
118
+ def render_write_text_with_left_bare(results, left_bare)
107
119
  if results.all? { |r| r.action == :noop && r.skipped.empty? }
108
- @out.puts("No changes")
109
- return
120
+ @out.puts("No changes") if left_bare.empty?
121
+ else
122
+ render_write_text(results)
110
123
  end
124
+ render_left_bare(left_bare)
125
+ end
111
126
 
127
+ def render_write_text(results)
112
128
  results.each do |result|
113
129
  case result.action
114
130
  when :created then render_write_created(result)
@@ -117,6 +133,7 @@ module Rigor
117
133
  when :skipped_outside_sig_root then render_write_skipped(result)
118
134
  when :skipped_invalid_rbs then render_write_invalid(result)
119
135
  when :skipped_invalid_encoding then render_write_invalid_encoding(result)
136
+ when :skipped_unparseable then render_write_unparseable(result)
120
137
  end
121
138
  end
122
139
  end
@@ -125,7 +142,29 @@ module Rigor
125
142
  # because "added 2" when one of them rewrote a line the project already had would understate the change.
126
143
  def applied_counts(result)
127
144
  added = "+#{result.applied.size - result.replaced.size}"
128
- result.replaced.empty? ? added : "#{added}, replaced #{result.replaced.size}"
145
+ added = "#{added}, replaced #{result.replaced.size}" unless result.replaced.empty?
146
+ repaired = result.repaired_headers
147
+ repaired.empty? ? added : "#{added}, repaired #{repaired.size} generic header(s): #{repaired.join(', ')}"
148
+ end
149
+
150
+ # #2605 — one line per generic class the writer left with a bare header: every file holding one, and the
151
+ # parameter names the blocking bodies spell. rbs keeps rejecting the class until those names are anchored
152
+ # (`::E`) or the headers are given parameters by hand.
153
+ def render_left_bare(left_bare)
154
+ left_bare.each do |entry|
155
+ reasons = entry.fetch(:blocking).map { |blocking| left_bare_reason(blocking) }
156
+ @out.puts("generic header left bare: #{entry.fetch(:class)} in #{entry.fetch(:targets).join(', ')} " \
157
+ "(#{reasons.join('; ')})")
158
+ end
159
+ end
160
+
161
+ def left_bare_reason(blocking)
162
+ if blocking.fetch(:reason) == "outside_sig_root"
163
+ "an opening outside the sig root at #{blocking.fetch(:target)}, which --write does not rewrite"
164
+ else
165
+ "#{blocking.fetch(:target)} names #{blocking.fetch(:names).join(', ')}, which the type parameter would " \
166
+ "capture"
167
+ end
129
168
  end
130
169
 
131
170
  def render_write_created(result)
@@ -193,10 +232,20 @@ module Rigor
193
232
  @out.puts(" Re-save the file as UTF-8 and re-run; sig-gen never modifies a file it cannot read faithfully.")
194
233
  end
195
234
 
196
- def render_write_json(results, refused)
235
+ # The EXISTING target file does not parse as RBS, so the writer will not merge into it (#2480). Like the
236
+ # encoding refusal the fix is the user's: the file is quarantined whole by `rigor check` until it parses.
237
+ def render_write_unparseable(result)
238
+ @out.puts("REFUSED #{result.target_path} — the existing file was not updated")
239
+ @out.puts(" #{result.error}")
240
+ @out.puts(" Fix the syntax error and re-run; sig-gen never modifies a file it cannot parse.")
241
+ end
242
+
243
+ def render_write_json(results, refused, left_bare)
197
244
  payload = { results: results.map(&:to_h) }
198
245
  payload[:refused] = refused.map(&:to_h) unless refused.empty?
199
- @out.puts(JSON.pretty_generate(payload))
246
+ # Absent when every bare generic header was repaired, so an ordinary payload is unchanged (#2605).
247
+ payload[:left_bare] = left_bare unless left_bare.empty?
248
+ @out.puts(JSON.pretty_generate(CLI::JsonDocument.stamp(payload)))
200
249
  end
201
250
  end
202
251
  end
@@ -11,7 +11,7 @@ module Rigor
11
11
  # - `target_path` — `.rbs` file the writer was responsible for (`nil` when the source path falls outside
12
12
  # the project signature tree, in which case `action` is `:skipped_outside_sig_root`).
13
13
  # - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root` /
14
- # `:skipped_invalid_rbs` / `:skipped_invalid_encoding`.
14
+ # `:skipped_invalid_rbs` / `:skipped_invalid_encoding` / `:skipped_unparseable`.
15
15
  # - `applied` — the {MethodCandidate}s that actually landed on disk.
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).
@@ -23,16 +23,20 @@ module Rigor
23
23
  # target declaration already carries annotations, so its bytes were left alone
24
24
  # (`sig.effect.left-unreadable`; see {Writer#splice_annotations}). The signature line itself was
25
25
  # still updated.
26
+ # - `repaired_headers` — the existing class or module headers the writer gave a generic class's type
27
+ # parameters (`"Array[unchecked out E]"`), because the file opened it with none (#2580).
26
28
  # - `error` — the refusal cause, when `action` is a refusal: for `:skipped_invalid_rbs` the file the writer
27
29
  # assembled does not parse, so it was NOT written (writing it would poison the project's sig tree — the
28
30
  # consumer quarantines an unparseable `.rbs`, taking every other type in that file down with it); for
29
31
  # `:skipped_invalid_encoding` the EXISTING target file is not valid UTF-8, so the writer refuses to merge
30
- # into content it cannot read faithfully.
32
+ # into content it cannot read faithfully; for `:skipped_unparseable` the EXISTING target file does not parse
33
+ # as RBS, so the writer refuses to merge into it (#2480).
31
34
  class WriteResult
32
- attr_reader :source_path, :target_path, :action, :applied, :skipped, :error, :left_unreadable, :replaced
35
+ attr_reader :source_path, :target_path, :action, :applied, :skipped, :error, :left_unreadable, :replaced,
36
+ :repaired_headers
33
37
 
34
38
  def initialize(source_path:, target_path:, action:, applied: [], skipped: [], error: nil, # rubocop:disable Metrics/ParameterLists
35
- left_unreadable: [], replaced: [])
39
+ left_unreadable: [], replaced: [], repaired_headers: [])
36
40
  @source_path = source_path
37
41
  @target_path = target_path
38
42
  @action = action
@@ -41,6 +45,7 @@ module Rigor
41
45
  @error = error
42
46
  @left_unreadable = left_unreadable.freeze
43
47
  @replaced = replaced.freeze
48
+ @repaired_headers = repaired_headers.freeze
44
49
  freeze
45
50
  end
46
51
 
@@ -51,14 +56,20 @@ module Rigor
51
56
  action: action.to_s,
52
57
  applied: applied.map(&:to_h),
53
58
  skipped: skipped.map { |c, reason| c.to_h.merge(write_skip_reason: reason.to_s) }
54
- }.tap do |h|
55
- h[:error] = error if error
56
- # Absent when nothing was replaced, so a run that only adds lines keeps its pre-#1076 payload.
57
- h[:replaced] = replaced.map(&:to_h) unless replaced.empty?
58
- # Absent rather than empty when nothing was left: an effects-off payload stays byte-identical
59
- # to a pre-#391 one.
60
- h[:effect_left_unreadable] = left_unreadable.map(&:to_h) unless left_unreadable.empty?
61
- end
59
+ }.tap { |h| add_optional_entries(h) }
60
+ end
61
+
62
+ private
63
+
64
+ def add_optional_entries(hash)
65
+ hash[:error] = error if error
66
+ # Absent when nothing was replaced, so a run that only adds lines keeps its pre-#1076 payload.
67
+ hash[:replaced] = replaced.map(&:to_h) unless replaced.empty?
68
+ # Absent when no header was repaired (#2580), for the same reason.
69
+ hash[:repaired_headers] = repaired_headers unless repaired_headers.empty?
70
+ # Absent rather than empty when nothing was left: an effects-off payload stays byte-identical
71
+ # to a pre-#391 one.
72
+ hash[:effect_left_unreadable] = left_unreadable.map(&:to_h) unless left_unreadable.empty?
62
73
  end
63
74
  end
64
75
  end
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "rbs"
5
5
 
6
6
  require_relative "classification"
7
+ require_relative "generic_header_scan"
7
8
  require_relative "layout_index"
8
9
  require_relative "superclass_spelling"
9
10
  require_relative "write_result"
@@ -15,7 +16,8 @@ module Rigor
15
16
  #
16
17
  # ADR-14 slice 2: the writer parses the target with `RBS::Parser` to find the matching class declaration and
17
18
  # inserts new method declarations just before the class's closing `end` keyword. Existing declarations are
18
- # NEVER touched unless `--overwrite` is set AND the candidate's classification is `tighter-return`.
19
+ # NEVER touched unless `--overwrite` is set and the candidate is eligible for replacement (see
20
+ # {#eligible_for_replacement?}).
19
21
  #
20
22
  # The slice does NOT re-render the whole file through `RBS::Writer`. That would lose comments / blank-line
21
23
  # formatting per upstream design; the byte-range insertion approach taken here preserves untouched
@@ -32,9 +34,18 @@ module Rigor
32
34
 
33
35
  # Per-`update_existing` accumulator. The merge_class helper mutates `source` / `decls` / `applied` /
34
36
  # `skipped` in place as each class is processed so the next class sees the latest byte positions.
35
- MergeState = Struct.new(:source, :decls, :applied, :skipped, :left_unreadable, :replaced, keyword_init: true)
37
+ MergeState = Struct.new(:source, :decls, :applied, :skipped, :left_unreadable, :replaced, :repaired_headers,
38
+ :target, keyword_init: true)
36
39
  private_constant :MergeState
37
40
 
41
+ # The target routing this writer applies; the generator maps candidates through it to ask {#accepts_target?}.
42
+ attr_reader :path_mapper
43
+
44
+ # #2605 — the generic classes whose bare headers the last {#write_all} (or {#write}) left bare, because a
45
+ # body names one of the parameters: `{ class:, targets:, blocking: [{ target:, names: }] }` each
46
+ # ({GenericHeaderScan#left_bare}).
47
+ attr_reader :left_bare
48
+
38
49
  # @param dry_run — assemble and validate every target exactly as a write would, and report it, without
39
50
  # creating or changing a file. `sig-gen --check` (ADR-112 WD4) is this, so it can only ever disagree
40
51
  # with `--write` about the disk, never about the content.
@@ -47,6 +58,15 @@ module Rigor
47
58
  @global_namespace_kinds = {}
48
59
  # Run-level qualified-class-name => superclass-token view, same lifecycle as `@global_namespace_kinds`.
49
60
  @global_superclasses = {}
61
+ # #2436 — the target being rendered's view of {MethodCandidate#class_type_params}, set per target.
62
+ @type_params = {}
63
+ # #2603/#2605 — the run-wide repair decision {#write_all} reads from the sig root before writing: the
64
+ # classes cleared for repair with their spelling, and the classes held back. {#write} leaves both empty and
65
+ # decides per file.
66
+ @scan_repairs = {}
67
+ @held_back = []
68
+ @left_bare = []
69
+ @repair_targets = []
50
70
  end
51
71
 
52
72
  # Process the full candidate list by resolving each candidate's target sig file via the path mapper (which
@@ -56,31 +76,94 @@ module Rigor
56
76
  # ADR-14 follow-up: this is the consolidated-layout entry point. The legacy `write(source_path,
57
77
  # candidates)` below assumes all candidates share a target and remains for spec convenience.
58
78
  #
79
+ # @param header_spelling — called with a class name, returns the header spelling of a generic class the run
80
+ # does not stamp (#2603: a bare header in a file no candidate routes to); the generator's
81
+ # {Generator#generic_header_spelling}. Without it only the run's stamp counts.
59
82
  # @return one per target sig file.
60
- def write_all(candidates)
83
+ def write_all(candidates, header_spelling: nil)
61
84
  emittable = candidates.select { |c| EMITTABLE.include?(c.classification) }
62
- return [] if emittable.empty?
85
+ scan_generic_headers(candidates, header_spelling)
63
86
 
64
87
  @global_namespace_kinds = build_namespace_kinds(candidates)
65
88
  @global_superclasses = build_superclasses(candidates)
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)
89
+ grouped = emittable.group_by { |c| target_of(c) }
90
+ results = grouped.map { |target, group| write_target(target, group) }
91
+ results + repair_only_results(candidates, grouped.keys.map { |target| target.expand_path.to_s })
92
+ end
93
+
94
+ def target_of(candidate)
95
+ @path_mapper.target_for(candidate.path, class_name: candidate.class_name,
96
+ method_name: candidate.method_name, kind: candidate.rbs_kind)
97
+ end
98
+ private :target_of
99
+
100
+ # Issues #2603 and #2605 — reads every file under the signature paths for bare openings of generic classes
101
+ # before anything is written ({GenericHeaderScan}). A class is repaired in every file or in none: one bare
102
+ # opening rbs still rejects keeps the class failing, so repairing its other openings would be churn. Only files
103
+ # under the sig root are rewritten; a bare opening anywhere else holds its class back.
104
+ def scan_generic_headers(candidates, header_spelling)
105
+ stamp = merged_type_params(candidates)
106
+ scan = GenericHeaderScan.scan(sig_root: @path_mapper.sig_root_dir, signature_dirs: @path_mapper.signature_dirs,
107
+ spelling_for: ->(name) { stamp[name] || header_spelling&.call(name) })
108
+ @scan_repairs = scan.repairs
109
+ @held_back = scan.left_bare.map { |entry| entry.fetch(:class) }
110
+ @left_bare = scan.left_bare
111
+ @repair_targets = scan.repair_targets
112
+ end
113
+ private :scan_generic_headers
114
+
115
+ # The files with a bare header to repair that no emitted member routes to: each gets the repair and nothing
116
+ # else (#2580), whether a skipped candidate routes to it or none does (#2603). The row names a candidate's
117
+ # source when one routes there. A file the writer would not open (not UTF-8, unparseable) is never listed.
118
+ def repair_only_results(candidates, written)
119
+ sources = candidates.each_with_object({}) do |candidate, acc|
120
+ acc[target_of(candidate).expand_path.to_s] ||= candidate.path if candidate.path
121
+ end
122
+ @repair_targets.filter_map do |path|
123
+ target = Pathname(path)
124
+ next if written.include?(target.expand_path.to_s) || !inside_sig_root?(target)
125
+
126
+ result = update_existing(sources[target.expand_path.to_s], target, [], repair_only: true)
127
+ result if %i[updated skipped_invalid_rbs].include?(result.action)
68
128
  end
69
- grouped.map { |target, group| write_target(target, group) }
70
129
  end
130
+ private :repair_only_results
71
131
 
72
- # @param candidates — only
73
- # emittable classifications (new-method /
74
- # tighter-return) are honoured; the caller is
75
- # responsible for filtering.
132
+ # @param candidates — only emittable classifications are honoured; `write` filters on `EMITTABLE` itself.
76
133
  def write(source_path, candidates)
77
134
  emittable = candidates.select { |c| EMITTABLE.include?(c.classification) }
78
135
  return WriteResult.new(source_path: source_path, target_path: nil, action: :noop) if emittable.empty?
79
136
 
137
+ @scan_repairs = {}
138
+ @held_back = []
139
+ @left_bare = []
80
140
  target = @path_mapper.target_for(source_path, class_name: emittable.first.class_name)
81
141
  write_target(target, emittable, source_path: source_path)
82
142
  end
83
143
 
144
+ # Whether the writer is sure to write `candidate`. A file the writer writes nothing to is left as it is, so a
145
+ # class shell riding on that file's candidates never reaches disk; the generator asks this before it counts
146
+ # a shell as declared (#2454). Only a new method is sure: without `--overwrite` the writer skips a tighter
147
+ # return or an inline overwrite whose method the target already declares. Counting too few only demotes a
148
+ # subclass, which is the output before #2454.
149
+ def self.writes?(candidate)
150
+ candidate.classification == Classification::NEW_METHOD
151
+ end
152
+
153
+ # Whether {#write_target} would write to `target` at all, asked before any candidate is rendered (#2461): a
154
+ # target inside the sig root that is missing (the create path), or that exists, is valid UTF-8 and parses.
155
+ # {#update_existing} refuses an unparseable or mis-encoded file and leaves it untouched, so a class shell on it
156
+ # never reaches disk. A spliced result {RbsValidity} rejects is a writer defect and is not predicted here.
157
+ def accepts_target?(target)
158
+ return false unless inside_sig_root?(target)
159
+ return true unless target.exist?
160
+
161
+ source = target.read
162
+ source.valid_encoding? && !parse_signature(source).nil?
163
+ rescue SystemCallError
164
+ false
165
+ end
166
+
84
167
  # Whether `proposed` has STRICTLY FEWER bare `untyped` tokens than `existing`. A count, not a slot-by-slot
85
168
  # comparison: it reads as "at least one more position is concrete", and does not prove that no concrete
86
169
  # position became `untyped` elsewhere. Word-boundary matching counts `untyped` only as a type token, not
@@ -157,10 +240,11 @@ module Rigor
157
240
  tree = build_namespace_tree(candidates, shells)
158
241
  kinds = merged_namespace_kinds(candidates)
159
242
  supers = merged_superclasses(candidates)
243
+ @type_params = merged_type_params(candidates)
160
244
  render_tree_nodes(tree, kinds, supers, 0)
161
245
  end
162
246
 
163
- # Drains `class_shells` from every candidate; the generator's walker populates the same set on every
247
+ # Unions `class_shells` across every candidate; the generator's walker populates the same set on every
164
248
  # candidate produced from a given file (gap-#3 (e)).
165
249
  def collect_class_shells(candidates)
166
250
  shells = Set.new
@@ -202,6 +286,13 @@ module Rigor
202
286
  merged
203
287
  end
204
288
 
289
+ # #2436 — every candidate carries the run's map, so folding the target's candidates is the whole view.
290
+ def merged_type_params(candidates)
291
+ candidates.each_with_object({}) do |c, acc|
292
+ acc.merge!(c.class_type_params) if c.respond_to?(:class_type_params)
293
+ end
294
+ end
295
+
205
296
  def build_superclasses(candidates)
206
297
  candidates.each_with_object({}) do |candidate, acc|
207
298
  next unless candidate.respond_to?(:class_superclasses)
@@ -257,7 +348,7 @@ module Rigor
257
348
  indent = INDENT * depth
258
349
  qualified = (prefix + [node[:name]]).join("::")
259
350
  keyword = node_keyword(node, kinds, qualified)
260
- header = "#{keyword} #{node[:name]}#{superclass_suffix(keyword, supers, qualified)}"
351
+ header = "#{keyword} #{node[:name]}#{@type_params[qualified]}#{superclass_suffix(keyword, supers, qualified)}"
261
352
  body = render_tree_node_body(node, kinds, supers, depth, prefix)
262
353
  "#{indent}#{header}\n#{body}#{indent}end\n"
263
354
  end
@@ -293,13 +384,12 @@ module Rigor
293
384
  :module
294
385
  end
295
386
 
296
- def update_existing(source_path, target, candidates)
387
+ def update_existing(source_path, target, candidates, repair_only: false)
297
388
  source = target.read
298
389
  # Pre-parser encoding guard, mirroring `Environment::RbsLoader.invalid_encoding?`: handed invalid
299
390
  # UTF-8, `RBS::Parser` raises a bare `ArgumentError` on rbs 4.1 (not the `ParsingError` that
300
391
  # `parse_signature` below rescues) and could hang outright on the older releases the gemspec
301
- # supports. The refusal is loud — unlike an unparseable file (`:noop`, surfaced by `rigor check`'s
302
- # own quarantine warning), a mojibake file would otherwise fail with a stack trace naming neither
392
+ # supports. The refusal is loud — a mojibake file would otherwise fail with a stack trace naming neither
303
393
  # the file nor the fix.
304
394
  unless source.valid_encoding?
305
395
  return WriteResult.new(source_path: source_path, target_path: target,
@@ -307,16 +397,19 @@ module Rigor
307
397
  error: "#{target} is not valid UTF-8")
308
398
  end
309
399
 
310
- decls = parse_signature(source)
311
- return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
312
-
313
- state = MergeState.new(source: source, decls: decls, applied: [], skipped: [], left_unreadable: [],
314
- replaced: [])
315
- merge_candidates(state, candidates)
400
+ # An existing file that does not parse is refused like a mis-encoded one, not skipped as `:noop` (#2480):
401
+ # `--check` must fail on it, because a `--write` that cannot touch the file is not an up-to-date `sig/`.
402
+ # `rigor check` quarantines such a file whole; this refusal names it at the sig-gen boundary too.
403
+ decls, parse_detail = parse_target_signature(target, source)
404
+ if decls.nil?
405
+ return WriteResult.new(source_path: source_path, target_path: target,
406
+ action: :skipped_unparseable,
407
+ error: "#{target} does not parse as RBS: #{parse_detail}")
408
+ end
316
409
 
317
- action = state.applied.empty? ? :noop : :updated
318
- unless action == :updated
319
- return WriteResult.new(source_path: source_path, target_path: target, action: action,
410
+ state = merged_state(source, decls, candidates, repair_only, target)
411
+ unless state_changed?(state)
412
+ return WriteResult.new(source_path: source_path, target_path: target, action: :noop,
320
413
  applied: state.applied, skipped: state.skipped,
321
414
  left_unreadable: state.left_unreadable)
322
415
  end
@@ -333,8 +426,9 @@ module Rigor
333
426
 
334
427
  target.write(state.source) unless @dry_run
335
428
  WriteResult.new(source_path: source_path, target_path: target,
336
- action: action, applied: state.applied, skipped: state.skipped,
337
- left_unreadable: state.left_unreadable, replaced: state.replaced)
429
+ action: :updated, applied: state.applied, skipped: state.skipped,
430
+ left_unreadable: state.left_unreadable, replaced: state.replaced,
431
+ repaired_headers: state.repaired_headers)
338
432
  end
339
433
 
340
434
  # Applies every class group, then every requested shell, then normalises the layout the three steps
@@ -343,12 +437,89 @@ module Rigor
343
437
  supers = merged_superclasses(candidates)
344
438
  kinds = merged_namespace_kinds(candidates)
345
439
  shells = collect_class_shells(candidates)
440
+ @type_params = merged_type_params(candidates)
346
441
  groups = candidates.group_by(&:class_name)
347
442
  # Ancestors first, so `Foo` is created (or found) before `Foo::Bar` looks for a parent to nest under.
348
443
  # A plain sort suffices: a name always sorts before every name it is a strict prefix of.
349
444
  groups.keys.sort.each { |name| merge_class(state, name, groups.fetch(name), kinds, supers) }
350
445
  merge_class_shells(state, shells, kinds, supers)
351
446
  collapse_nested_declarations(state, groups.keys + shells.to_a)
447
+ # Last, so the members this run merged count towards {#shadowed_after_merge?}.
448
+ repair_generic_headers(state, state.target)
449
+ end
450
+
451
+ # Issue #2580 — a declaration this file already holds for a class the loaded RBS declares generic, opened
452
+ # with no type parameters at all (`class Array`, as sig-gen wrote it before #2436), is a guaranteed
453
+ # `RBS::GenericParameterMismatchError`: rbs requires every declaration of a class to repeat the first one's
454
+ # parameters, and the class, plus every class whose ancestry reaches it (`Object` through `Kernel`), then
455
+ # fails its definition build. Writing more members into that header would keep the file broken, so the
456
+ # header gets the same spelling a new header would ({Generator#stamp_generic_reopenings}: plain
457
+ # parameters only). A header that spells parameters of its own is the project's and is left alone, as is
458
+ # every class neither the run's stamp nor {#write_all}'s scan names.
459
+ #
460
+ # A declaration whose body names a type like one of the parameters (`def old: () -> E` for a project class
461
+ # `E`) is left bare: under `class Array[unchecked out E]` that `E` would bind to the parameter, silently
462
+ # changing what the member says. The body is matched as text, so a comment or a nested declaration that
463
+ # spells the name also keeps the header bare, which is the state the file was already in; a method type that
464
+ # declares the name as its own parameter (`def m: [E] (E) -> E`) does not (#2605).
465
+ #
466
+ # Under {#write_all} the class is repaired in every file under the sig root or in none ({GenericHeaderScan},
467
+ # #2605): a class held back there is not repaired here, and a class the run does not stamp is repaired with the
468
+ # scan's spelling (#2603). The check below runs again after the merge, so a member this run merged counts
469
+ # too; the generator anchors a member's type that a parameter would capture (#2436), so it holds back a class
470
+ # the scan cleared only for a member written without that anchor, and is then reported in {#left_bare}.
471
+ # `repaired_headers` gets one entry per declaration repaired.
472
+ def repair_generic_headers(state, target)
473
+ spellings = @scan_repairs.merge(@type_params).except(*@held_back)
474
+ spellings.keys.sort.each do |name|
475
+ spelling = spellings.fetch(name)
476
+ bare = find_class_decls(state.decls, name).select { |d| d.type_params.empty? && d.location&.[](:name) }
477
+ next if bare.empty? || shadowed_after_merge?(state.source, bare, name, spelling, target)
478
+
479
+ insert_type_params(state, bare, spelling)
480
+ bare.each { state.repaired_headers << "#{name}#{spelling}" }
481
+ end
482
+ end
483
+
484
+ # Whether any of `bare` names one of the parameters `spelling` declares ({GenericHeaderScan.shadowing_names});
485
+ # each such opening is recorded in {#left_bare}.
486
+ def shadowed_after_merge?(source, bare, name, spelling, target)
487
+ params = GenericHeaderScan.parameter_names(spelling)
488
+ return true if params.nil?
489
+
490
+ blocking = bare.filter_map do |decl|
491
+ names = GenericHeaderScan.shadowing_names(source, decl, params)
492
+ { target: target.to_s, reason: "names_parameter", names: names } unless names.empty?
493
+ end
494
+ return false if blocking.empty?
495
+
496
+ @left_bare << { class: name, targets: [target.to_s], blocking: blocking }
497
+ true
498
+ end
499
+
500
+ # Splices `spelling` right after each declaration's name, last one first so earlier offsets stay valid.
501
+ def insert_type_params(state, decls, spelling)
502
+ decls.map { |d| d.location[:name].end_pos }.sort.reverse_each do |pos|
503
+ state.source = state.source[0...pos] + spelling + state.source[pos..]
504
+ end
505
+ state.decls = parse_signature(state.source) || state.decls
506
+ end
507
+
508
+ def merged_state(source, decls, candidates, repair_only, target)
509
+ state = MergeState.new(source: source, decls: decls, applied: [], skipped: [], left_unreadable: [],
510
+ replaced: [], repaired_headers: [], target: target)
511
+ repair_only ? repair_only_candidates(state, candidates) : merge_candidates(state, candidates)
512
+ state
513
+ end
514
+
515
+ def state_changed?(state)
516
+ !(state.applied.empty? && state.repaired_headers.empty?)
517
+ end
518
+
519
+ # {#repair_only_results}' merge step: the run's stamp, and the header repair alone.
520
+ def repair_only_candidates(state, candidates)
521
+ @type_params = merged_type_params(candidates)
522
+ repair_generic_headers(state, state.target)
352
523
  end
353
524
 
354
525
  # ADR-14 gap-#3 (e): for every requested class shell that isn't already declared in the target file,
@@ -401,7 +572,9 @@ module Rigor
401
572
  parent_name = segments[0...-1].join("::")
402
573
  parent = find_class_decl(state.decls, parent_name)
403
574
  decl = parent && find_class_decl(state.decls, qualified)
404
- return if decl.nil? || overlapping?(parent, decl)
575
+ # A rooted declaration (`module A; class ::B::C`) keeps the layout the user wrote: cutting it out of its
576
+ # enclosing module would leave that module empty, so the member merges in place instead.
577
+ return if decl.nil? || overlapping?(parent, decl) || decl.name.to_s.start_with?("::")
405
578
 
406
579
  region = decl_region(state.source, decl)
407
580
  block = region && relocated_block(state.source, decl, parent, segments.last, region)
@@ -560,6 +733,15 @@ module Rigor
560
733
  nil
561
734
  end
562
735
 
736
+ # Parses an EXISTING target, naming it as the buffer so the parse error carries `path:line:col` (a bare
737
+ # String would be named `a.rbs`). Returns `[decls, nil]`, or `[nil, first_line_of_message]` on a parse error.
738
+ def parse_target_signature(target, source)
739
+ _, _, decls = RBS::Parser.parse_signature(RBS::Buffer.new(name: target.to_s, content: source))
740
+ [decls, nil]
741
+ rescue RBS::ParsingError => e
742
+ [nil, e.message.to_s.lines.first.to_s.strip]
743
+ end
744
+
563
745
  def merge_class(state, class_name, methods, kinds, supers)
564
746
  decl = find_class_decl(state.decls, class_name)
565
747
  if decl.nil?
@@ -586,11 +768,10 @@ module Rigor
586
768
  decls.each do |decl|
587
769
  next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
588
770
 
589
- local = decl.name.to_s.sub(/\A::/, "")
590
- full = prefix.empty? ? local : "#{prefix.join('::')}::#{local}"
591
- return decl if full == qualified_name
771
+ path = ::Rigor::Environment::RbsLoader.declared_class_path(decl, prefix)
772
+ return decl if path.join("::") == qualified_name
592
773
 
593
- nested = find_class_decl_in(decl.members, prefix + [local], qualified_name)
774
+ nested = find_class_decl_in(decl.members, path, qualified_name)
594
775
  return nested if nested
595
776
  end
596
777
  nil
@@ -602,10 +783,9 @@ module Rigor
602
783
  decls.each do |decl|
603
784
  next unless decl.is_a?(RBS::AST::Declarations::Class) || decl.is_a?(RBS::AST::Declarations::Module)
604
785
 
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)
786
+ path = ::Rigor::Environment::RbsLoader.declared_class_path(decl, prefix)
787
+ found << decl if path.join("::") == qualified_name
788
+ find_class_decls(decl.members, qualified_name, path, found)
609
789
  end
610
790
  found
611
791
  end
@@ -669,7 +849,7 @@ module Rigor
669
849
  # name, slices out the old declaration's source range and substitutes the new RBS one-liner. Members that
670
850
  # are not `MethodDefinition`s are left alone.
671
851
  #
672
- # Two candidate classifications are eligible for replacement under `--overwrite`:
852
+ # Under `--overwrite`, an inline overwrite is always eligible, and so are two other classifications:
673
853
  #
674
854
  # 1. `TIGHTER_RETURN` — the classifier already proved the new return type is a strict subtype of the
675
855
  # declared one (with lenience guards passed).
@@ -6,8 +6,8 @@ module Rigor
6
6
  #
7
7
  # Every carrier's acceptance check is the same fixed forwarding on `self` —
8
8
  # `Inference::Acceptance.accepts(self, other, mode: mode)` — so it lived as an identical copy in
9
- # fourteen carriers. The one exception is `Type::App`, which forwards on its reduced `bound` type rather
10
- # than `self`; it keeps its own `accepts` and does not include this.
9
+ # every carrier that includes it. The one exception is `Type::App`, which forwards on its reduced `bound`
10
+ # type rather than `self`; it keeps its own `accepts` and does not include this.
11
11
  module AcceptanceRouter
12
12
  def accepts(other, mode: :gradual)
13
13
  Inference::Acceptance.accepts(self, other, mode: mode)
@@ -9,7 +9,7 @@ module Rigor
9
9
  # answer alongside the boundary mode the answer was computed under and an ordered list of textual
10
10
  # reasons describing which rules fired.
11
11
  #
12
- # AcceptsResult is the dual of `SubtypeResult` (Slice 5+). Acceptance answers "is `other` passable to
12
+ # AcceptsResult is the dual of the planned `SubtypeResult`. Acceptance answers "is `other` passable to
13
13
  # `self` at a method-parameter or assignment boundary?", consulting the gradual-typing rules in
14
14
  # docs/type-specification/value-lattice.md when `mode` is `:gradual`, and the strict subset relation
15
15
  # when `mode` is `:strict`. Phase 2c ships full `:gradual` semantics; `:strict` is reserved for later
@@ -17,13 +17,11 @@ module Rigor
17
17
  # (use a plain type alias instead).
18
18
  #
19
19
  # `bound` is a `Rigor::Type` representing the value Rigor MUST erase to when this `App` cannot be
20
- # reduced — registered at `%a{rigor:v1:hkt_register}` time, defaulting to
21
- # `Rigor::Type::Dynamic[Rigor::Type::Top]` per ADR-20 D5 / WD2. It also drives the lattice probes
22
- # (`top` / `bot` / `dynamic`) and the acceptance fallback while no reduction is wired (Slice 1).
20
+ # reduced — registered at `%a{rigor:v1:hkt_register}` time, defaulting to the untyped top per ADR-20 D5 /
21
+ # WD2. It also drives the lattice probes (`top` / `bot` / `dynamic`) and `accepts`.
23
22
  #
24
- # Slice 1 ships the carrier as **opaque**: every operation delegates to `bound` since no reduction
25
- # surface exists yet. Slice 2 introduces the conditional / indexed-access evaluator that reduces `App`
26
- # to its registered body before delegating; the carrier shape stays identical.
23
+ # The carrier is opaque: those operations delegate to `bound`, and {#reduce} is the one path that unfolds
24
+ # the application to its registered body first (ADR-20 Slice 2a).
27
25
  #
28
26
  # Display form per ADR-20 OQ5: bare RBS-style `uri[arg1, arg2]`, not the wrapped `App[uri, [arg1,
29
27
  # arg2]]` faithful form. Two `App` values are structurally equal iff their `uri`, `args`, AND `bound`