rigortype 0.3.9 → 0.4.0

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 (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -31,12 +31,16 @@ module Rigor
31
31
 
32
32
  # Per-`update_existing` accumulator. The merge_class helper mutates `source` / `decls` / `applied` /
33
33
  # `skipped` in place as each class is processed so the next class sees the latest byte positions.
34
- MergeState = Struct.new(:source, :decls, :applied, :skipped, keyword_init: true)
34
+ MergeState = Struct.new(:source, :decls, :applied, :skipped, :left_unreadable, :replaced, keyword_init: true)
35
35
  private_constant :MergeState
36
36
 
37
- def initialize(path_mapper:, overwrite: false)
37
+ # @param dry_run — assemble and validate every target exactly as a write would, and report it, without
38
+ # creating or changing a file. `sig-gen --check` (ADR-112 WD4) is this, so it can only ever disagree
39
+ # with `--write` about the disk, never about the content.
40
+ def initialize(path_mapper:, overwrite: false, dry_run: false)
38
41
  @path_mapper = path_mapper
39
42
  @overwrite = overwrite
43
+ @dry_run = dry_run
40
44
  # Run-level (cross-file) namespace-kind view, populated per `#write_all` from every candidate's per-file
41
45
  # map. Empty until then so the single-target `#write` path falls back to per-candidate kinds only.
42
46
  @global_namespace_kinds = {}
@@ -74,6 +78,15 @@ module Rigor
74
78
  write_target(target, emittable, source_path: source_path)
75
79
  end
76
80
 
81
+ # Whether `proposed` has STRICTLY FEWER bare `untyped` tokens than `existing`. A count, not a slot-by-slot
82
+ # comparison: it reads as "at least one more position is concrete", and does not prove that no concrete
83
+ # position became `untyped` elsewhere. Word-boundary matching counts `untyped` only as a type token, not
84
+ # as a substring inside identifiers. Public because the generator asks the same question before it lists a
85
+ # declared `initialize` at all (#1436).
86
+ def self.fewer_untyped?(proposed, existing)
87
+ proposed.scan(/\buntyped\b/).size < existing.scan(/\buntyped\b/).size
88
+ end
89
+
77
90
  private
78
91
 
79
92
  # Shared per-target write path used by both `#write` and `#write_all`. Picks a representative
@@ -91,7 +104,7 @@ module Rigor
91
104
  # Deliberately NARROWER than {Classification::EMITTABLE}: the write path has never produced a
92
105
  # `:new_file` row (the generator does not classify one), and admitting it here would let a
93
106
  # classification with no writer behind it decide that a target file must be created.
94
- EMITTABLE = [Classification::NEW_METHOD, Classification::TIGHTER_RETURN].freeze
107
+ EMITTABLE = [Classification::NEW_METHOD, Classification::TIGHTER_RETURN, Classification::INLINE_OVERWRITE].freeze
95
108
  private_constant :EMITTABLE
96
109
 
97
110
  def inside_sig_root?(target)
@@ -116,8 +129,10 @@ module Rigor
116
129
  action: :skipped_invalid_rbs, error: error)
117
130
  end
118
131
 
119
- FileUtils.mkdir_p(target.dirname)
120
- target.write(content)
132
+ unless @dry_run
133
+ FileUtils.mkdir_p(target.dirname)
134
+ target.write(content)
135
+ end
121
136
  WriteResult.new(source_path: source_path, target_path: target,
122
137
  action: :created, applied: candidates)
123
138
  end
@@ -245,7 +260,7 @@ module Rigor
245
260
 
246
261
  def render_tree_node_body(node, kinds, supers, depth, prefix)
247
262
  inner_indent = INDENT * (depth + 1)
248
- method_lines = node[:methods].map { |c| "#{inner_indent}#{c.rbs}\n" }.join
263
+ method_lines = node[:methods].flat_map { |c| c.rbs_lines.map { |line| "#{inner_indent}#{line}\n" } }.join
249
264
  child_blocks = node[:children].values.map do |child|
250
265
  render_tree_node(child, kinds, supers, depth + 1, prefix + [node[:name]])
251
266
  end.join
@@ -291,13 +306,15 @@ module Rigor
291
306
  decls = parse_signature(source)
292
307
  return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
293
308
 
294
- state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
309
+ state = MergeState.new(source: source, decls: decls, applied: [], skipped: [], left_unreadable: [],
310
+ replaced: [])
295
311
  merge_candidates(state, candidates)
296
312
 
297
313
  action = state.applied.empty? ? :noop : :updated
298
314
  unless action == :updated
299
315
  return WriteResult.new(source_path: source_path, target_path: target, action: action,
300
- applied: state.applied, skipped: state.skipped)
316
+ applied: state.applied, skipped: state.skipped,
317
+ left_unreadable: state.left_unreadable)
301
318
  end
302
319
 
303
320
  # The merge splices text into an existing file by byte offset, so a bug here can produce a file neither
@@ -310,9 +327,10 @@ module Rigor
310
327
  action: :skipped_invalid_rbs, error: error)
311
328
  end
312
329
 
313
- target.write(state.source)
330
+ target.write(state.source) unless @dry_run
314
331
  WriteResult.new(source_path: source_path, target_path: target,
315
- action: action, applied: state.applied, skipped: state.skipped)
332
+ action: action, applied: state.applied, skipped: state.skipped,
333
+ left_unreadable: state.left_unreadable, replaced: state.replaced)
316
334
  end
317
335
 
318
336
  # Applies every class group, then every requested shell, then normalises the layout the three steps
@@ -544,7 +562,7 @@ module Rigor
544
562
  state.applied.concat(methods)
545
563
  insert_namespace_chain(state, class_name, kinds, supers, methods)
546
564
  else
547
- state.source = merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
565
+ state.source = merge_into_existing_class(state.source, decl, methods, state)
548
566
  state.decls = parse_signature(state.source) || state.decls
549
567
  end
550
568
  end
@@ -575,24 +593,29 @@ module Rigor
575
593
  source.end_with?("\n")
576
594
  end
577
595
 
578
- def merge_into_existing_class(source, decl, methods, applied, skipped)
596
+ def merge_into_existing_class(source, decl, methods, state)
579
597
  existing_pairs = collect_member_pairs(decl)
580
598
  new_methods, conflicting = partition_against_existing(methods, existing_pairs)
581
599
 
582
600
  source = insert_into_class(source, decl, new_methods)
583
- applied.concat(new_methods)
601
+ state.applied.concat(new_methods)
584
602
 
585
603
  if @overwrite
586
- source, replaced = replace_eligible_conflicts(source, decl, conflicting)
587
- applied.concat(replaced)
588
- skipped.concat(conflicting.reject { |c| replaced.include?(c) }.map { |c| [c, :user_authored] })
604
+ source, replaced = replace_eligible_conflicts(source, decl, conflicting, state)
605
+ state.applied.concat(replaced)
606
+ state.replaced.concat(replaced)
607
+ state.skipped.concat(conflicting.reject { |c| replaced.include?(c) }.map { |c| [c, :user_authored] })
589
608
  else
590
- skipped.concat(conflicting.map { |c| [c, :user_authored] })
609
+ state.skipped.concat(conflicting.map { |c| [c, :user_authored] })
591
610
  end
592
611
 
593
612
  source
594
613
  end
595
614
 
615
+ def inline_overwrite?(candidate)
616
+ candidate.classification == Classification::INLINE_OVERWRITE
617
+ end
618
+
596
619
  # Returns a list of `[method_name (Symbol), kind (Symbol)]` pairs for every method-like member in the
597
620
  # declaration. ADR-14 slice 4 recognises `MethodDefinition`'s `:instance` / `:singleton` kind plus the
598
621
  # three `attr_*` declaration kinds so a source-side `attr_reader :name` and an RBS-side
@@ -628,7 +651,8 @@ module Rigor
628
651
  return source if new_methods.empty?
629
652
 
630
653
  indent = INDENT * member_indent_depth(decl)
631
- insert_before_end(source, decl, new_methods.map { |c| "#{indent}#{c.rbs}\n" }.join)
654
+ lines = new_methods.flat_map { |c| c.rbs_lines.map { |line| "#{indent}#{line}\n" } }.join
655
+ insert_before_end(source, decl, lines)
632
656
  end
633
657
 
634
658
  # Walks the class's existing method declarations; for each replaceable candidate that matches a member
@@ -643,21 +667,32 @@ module Rigor
643
667
  # canonical case is `initialize_stub_candidate`, which bypasses the existing-RBS comparison and always
644
668
  # classifies as `NEW_METHOD` — when sig-gen's `--params=observed` upgrades a `(path: untyped) -> void`
645
669
  # declaration to `(path: String) -> void` we want `--overwrite` to apply it.
646
- def replace_eligible_conflicts(source, decl, candidates)
670
+ def replace_eligible_conflicts(source, decl, candidates, state)
647
671
  eligible = candidates.select { |c| eligible_for_replacement?(c, decl, source) }
648
672
  return [source, []] if eligible.empty?
649
673
 
650
674
  replaced = []
675
+ done = {}
651
676
  # Apply replacements from highest byte position downward so earlier byte offsets remain valid as the
652
677
  # source grows or shrinks.
653
- sorted = eligible.sort_by { |c| -member_position(decl, c.method_name, c.kind) }
678
+ sorted = eligible.sort_by { |c| -member_position(decl, c) }
654
679
  sorted.each do |candidate|
655
- source = apply_replacement(source, decl, candidate) and replaced << candidate
680
+ member = replaceable_member(decl, candidate)
681
+ # An `attr_accessor` answers for two methods; its one replacement covers both candidates.
682
+ next replaced << candidate if member && done[member.location.start_pos]
683
+
684
+ result = apply_replacement(source, decl, candidate, state)
685
+ next if result.nil?
686
+
687
+ done[member.location.start_pos] = true
688
+ source = result
689
+ replaced << candidate
656
690
  end
657
691
  [source, replaced]
658
692
  end
659
693
 
660
694
  def eligible_for_replacement?(candidate, decl, source)
695
+ return true if inline_overwrite?(candidate)
661
696
  return false if replaces_declared_void?(candidate, decl)
662
697
 
663
698
  case candidate.classification
@@ -684,25 +719,37 @@ module Rigor
684
719
  declared_void && !candidate.rbs.to_s.end_with?(" -> void")
685
720
  end
686
721
 
687
- # Compares the existing member's source-side RBS text against the candidate's proposed RBS text. Returns
688
- # true when the new spelling has STRICTLY FEWER bare `untyped` tokens than the existing one — i.e. at
689
- # least one `untyped` slot becomes a concrete type AND no concrete slot becomes `untyped`. Word-boundary
690
- # matching ensures we count `untyped` only as a type token, not as a substring inside identifiers.
722
+ # Compares the existing member's source-side RBS text against the candidate's proposed RBS text
723
+ # ({.fewer_untyped?}).
691
724
  def tightens_untyped?(candidate, decl, source)
692
725
  member = find_method_member(decl, candidate.method_name, candidate.kind)
693
726
  return false if member.nil?
694
727
 
695
- existing_rbs = source[member.location.start_pos...member.location.end_pos]
696
- count_untyped(candidate.rbs) < count_untyped(existing_rbs)
728
+ self.class.fewer_untyped?(candidate.rbs, source[member.location.start_pos...member.location.end_pos])
697
729
  end
698
730
 
699
- def count_untyped(rbs)
700
- rbs.scan(/\buntyped\b/).size
731
+ def member_position(decl, candidate)
732
+ member = replaceable_member(decl, candidate)
733
+ member ? member.location.start_pos : -1
701
734
  end
702
735
 
703
- def member_position(decl, method_name, kind)
704
- member = find_method_member(decl, method_name, kind)
705
- member ? member.location.start_pos : -1
736
+ # The member a replacement overwrites. An inline overwrite may also land on an `attr_*` declaration of
737
+ # the same method (ADR-112 WD4), which it replaces in the inline attribute's own spelling; an
738
+ # `overloading?` member (`| ...`) is never its target.
739
+ def replaceable_member(decl, candidate)
740
+ member = find_method_member(decl, candidate.method_name, candidate.kind)
741
+ return member unless candidate.classification == Classification::INLINE_OVERWRITE
742
+
743
+ member = nil if member.respond_to?(:overloading?) && member.overloading?
744
+ member || find_attr_member(decl, candidate.method_name, candidate.kind)
745
+ end
746
+
747
+ def find_attr_member(decl, method_name, kind)
748
+ decl.members.find do |m|
749
+ pairs = []
750
+ collect_pairs_for_member(m, pairs) unless m.is_a?(RBS::AST::Members::MethodDefinition)
751
+ m.respond_to?(:kind) && m.kind == kind && pairs.any? { |name, _| name == method_name }
752
+ end
706
753
  end
707
754
 
708
755
  def find_method_member(decl, method_name, kind)
@@ -714,12 +761,96 @@ module Rigor
714
761
  # Splices the new RBS one-liner over the existing declaration's byte range. `RBS::Parser`'s location
715
762
  # starts at the `def` keyword, NOT at the column zero of the line, so the leading whitespace stays inside
716
763
  # `source[0...start_pos]` and we do not re-emit it.
717
- def apply_replacement(source, decl, candidate)
718
- member = find_method_member(decl, candidate.method_name, candidate.kind)
764
+ def apply_replacement(source, decl, candidate, state)
765
+ member = replaceable_member(decl, candidate)
719
766
  return nil if member.nil?
720
767
 
721
768
  loc = member.location
722
- source[0...loc.start_pos] + candidate.rbs + source[loc.end_pos..]
769
+ replaced = source[0...loc.start_pos] + kept_comments(source, loc) + candidate.rbs + source[loc.end_pos..]
770
+ # Both splices insert above the member, the effect annotation at its own line and the declared ones at
771
+ # its first annotation's, so running the lower insertion first keeps the positions the parse gave valid.
772
+ replaced = splice_annotations(replaced, member, candidate, state)
773
+ splice_declared_annotations(replaced, member, candidate)
774
+ end
775
+
776
+ # A member written across lines (`def f: () -> A` / ` # why` / ` | (B) -> C`) can hold comments inside its
777
+ # own location, which the splice above would delete with the text it replaces. Each such comment line is
778
+ # kept, moved above the new line at the member's indentation. Only whole comment lines are recognised: RBS
779
+ # has no string type that spans a line, so a line whose first non-blank character is `#` is a comment.
780
+ def kept_comments(source, loc)
781
+ text = source[loc.start_pos...loc.end_pos].to_s
782
+ return "" unless text.include?("\n")
783
+
784
+ indent = source[line_start_index(source, loc.start_pos)...loc.start_pos].to_s
785
+ indent = "" unless indent.match?(/\A[ \t]*\z/)
786
+ text.lines.drop(1).filter_map { |line| line.strip if line.match?(/\A\s*#/) }
787
+ .map { |comment| "#{comment}\n#{indent}" }.join
788
+ end
789
+
790
+ # ADR-112 WD4 — the annotations an inline declaration carries that its `sig/` copy lacks, added above the
791
+ # member. Additive only: an annotation already there stays, whoever wrote it, so a copy never loses
792
+ # anything to regeneration (an annotation later deleted inline is therefore not deleted here).
793
+ def splice_declared_annotations(source, member, candidate)
794
+ missing = missing_declared_annotations(member, candidate)
795
+ return source if missing.empty?
796
+
797
+ line_start = line_start_index(source, member.location.start_pos)
798
+ indent = source[line_start...member.location.start_pos].to_s
799
+ return source unless indent.match?(/\A[ \t]*\z/)
800
+
801
+ anchor = annotation_block_start(source, member, line_start)
802
+ source[0...anchor] + missing.map { |line| "#{indent}#{line}\n" }.join + source[anchor..]
803
+ end
804
+
805
+ # A declared annotation line is `%a` plus a one-character delimiter pair around its content.
806
+ def missing_declared_annotations(member, candidate)
807
+ present = member.annotations.map { |annotation| annotation.string.to_s.strip }
808
+ candidate.declared_annotations.reject { |line| present.include?(line[3...-1].to_s.strip) }
809
+ end
810
+
811
+ # The start of the line the member's first annotation sits on, or of the member's own line.
812
+ def annotation_block_start(source, member, line_start)
813
+ starts = member.annotations.filter_map { |annotation| annotation.location&.start_pos }
814
+ ([line_start] + starts.map { |pos| line_start_index(source, pos) }).min
815
+ end
816
+
817
+ # ADR-103 WD9 — the annotation half of a replacement, and the one place this slice is allowed to
818
+ # write bytes ABOVE an existing declaration.
819
+ #
820
+ # `RBS::Parser` puts a member's annotations OUTSIDE its `location`, so {#apply_replacement} above
821
+ # already leaves every annotation on the declaration byte-untouched — the `%a{deprecated}` and the
822
+ # `%a{implicitly-returns-nil}` a user wrote survive an `--overwrite` unchanged. What is left is the
823
+ # question of adding ours, and the answer is: only onto a declaration that carries none.
824
+ #
825
+ # A declaration that already carries an annotation is left exactly as it is, and the candidate is
826
+ # reported as `sig.effect.left-unreadable`. The writer cannot tell an annotation it authored from
827
+ # one the user wrote, and it has no grammar for merging two — so rewriting the region could silently
828
+ # replace an author's own `%a{pure}` with a labelled envelope, or strip a directive Rigor does not
829
+ # read at all. Leaving it and saying so is the only move that cannot destroy an authored fact.
830
+ #
831
+ # An annotation on one of the member's overloads (`def m: %a{pure} (Integer) -> String`) counts too:
832
+ # it sits inside the location, and a tighter return keeps it (#1436), so ours would be a second one.
833
+ def splice_annotations(source, member, candidate, state)
834
+ return source if candidate.annotations.empty?
835
+
836
+ if annotated?(member)
837
+ # Rebuilt so the reported row carries the reason it was actually given. The generator stamped
838
+ # `:emitted` when it decided the annotation; only the writer knows the declaration refused it.
839
+ state.left_unreadable << candidate.with_effect_annotation(candidate.annotations, :left_unreadable)
840
+ return source
841
+ end
842
+
843
+ line_start = line_start_index(source, member.location.start_pos)
844
+ indent = source[line_start...member.location.start_pos].to_s
845
+ return source unless indent.match?(/\A[ \t]*\z/)
846
+
847
+ source[0...line_start] + candidate.annotations.map { |line| "#{indent}#{line}\n" }.join + source[line_start..]
848
+ end
849
+
850
+ def annotated?(member)
851
+ return true unless member.annotations.empty?
852
+
853
+ member.respond_to?(:overloads) && member.overloads.any? { |overload| overload.annotations.any? }
723
854
  end
724
855
  end
725
856
  end
data/lib/rigor/sig_gen.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "sig_gen/alias_index"
3
4
  require_relative "sig_gen/classification"
5
+ require_relative "sig_gen/effect_annotation"
6
+ require_relative "sig_gen/inline_declarations"
4
7
  require_relative "sig_gen/method_candidate"
5
8
  require_relative "sig_gen/observed_call"
6
9
  require_relative "sig_gen/meta_class_shape"
@@ -32,7 +32,7 @@ module Rigor
32
32
  # requiring the loader from here fails outright. `signature_path_audit_spec` pins the two
33
33
  # anchors equal, and pins this discovery against `Loader.bundled_plugin_sig_path`, so the
34
34
  # duplication cannot drift unnoticed.
35
- BUNDLED_PLUGINS_ROOT = File.expand_path("../../plugins", __dir__)
35
+ BUNDLED_PLUGINS_ROOT = File.expand_path("../../plugins", __dir__).freeze
36
36
 
37
37
  # One configured `signature_paths:` entry's resolution status.
38
38
  #
@@ -25,7 +25,6 @@ module Rigor
25
25
  module NodeWalker
26
26
  module_function
27
27
 
28
- # @return when no block is given.
29
28
  def each(root, &)
30
29
  return to_enum(__method__, root) unless block_given?
31
30
 
@@ -46,8 +45,6 @@ module Rigor
46
45
  # itself). The yielded `ancestors` array is the live descent stack — callers that retain it past the
47
46
  # block invocation MUST copy it (`Plugin::NodeContext` does). Used by the plugin engine to give
48
47
  # `node_rule` blocks their enclosing class / method / block context (ADR-37 slice 1d).
49
- #
50
- # @return when no block is given.
51
48
  def each_with_ancestors(root, &)
52
49
  return to_enum(__method__, root) unless block_given?
53
50
 
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Source
7
+ # Issue #992 — the positional-argument envelope of a `Prism::DefNode`, and the ONE place a `def` nobody
8
+ # declared is turned into something `call.wrong-arity` can check a call against.
9
+ #
10
+ # Both undeclared lanes read it. A method known only to the discovery walk has no other shape, and a
11
+ # method `rigor-rbs-inline` synthesized a fully defaulted skeleton for (`%a{rigor:v1:inferred-signature}`)
12
+ # is sent here too rather than having its synthesized member's arity read, so a `def` can never be
13
+ # judged by two parsers of its parameter list that disagree.
14
+ #
15
+ # An envelope is plain frozen data, `[min, max, required_keywords]`, because it rides the ADR-85 seed
16
+ # bundle through `Marshal`: `max` is nil when a rest or `...` parameter leaves it unbounded, and
17
+ # `required_keywords` is true when the def takes a required keyword — a call that passes positionals
18
+ # only then fails on the missing keyword whatever its count, which is a different diagnostic than the
19
+ # one this envelope backs, so the rule declines on it the way `compute_arity_envelope` declines an RBS
20
+ # function with required keywords.
21
+ #
22
+ # {OPAQUE} is the value every other contribution to a name records — `define_method`, `attr_*`, an alias,
23
+ # a macro that names the method, a second `def` whose envelope differs — and it absorbs under {.merge}.
24
+ # That makes the per-name fold a join: order-independent, idempotent, and unable to keep one of several
25
+ # shapes silently.
26
+ module ParameterEnvelope
27
+ module_function
28
+
29
+ OPAQUE = :opaque
30
+
31
+ # The envelope of `def_node`'s own parameter list. Block, keyword-optional and keyword-rest parameters
32
+ # never change how many positionals the method accepts; `super`, `yield` and `&block` in the body do
33
+ # not either, so the body is not read at all.
34
+ def of(def_node)
35
+ params = def_node.parameters
36
+ return [0, 0, false].freeze if params.nil?
37
+
38
+ required = params.requireds.size + params.posts.size
39
+ [required, maximum(params, required), required_keywords?(params)].freeze
40
+ end
41
+
42
+ def maximum(params, required)
43
+ return nil if params.rest || params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
44
+
45
+ required + params.optionals.size
46
+ end
47
+
48
+ def required_keywords?(params)
49
+ params.keywords.any?(Prism::RequiredKeywordParameterNode)
50
+ end
51
+
52
+ # The join of two contributions to one name: equal envelopes stay, anything else is {OPAQUE}.
53
+ def merge(left, right)
54
+ return right if left.nil?
55
+ return left if right.nil?
56
+
57
+ left == right ? left : OPAQUE
58
+ end
59
+
60
+ def opaque?(envelope) = envelope == OPAQUE
61
+
62
+ # Folds `overlay`'s per-class entries into `base` with {.merge}, returning a new table. Both are
63
+ # `{class name => {key => envelope}}`.
64
+ def merge_tables(base, overlay)
65
+ return overlay if base.nil? || base.empty?
66
+ return base if overlay.nil? || overlay.empty?
67
+
68
+ base.merge(overlay) { |_class, left, right| left.merge(right) { |_key, a, b| merge(a, b) } }
69
+ end
70
+ end
71
+ end
72
+ end
data/lib/rigor/source.rb CHANGED
@@ -15,3 +15,4 @@ require_relative "source/node_locator"
15
15
  require_relative "source/node_walker"
16
16
  require_relative "source/literals"
17
17
  require_relative "source/constant_path"
18
+ require_relative "source/parameter_envelope"
@@ -825,24 +825,72 @@ module Rigor
825
825
 
826
826
  return [top] if flattened.any?(Top)
827
827
 
828
- absorb_float_ranges(unique_members(flattened))
828
+ absorb_contained_members(unique_members(flattened))
829
+ end
830
+
831
+ # Drops every member another member already contains, so the join after a guard names one set
832
+ # instead of several readings of it. The normative relation is
833
+ # docs/type-specification/normalization.md § "Member absorption"; {absorbed_by?} is that list.
834
+ #
835
+ # Only a `FloatRange`, `Tuple` or `HashShape` member can be dropped, so the O(n²) scan is
836
+ # skipped for every union that carries none of them — this runs on the hot path of every join.
837
+ #
838
+ # The `!absorbed_by?(other, member)` re-check costs one predicate call per pair that absorbed
839
+ # at all, and it is what makes "both arms dropped" unrepresentable should a future clause make
840
+ # the relation symmetric on a pair of distinct members.
841
+ def absorb_contained_members(members)
842
+ return members unless members.any? { |m| absorbable_member?(m) }
843
+
844
+ members.reject do |member|
845
+ absorbable_member?(member) &&
846
+ members.any? do |other|
847
+ !other.equal?(member) && absorbed_by?(member, other) && !absorbed_by?(other, member)
848
+ end
849
+ end
829
850
  end
830
851
 
831
- # ADR-109 WD5 — a `FloatRange` member is absorbed by a bare `Nominal[Float]` member or by a
832
- # `FloatRange` member that contains it. The truthy edge of `x > c` is a range and the falsy
833
- # edge keeps `Float`, so the join after every Float guard would otherwise carry both and every
834
- # later guard would narrow member by member into a growing union (`Float[...1.0] |
835
- # Float[0.0...1.0]`) that names one set. `IntegerRange` keeps its pre-ADR-109 behaviour
836
- # (`Integer | Integer[0..5]` stays as written) because both of its edges narrow.
837
- def absorb_float_ranges(members)
838
- return members unless members.any?(FloatRange)
852
+ def absorbable_member?(type)
853
+ type.is_a?(FloatRange) || type.is_a?(Tuple) || type.is_a?(HashShape)
854
+ end
839
855
 
840
- members.reject do |member|
841
- member.is_a?(FloatRange) &&
842
- members.any? { |other| !other.equal?(member) && float_range_absorbed_by?(member, other) }
856
+ # `narrower`'s inhabitants are a subset of `wider`'s under a rule the union already applies to
857
+ # a direct member. The structural clauses only lift that same relation element-wise over a
858
+ # fixed spine; none of them introduces an absorption the flat union does not already perform,
859
+ # which is why a value-pinned element never collapses (normalization.md keeps `1 | Integer`,
860
+ # so `[1, String] | [Integer, String]` keeps both arms) and an `IntegerRange` or `Refined`
861
+ # element never does either.
862
+ def absorbed_by?(narrower, wider)
863
+ return true if wider.is_a?(Top) || narrower.is_a?(Bot)
864
+ return true if listed_in_union?(narrower, wider)
865
+
866
+ case narrower
867
+ when FloatRange then float_range_absorbed_by?(narrower, wider)
868
+ when Tuple then wider.is_a?(Tuple) && tuple_absorbed_by?(narrower, wider)
869
+ when HashShape then wider.is_a?(HashShape) && hash_shape_absorbed_by?(narrower, wider)
870
+ when Union then union_absorbed_by?(narrower, wider)
871
+ else false
843
872
  end
844
873
  end
845
874
 
875
+ # Flatten-and-dedupe read one level down: the member is already one of the arms `wider` lists.
876
+ def listed_in_union?(narrower, wider)
877
+ wider.is_a?(Union) && wider.members.any? { |member| element_absorbed_or_equal?(narrower, member) }
878
+ end
879
+
880
+ def union_absorbed_by?(narrower, wider)
881
+ narrower.members.all? { |member| element_absorbed_or_equal?(member, wider) }
882
+ end
883
+
884
+ def element_absorbed_or_equal?(narrower, wider)
885
+ narrower.equal?(wider) || narrower == wider || absorbed_by?(narrower, wider)
886
+ end
887
+
888
+ # ADR-109 WD5 — a `FloatRange` is absorbed by a bare `Nominal[Float]` or by a `FloatRange` that
889
+ # contains it. The truthy edge of `x > c` is a range and the falsy edge keeps `Float`, so the
890
+ # join after every Float guard would otherwise carry both and every later guard would narrow
891
+ # member by member into a growing union (`Float[...1.0] | Float[0.0...1.0]`) that names one set.
892
+ # `IntegerRange` keeps its pre-ADR-109 behaviour (`Integer | Integer[0..5]` stays as written)
893
+ # because both of its edges narrow.
846
894
  def float_range_absorbed_by?(range, other)
847
895
  case other
848
896
  when Nominal then other.class_name == "Float" && other.type_args.empty?
@@ -851,6 +899,34 @@ module Rigor
851
899
  end
852
900
  end
853
901
 
902
+ # Differing arity is never absorbed: `[A]` and `[A, B]` have disjoint inhabitants, so neither
903
+ # arm is a reading of the other.
904
+ def tuple_absorbed_by?(narrower, wider)
905
+ return false unless narrower.elements.size == wider.elements.size
906
+
907
+ narrower.elements.each_with_index.all? do |element, index|
908
+ element_absorbed_or_equal?(element, wider.elements[index])
909
+ end
910
+ end
911
+
912
+ def hash_shape_absorbed_by?(narrower, wider)
913
+ return false unless hash_shape_spine_equal?(narrower, wider)
914
+
915
+ narrower.pairs.all? { |key, value| element_absorbed_or_equal?(value, wider.pairs[key]) }
916
+ end
917
+
918
+ # The spine is everything but the value types: the key set and the openness / optionality /
919
+ # read-only policy. Two shapes that disagree on any of it describe differently-shaped hashes,
920
+ # and an element-wise reading of their values would not be a containment.
921
+ def hash_shape_spine_equal?(narrower, wider)
922
+ narrower.pairs.size == wider.pairs.size &&
923
+ narrower.extra_keys == wider.extra_keys &&
924
+ narrower.required_keys == wider.required_keys &&
925
+ narrower.optional_keys == wider.optional_keys &&
926
+ narrower.read_only_keys == wider.read_only_keys &&
927
+ narrower.pairs.each_key.all? { |key| wider.pairs.key?(key) }
928
+ end
929
+
854
930
  def unique_members(types)
855
931
  types.each_with_object([]) do |type, unique|
856
932
  unique << type unless unique.any? { |member| member.equal?(type) || member == type }
@@ -70,6 +70,7 @@ module Rigor
70
70
  "Array" => ->(removed) { removed.is_a?(Tuple) && removed.elements.empty? },
71
71
  "Hash" => ->(removed) { removed.is_a?(HashShape) && removed.pairs.empty? }
72
72
  }.freeze
73
+ Ractor.make_shareable(EMPTY_WITNESS_PREDICATES)
73
74
  private_constant :EMPTY_WITNESS_PREDICATES
74
75
 
75
76
  def top
@@ -60,7 +60,7 @@ module Rigor
60
60
  end
61
61
 
62
62
  def describe(verbosity = :short)
63
- return "{}" if pairs.empty?
63
+ return "{}" if pairs.empty? && closed?
64
64
 
65
65
  rendered = pairs.map { |k, v| render_entry(k, v, verbosity) }
66
66
  rendered << "..." if open?
@@ -176,6 +176,7 @@ module Rigor
176
176
  # recovered by inspecting an arbitrary string.
177
177
  literal_string: ->(v) { v.is_a?(String) }
178
178
  }.freeze
179
+ Ractor.make_shareable(PREDICATES)
179
180
 
180
181
  # Maps `[base_class_name, predicate_id]` pairs to their kebab-case canonical name. Registered
181
182
  # shapes print through `describe`; unregistered combinations fall back to the operator form.
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.3.9"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/rigor.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "rigor/environment"
10
10
  require_relative "rigor/rbs_extended"
11
11
  require_relative "rigor/testing"
12
12
  require_relative "rigor/inference/budget_trace"
13
+ require_relative "rigor/inference/external_ancestor_resolution"
13
14
  require_relative "rigor/inference/dynamic_origin"
14
15
  require_relative "rigor/inference/fallback"
15
16
  require_relative "rigor/inference/fallback_tracer"
@@ -29,6 +29,7 @@ module Rigor
29
29
 
30
30
  # The `_later` twins enqueue an ActiveJob that broadcasts, so they carry the enqueue meaning too.
31
31
  TURBO_LATER_SELECTORS = TURBO_SELECTORS.map { |name| "#{name.delete_suffix('_to')}_later_to" }.freeze
32
+ Ractor.make_shareable(TURBO_LATER_SELECTORS)
32
33
 
33
34
  LATER = (BROADCASTING + ["rails.activejob.enqueue", "job.enqueue"]).freeze
34
35