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
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "active_model_serializers/serializer_index"
6
+ require_relative "active_model_serializers/serializer_discoverer"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ # rigor-active-model-serializers — types the implicit-self `object` reader inside a serializer as the
11
+ # model the serializer serializes.
12
+ #
13
+ # It emits no diagnostic. What it contributes is one return type and the gem's own framework
14
+ # constants, and both exist for the same measurement: the 2026-09-01 corpus opacity sweep (#534
15
+ # item 6) found `object` to be Mastodon's single largest unresolved implicit-self send — 751 sites in
16
+ # `app/serializers` alone — with no plugin owning ActiveModelSerializers at all. Every
17
+ # `object.account.username` chain below one of those sites was dispatching on `Dynamic[top]`.
18
+ #
19
+ # plugins:
20
+ # - gem: rigor-active-model-serializers
21
+ # config:
22
+ # serializer_search_paths: ["app/serializers", "app/lib"] # default; optional
23
+ # serializer_base_classes: ["ActiveModel::Serializer"] # default; optional
24
+ # model_overrides: {} # default; optional
25
+ #
26
+ # ## What `object` types to, and when it declines
27
+ #
28
+ # AMS's `object` is `ActiveModel::Serializer#object`, the resource the serializer was constructed
29
+ # with. Nothing in a serializer's source STATES that resource's class — AMS binds it at `new` time —
30
+ # so the type is only ever derived, and two independent things have to agree before it is:
31
+ #
32
+ # 1. The `<Model>Serializer` naming convention resolves to exactly one model `rigor-activerecord`
33
+ # discovered (`REST::AccountSerializer` → `Account`), and
34
+ # 2. that model ANSWERS the serializer: every name the serializer will read off its resource — the
35
+ # `attributes` / `has_many` / `has_one` declarations it does not define itself, plus every
36
+ # `object.<name>` in its body — is a column, association, enum, alias or scope of that model, or a
37
+ # method the project defines on it.
38
+ #
39
+ # A `model_overrides` entry short-circuits both: the project asserted the answer. Anything else
40
+ # DECLINES, and `object` keeps whatever the engine would have given it (`Dynamic`).
41
+ #
42
+ # ## Why the name alone is not enough
43
+ #
44
+ # The name is a guess and the model index only proves the guessed class EXISTS, never that this
45
+ # serializer serializes it. Mastodon has three serializers where the guess is wrong and the class is
46
+ # real: `REST::ConversationSerializer` serializes an `AccountConversation` (its `unread`,
47
+ # `participant_accounts` and `last_status` are all absent from `Conversation`), and both
48
+ # `REST::InstanceSerializer` and `REST::V1::InstanceSerializer` serialize an `InstancePresenter`
49
+ # (`object.contact`, `object.thumbnail`). A corpus diff cannot see the damage, because an Active
50
+ # Record model's surface is open and a wrong-but-real model absorbs every read in silence — so the
51
+ # check has to be a positive one, made before the answer is contributed.
52
+ #
53
+ # Requiring EVERY name to be answered, rather than most of them, is the same choice: a serializer
54
+ # whose resource is a decorator around the model shares most of the model's surface, and it is
55
+ # exactly the one or two extra names that say so.
56
+ #
57
+ # ## Scope
58
+ #
59
+ # - **SimpleForm inputs are NOT handled.** The sweep's 875-site `object` count mixes AMS serializers
60
+ # with `SimpleForm::Inputs::Base#object`, a different gem with a different resource story
61
+ # (`object` there is the form's record, named by the `simple_form_for` call site, not by the input
62
+ # class). That belongs in a `rigor-simple-form` plugin and is deliberately left out.
63
+ # - **`serializer:` / `each_serializer:` options are not read.** `has_many :emojis, serializer:
64
+ # REST::CustomEmojiSerializer` states which serializer renders an association, never which model a
65
+ # serializer serializes, so it cannot ground `object`.
66
+ # - **A serializer with no declarations and no `object` reads gets no answer.** There is nothing to
67
+ # check the name against, and the name alone is what this refuses to trust.
68
+ class ActiveModelSerializers < Rigor::Plugin::Base
69
+ manifest(
70
+ id: "active-model-serializers",
71
+ target_gems: ["active_model_serializers"],
72
+ version: "0.1.0",
73
+ description: "Types the implicit-self `object` reader inside ActiveModel::Serializer subclasses " \
74
+ "as the serializer's model, and declares the gem's framework constants.",
75
+ config_schema: {
76
+ # `app/lib` is in the default set because a project base serializer routinely lives outside
77
+ # `app/serializers` — Mastodon's `ActivityPub::Serializer`, the parent of 60 of its 153
78
+ # serializers, is at `app/lib/activitypub/serializer.rb`. Without it the ancestry closure stops
79
+ # at the base class and two fifths of the project's serializers are simply not serializers as
80
+ # far as this plugin is concerned. A directory the project does not have costs one probe.
81
+ "serializer_search_paths" => { kind: :array, default: ["app/serializers", "app/lib"] },
82
+ "serializer_base_classes" => { kind: :array, default: ["ActiveModel::Serializer"] },
83
+ # Serializer class name => model class name, for the resources the derivation cannot reach: a
84
+ # serializer whose resource is a presenter or a decorator, or one named for a JSON shape rather
85
+ # than a model. An override is the project's own assertion and is NOT re-checked — a project
86
+ # that names a class Rigor has no RBS for gets a lenient nominal.
87
+ "model_overrides" => { kind: :hash, default: {} }
88
+ },
89
+ # Optional on purpose: without `rigor-activerecord` there is no model set to check a name
90
+ # against, and every non-overridden serializer declines. That is the designed degradation — the
91
+ # plugin contributes less, never something wrong.
92
+ consumes: [{ plugin_id: "activerecord", name: :model_index, optional: true }],
93
+ # ADR-25 (#534 item 7, same admission rule as `rigor-activerecord`'s `sig/active_record/
94
+ # framework.rbs`) — the gem's own constants, declared so they resolve and asserting nothing else.
95
+ signature_paths: ["sig"],
96
+ # ADR-26 — every class and module the bundled signature names is open, so that declaring it buys
97
+ # constant resolution and asserts nothing about a member. `ActiveModel::Serializer` is the row
98
+ # that matters most (a project's serializers inherit from it, and its whole instance surface —
99
+ # `object`, `scope`, `read_attribute_for_serialization`, the `_attributes` class state — is
100
+ # unenumerated), and `ActiveModelSerializers` / `::Adapter` are the ones the canonical
101
+ # initializer dispatches on: `ActiveModelSerializers.config.adapter = :json_api` and
102
+ # `ActiveModelSerializers::Adapter.register(...)` are what an AMS `config/initializers` file says.
103
+ open_receivers: [
104
+ "ActiveModel::Serializer",
105
+ "ActiveModel::Serializer::CollectionSerializer",
106
+ "ActiveModelSerializers",
107
+ "ActiveModelSerializers::Adapter",
108
+ "ActiveModelSerializers::Model",
109
+ "ActiveModelSerializers::SerializableResource"
110
+ ]
111
+ )
112
+
113
+ producer :serializer_index, watch: -> { [[@serializer_search_paths, "**/*.rb"]] } do |_params|
114
+ SerializerDiscoverer.new(
115
+ io_boundary: io_boundary,
116
+ search_paths: @serializer_search_paths,
117
+ base_classes: @serializer_base_classes
118
+ ).discover
119
+ end
120
+
121
+ def init(_services)
122
+ @serializer_search_paths = Array(config.fetch("serializer_search_paths")).map(&:to_s)
123
+ @serializer_base_classes = Array(config.fetch("serializer_base_classes")).map(&:to_s)
124
+ @model_overrides = config.fetch("model_overrides").to_h { |k, v| [k.to_s.delete_prefix("::"), v.to_s] }
125
+ @derivations = {}
126
+ end
127
+
128
+ # The `methods:` gate keeps this off every dispatch whose name is not `object`; the receiver and
129
+ # argument checks keep it off `foo.object` and `object(x)`, neither of which is the AMS reader.
130
+ dynamic_return methods: [:object] do |call_node, scope|
131
+ next nil unless call_node.is_a?(Prism::CallNode)
132
+ next nil unless call_node.receiver.nil?
133
+ next nil unless call_node.arguments.nil?
134
+
135
+ entry = serializer_entry(scope)
136
+ next nil if entry.nil?
137
+ # An explicit `def object`, here or on an ancestor, is the definition that runs. Answering over
138
+ # it would replace a type the engine derived from real source with a guess, and silence whatever
139
+ # that source proves.
140
+ next nil if entry.defines_object? || project_defines_object?(entry.class_name, scope)
141
+
142
+ model_name = model_class_name_for(entry, scope)
143
+ next nil if model_name.nil?
144
+
145
+ Rigor::Type::Combinator.nominal_of(model_name)
146
+ end
147
+
148
+ private
149
+
150
+ # The discovered serializer whose body `self` is in, or nil. Membership of the index is the only
151
+ # gate: a `*Serializer` name is not evidence of anything, since `object` is an ordinary method name
152
+ # that a `Json::ConversationSerializer` or an `Oj::AccountSerializer` may define for itself.
153
+ def serializer_entry(scope)
154
+ self_type = scope&.self_type
155
+ return nil unless self_type.respond_to?(:class_name)
156
+
157
+ name = self_type.class_name
158
+ return nil if name.nil? || name.empty?
159
+
160
+ producer_value(:serializer_index)&.find(name)
161
+ end
162
+
163
+ def project_defines_object?(serializer_name, scope)
164
+ return false unless scope.respond_to?(:user_def_through_ancestors)
165
+
166
+ !scope.user_def_through_ancestors(serializer_name, :object).first.nil?
167
+ end
168
+
169
+ # The model a serializer serializes, or nil where nothing corroborates it. Memoised per serializer
170
+ # class INCLUDING the nil answer: the ancestor walks behind `project_defines?` are run once per
171
+ # name per serializer, and `object` is read hundreds of times across a real `app/serializers`.
172
+ def model_class_name_for(entry, scope)
173
+ return @derivations[entry.class_name] if @derivations.key?(entry.class_name)
174
+
175
+ @derivations[entry.class_name] = derive_model_class_name(entry, scope)
176
+ end
177
+
178
+ def derive_model_class_name(entry, scope)
179
+ override = @model_overrides[entry.class_name]
180
+ return override unless override.nil? || override.empty?
181
+
182
+ # The published `:model_index` fact is a Hash keyed by the model's DE-ROOTED constant path
183
+ # (#583). `rigor-activerecord` withholds it entirely in reduced mode (no `db/schema.rb` /
184
+ # `db/structure.sql`), so a schema-less project declines here rather than typing `object` off a
185
+ # name alone.
186
+ index = read_fact(plugin_id: "activerecord", name: :model_index)
187
+ return nil if index.nil? || index.empty?
188
+
189
+ resolved = convention_candidates(entry.class_name).select { |candidate| index.key?(candidate) }
190
+ # Two readings that both name a real model (`Admin::AccountSerializer` where `Admin::Account` and
191
+ # `Account` both exist) is an ambiguity, not a first-hit: nothing here ranks one above the other.
192
+ return nil unless resolved.size == 1
193
+
194
+ model = resolved.first
195
+ answers?(entry, index.fetch(model), model, scope) ? model : nil
196
+ end
197
+
198
+ # Whether the model ANSWERS the serializer — see the class comment for why this is required and why
199
+ # it is required of every name rather than most of them. A serializer that declares nothing and
200
+ # reads nothing states nothing to check, and is declined for that reason rather than admitted for
201
+ # it.
202
+ def answers?(entry, row, model_name, scope)
203
+ declarations = entry.unhandled_declarations
204
+ reads = entry.resource_reads
205
+ return false if declarations.empty? && reads.empty?
206
+
207
+ members = model_members(row)
208
+ # A declaration the serializer itself renders is not a read of the resource at all. The entry
209
+ # subtracts the serializer's OWN methods; a base serializer's `def formatted` is only visible to
210
+ # the engine's ancestor walk, and a declaration it handles says nothing about the model.
211
+ declarations.all? do |name|
212
+ project_defines?(entry.class_name, name, scope) ||
213
+ members.include?(name) || project_defines?(model_name, name, scope)
214
+ end && reads.all? { |name| members.include?(name) || project_defines?(model_name, name, scope) }
215
+ end
216
+
217
+ # Every name the model answers that its `:model_index` row states. The `?` forms are Active
218
+ # Record's own per-column predicates, which a serializer reads as readily as the column.
219
+ #
220
+ # `macro_methods` (#1049) is the row's name-only set: `delegate`'s methods, the Paperclip / Active
221
+ # Storage attachment readers, and an `enum`'s per-value predicates, from the model body and from every
222
+ # concern the model includes. It is what lets `REST::AccountSerializer`'s `followers_count` / `user` /
223
+ # `avatar` reads resolve; an older producer that does not publish the key reads as the empty set here,
224
+ # which declines exactly as before rather than erroring.
225
+ def model_members(row)
226
+ columns = Array(row[:columns]).map(&:to_s)
227
+ associations = Array(row[:associations]).map { |a| a[:name].to_s }
228
+ enums = row[:enums].is_a?(Hash) ? row[:enums].keys.map(&:to_s) : []
229
+ aliases = row[:aliases].is_a?(Hash) ? row[:aliases].keys.map(&:to_s) : []
230
+ scopes = Array(row[:scopes]).map(&:to_s)
231
+ macros = Array(row[:macro_methods]).map(&:to_s)
232
+ (columns + columns.map { |c| "#{c}?" } + associations + enums + aliases + scopes + macros).to_set
233
+ end
234
+
235
+ # A method the project writes in Ruby on `class_name` or an ancestor of it. On the model side that
236
+ # is `Account#local?` or a concern's reader — the model index cannot see these, and without them
237
+ # the check would decline nearly every real serializer. On the serializer side it is a base
238
+ # serializer's own rendering method, which is not a read of the resource at all.
239
+ def project_defines?(class_name, method_name, scope)
240
+ return false unless scope.respond_to?(:user_def_through_ancestors)
241
+
242
+ !scope.user_def_through_ancestors(class_name, method_name.to_sym).first.nil?
243
+ end
244
+
245
+ # `REST::AccountSerializer` offers two readings — the namespaced `REST::Account` and the
246
+ # demodulized `Account` — and Rails apps use both (`Admin::AccountSerializer` for `Admin::Account`
247
+ # is as real as Mastodon's `REST::AccountSerializer` for `Account`). Both are offered to the model
248
+ # index, and exactly one of them has to land.
249
+ def convention_candidates(serializer_name)
250
+ return [] unless serializer_name.end_with?("Serializer")
251
+ # A class named exactly `Serializer` is a namespace's base serializer, never a model's.
252
+ return [] if serializer_name.split("::").last == "Serializer"
253
+
254
+ stripped = serializer_name.delete_suffix("Serializer").delete_suffix("::")
255
+ return [] if stripped.empty?
256
+
257
+ [stripped, stripped.split("::").last].uniq.reject(&:empty?)
258
+ end
259
+ end
260
+
261
+ Rigor::Plugin.register(ActiveModelSerializers)
262
+ end
263
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when `.rigor.yml` lists
4
+ # `rigor-active-model-serializers` under `plugins:`. The loader expects this `require` to side-effect a
5
+ # call to `Rigor::Plugin.register`, which the body of
6
+ # `lib/rigor/plugin/active_model_serializers.rb` performs at load time.
7
+ require_relative "rigor/plugin/active_model_serializers"
@@ -0,0 +1,45 @@
1
+ # The ActiveModelSerializers gem's own constants — issue #534 item 6.
2
+ #
3
+ # The 2026-09-01 corpus opacity sweep counted `ActiveModel::Serializer` 101 times on Mastodon,
4
+ # `ActiveModelSerializers::Model` 29 and `ActiveModelSerializers::SerializableResource` 25, every
5
+ # one of them resolving to `untyped` because nothing in the bundle named the constant. This file
6
+ # names them, and nothing more.
7
+ #
8
+ # ## The admission rule
9
+ #
10
+ # The same rule `plugins/rigor-activerecord/sig/active_record/framework.rbs` states: declaring a
11
+ # constant makes it RBS-KNOWN, and a known class with an incomplete signature turns every member the
12
+ # signature omits into a false `call.undefined-method`. Every class here is therefore ALSO listed in
13
+ # the manifest's `open_receivers:` (ADR-26), which is what keeps the members this file does not
14
+ # enumerate lenient.
15
+ #
16
+ # That pairing is load-bearing rather than defensive here, because — unlike the Active Record
17
+ # exception hierarchy, which is rescued and not dispatched on — `ActiveModel::Serializer` and
18
+ # `ActiveModelSerializers::Model` are exactly the "class a project INHERITS from" shape the Active
19
+ # Record file declines to declare. `class REST::AccountSerializer < ActiveModel::Serializer` is what
20
+ # an AMS application looks like, and `object`, `scope`, `read_attribute_for_serialization`,
21
+ # `attributes`, `has_many` and the rest of the AMS surface are not enumerated below. The Mastodon
22
+ # before/after diff recorded in `docs/notes/20260917-ams-object-recognizer.md` is the evidence that
23
+ # the pairing holds on a real application.
24
+ #
25
+ # `ActiveModel` is declared as a module by `rigor-activerecord` as well; RBS reopens a module across
26
+ # files, so the two declarations compose rather than collide, and neither plugin depends on the
27
+ # other being enabled.
28
+
29
+ module ActiveModel
30
+ class Serializer
31
+ class CollectionSerializer
32
+ end
33
+ end
34
+ end
35
+
36
+ module ActiveModelSerializers
37
+ class Model
38
+ end
39
+
40
+ class SerializableResource
41
+ end
42
+
43
+ module Adapter
44
+ end
45
+ end
@@ -49,6 +49,7 @@ module Rigor
49
49
  "shoryuken" => ["io.net"], "backburner" => ["io.net"], "sucker_punch" => [],
50
50
  "async" => [], "inline" => [], "test" => []
51
51
  }.freeze
52
+ Ractor.make_shareable(TRANSPORTS)
52
53
 
53
54
  # The meaning half, which is adapter-independent and is what a policy actually names.
54
55
  MEANING = ["rails.activejob.enqueue", "job.enqueue"].freeze
@@ -129,12 +129,12 @@ module Rigor
129
129
  services.fact_store.publish(plugin_id: manifest.id, name: :reachability_roots, value: roots)
130
130
  end
131
131
 
132
- # File-level only: the load-error emission. Per-call arity validation runs over the engine-owned
132
+ # File-level only: the load-error disclosure. Per-call arity validation runs over the engine-owned
133
133
  # walk via the node_rule below (ADR-37). The job index is lazily loaded + memoised by
134
134
  # `producer_value`, shared by both surfaces.
135
135
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
136
136
  index = producer_value(:job_index)
137
- return [load_error_diagnostic(path)] if index.nil? && producer_error(:job_index)
137
+ disclose_load_error if index.nil? && producer_error(:job_index)
138
138
 
139
139
  []
140
140
  end
@@ -148,10 +148,14 @@ module Rigor
148
148
 
149
149
  private
150
150
 
151
- def load_error_diagnostic(path)
151
+ # Issue #1056 — "the job index did not load" is a fact about the run's INPUTS, not about the file being
152
+ # analysed, so it is registered rather than returned: see {Plugin::Base#disclose_once} for the
153
+ # channel, the `(plugin id, key)` de-duplication and why the engine positions it at `.rigor.yml:1:1`.
154
+ # Returned from the per-file hook it carried no once-guard at all and repeated on every file.
155
+ def disclose_load_error
152
156
  error = producer_error(:job_index)
153
- Rigor::Analysis::Diagnostic.new(
154
- path: path, line: 1, column: 1,
157
+ disclose_once(
158
+ :job_index_load_failed,
155
159
  message: "rigor-activejob: failed to discover jobs: #{error.class}: #{error.message}",
156
160
  severity: :warning,
157
161
  rule: "load-error"
@@ -13,13 +13,15 @@ module Rigor
13
13
  # | Method | Recognised arg shape | Validation |
14
14
  # | --- | --- | --- |
15
15
  # | `Model.find(id)` | any positional | arity check (1+ args) |
16
+ # | `Model.find { … }` | a block, no args | none (`Enumerable#find`) |
16
17
  # | `Model.find_by(col: v, ...)` | keyword args | each key must be a column |
17
18
  # | `Model.where(col: v, ...)` | keyword args | each key must be a column |
18
19
  # | `Model.where(string)` | String literal | parser-side; not validated |
19
20
  #
20
21
  # Successful matches surface as `:info` diagnostics naming the resolved table; unknown columns
21
22
  # surface as `:error`. Calls whose receiver is not a model in the index, and calls with
22
- # non-keyword arguments to `where` / `find_by`, stay silent.
23
+ # non-keyword arguments to `where` / `find_by`, stay silent. A model's own `self.find` owns its arity,
24
+ # so the arity check and the notes for the forms the return-type tier leaves to it stay silent too.
23
25
  class Analyzer
24
26
  # Methods that take a column → value Hash and need each key validated against the receiver's
25
27
  # column set. The bang variants (`find_by!` raises instead of returning nil; `find_or_create_by!`
@@ -35,9 +37,13 @@ module Rigor
35
37
 
36
38
  attr_reader :diagnostics
37
39
 
38
- def initialize(path:, model_index:)
40
+ # `scope` is the file's entry scope, which carries the project's discovered methods; it answers
41
+ # whether a model defines its own `self.find`. An editor run seeds none, so there the check misses
42
+ # even a same-file `self.find` the typer sees (#1329).
43
+ def initialize(path:, model_index:, scope:)
39
44
  @path = path
40
45
  @model_index = model_index
46
+ @scope = scope
41
47
  @diagnostics = []
42
48
  end
43
49
 
@@ -69,16 +75,42 @@ module Rigor
69
75
  end
70
76
  end
71
77
 
78
+ # A block turns `find` into `Enumerable#find` over `all` (see `Activerecord#block_find_return_type`),
79
+ # which takes no id, so the zero-argument check applies only to the block-less id lookup.
80
+ #
81
+ # The arity check and the notes describe Rails' `find`. A model's own `self.find` has its own arity,
82
+ # and the return-type tier (`Activerecord#finder_return_type`) leaves the multi-id and block forms to
83
+ # it, so a note there would claim a type the call does not get. One id still types as the model.
72
84
  def validate_find(node, entry)
85
+ return validate_block_find(node, entry) if node.block
86
+
73
87
  arity = call_argument_count(node)
74
88
  if arity.zero?
89
+ return if own_find?(entry)
90
+
75
91
  push_error(node, "wrong-arity",
76
92
  "`#{entry.class_name}.find` expects at least 1 argument, got 0")
77
93
  return
78
94
  end
95
+ return if arity >= 2 && own_find?(entry)
79
96
 
97
+ returned = arity >= 2 ? "Array[#{entry.class_name}]" : entry.class_name
80
98
  push_info(node, "model-call",
81
- "`#{entry.class_name}.find` returns #{entry.class_name} (table: `#{entry.table_name}`)")
99
+ "`#{entry.class_name}.find` returns #{returned} (table: `#{entry.table_name}`)")
100
+ end
101
+
102
+ def validate_block_find(node, entry)
103
+ return unless call_argument_count(node).zero?
104
+ return if own_find?(entry)
105
+
106
+ push_info(node, "model-call",
107
+ "`#{entry.class_name}.find` returns #{entry.class_name} | nil (table: `#{entry.table_name}`)")
108
+ end
109
+
110
+ # The predicate `Activerecord#finder_return_type` declines on, so the note and the type agree in a
111
+ # CLI run. In an editor run this scope lacks the file's own methods (#1329).
112
+ def own_find?(entry)
113
+ @scope.discovered_method_through_ancestors?(entry.class_name, :find, :singleton)
82
114
  end
83
115
 
84
116
  def validate_column_hash_call(node, entry)
@@ -38,32 +38,55 @@ module Rigor
38
38
 
39
39
  READ = ["io.db.read"].freeze
40
40
  WRITE = ["io.db.write"].freeze
41
+ READ_WRITE = ["io.db.read", "io.db.write"].freeze
41
42
  TRANSACTION = ["io.db.transaction"].freeze
42
43
  SCHEMA_WRITE = ["io.db.write", "rails.schema.write"].freeze
43
44
 
44
45
  # Class-side finders. Every one issues a `SELECT` the moment it is called — that is what separates
45
- # them from `where`, which returns a relation and issues nothing.
46
+ # them from `where`, which returns a relation and issues nothing. An `async_*` calculation schedules
47
+ # it on the async executor, or runs it at once when there is none. `first_or_initialize` is
48
+ # `first || new`, and `extract_associated` loads the association it preloads.
46
49
  SINGLETON_READS = %w[
47
50
  find find_by find_by! first first! last last! take take! sole find_sole_by
48
- second third fourth fifth forty_two second_to_last third_to_last
51
+ second second! third third! fourth fourth! fifth fifth! forty_two forty_two!
52
+ second_to_last second_to_last! third_to_last third_to_last!
49
53
  exists? any? none? one? many? empty? count sum average minimum maximum calculate
50
54
  pluck pick ids find_each find_in_batches in_batches find_by_sql count_by_sql
51
- find_or_initialize_by
55
+ find_or_initialize_by first_or_initialize extract_associated
56
+ async_ids async_count async_average async_minimum async_maximum async_sum async_pluck async_pick
52
57
  ].freeze
53
58
 
54
- # Class-side writers.
59
+ # Class-side writers that issue their statement and query nothing of their own: `create` is
60
+ # `new(…).save`, the bulk writers compile one `INSERT`, and the counter writers are an `update_all` on
61
+ # `unscoped`, which cannot eager-load.
55
62
  SINGLETON_WRITES = %w[
56
63
  create create! insert insert! insert_all insert_all! upsert upsert_all
57
- update update! update_all delete delete_all delete_by destroy destroy_all destroy_by
58
- find_or_create_by find_or_create_by! create_or_find_by create_or_find_by! touch_all
64
+ update_counters increment_counter decrement_counter
65
+ ].freeze
66
+
67
+ # Class-side writers that query as well, checked against activerecord 8.1.3.1. Most are in
68
+ # `Querying::QUERYING_METHODS`, which Rails delegates to `all`, so each is the Relation method of the
69
+ # same name and the row carries the read that `relation.rbs` gives it: a lookup before the write, a
70
+ # `find_by!` after a failed insert, the records `destroy_all` loads, or the `SELECT` of distinct
71
+ # primary keys an eager-loading, limited scope runs before its UPDATE / DELETE. `all` adds the default
72
+ # scope, which can eager-load. `delete` is `where(id:).delete_all`, and `destroy` calls `find`. Three
73
+ # are not delegated and read on their own: `update(!)` calls `find` or walks `all.each`, and
74
+ # `reset_counters` counts the association before it writes the column.
75
+ SINGLETON_READ_WRITES = %w[
76
+ update update! update_all delete delete_all delete_by destroy destroy_all destroy_by touch_all
77
+ find_or_create_by find_or_create_by! create_or_find_by create_or_find_by!
78
+ first_or_create first_or_create! reset_counters
59
79
  ].freeze
60
80
 
61
81
  # Instance-side readers. `reload` re-issues the `SELECT` and replaces the record's attributes,
62
82
  # which is a receiver mutation as well as a read.
63
83
  INSTANCE_READS = %w[valid? invalid? reload].freeze
64
84
 
65
- # Instance-side writers. Each is a statement issued now; `save`'s callbacks and validators are the
66
- # `effect_edges:` half, and arrive as edges rather than labels.
85
+ # Instance-side writers. Each is a statement issued now, and none queries on its own: `update` is
86
+ # `assign_attributes` plus `save`, and `increment!` / `update_columns` compile one UPDATE. What `save`
87
+ # or `destroy` reads beyond that comes from the model's callbacks and validators. The callback edge
88
+ # carries the ones it reads: symbol-argument callback macros and a uniqueness validator. Nothing
89
+ # carries the reads a required `belongs_to`, a `touch:` option or a `dependent:` option registers.
67
90
  INSTANCE_WRITES = %w[
68
91
  save save! update update! update_attribute update_attributes update_attributes!
69
92
  update_column update_columns destroy destroy! delete touch increment! decrement!
@@ -80,6 +103,26 @@ module Rigor
80
103
  all? any? none? one? include? member? first count sum
81
104
  ].freeze
82
105
 
106
+ # The writers an association's `CollectionProxy` defines, plus its `delete` / `destroy`, which
107
+ # override a plain Relation's own `delete(id_or_array)` / `destroy(id)`. Those are
108
+ # `where(id:).delete_all` and `find(id).destroy`, both inside this row's labels. Each adds records
109
+ # to the association's in-memory target or removes them from it. Depending on whether the owner is
110
+ # saved and on the association's `dependent:` option, it may also read, write, and open a
111
+ # transaction, so the row names all three rather than the parent `io.db`, which
112
+ # `--label io.db.write` would not match.
113
+ #
114
+ # The mutation is spelt from the call site, as every row here is, and bare `mutate` is the label
115
+ # for a receiver that is not the caller's `self`: the proxy is an object the caller holds, like the
116
+ # session in rigor-actionpack's `SESSION_WRITE`. The RBS envelopes on `build` / `reset` are written
117
+ # from the callee's side instead, and say `mutate.self`.
118
+ #
119
+ # A saved owner's `<<` saves the record, which runs the model's `before_save` / `after_commit`
120
+ # callbacks. No edge carries those to the caller, which is the same gap `Relation#create` has: the
121
+ # call site's receiver is `Relation[Post]`, and the edge that would reach `Post#save` needs the type
122
+ # argument, which the collector does not record (#1313).
123
+ PROXY_WRITERS = %w[<< push append concat replace delete destroy clear].freeze
124
+ PROXY_WRITE = ["io.db.read", "io.db.write", "io.db.transaction", "mutate"].freeze
125
+
83
126
  # Raw SQL, narrowed by the statement's own leading verb ({Rigor::Effects::Narrowing} `sql_verb`).
84
127
  ADAPTER_SQL = %w[execute exec_query exec_insert exec_update exec_delete select_all select_one
85
128
  select_value select_values select_rows query query_value query_values].freeze
@@ -103,6 +146,12 @@ module Rigor
103
146
  # Ambient AR calls that are neither a read nor a write of rows.
104
147
  TRANSACTIONAL = %w[transaction with_lock lock!].freeze
105
148
 
149
+ # The instance-side locks re-read the row: `lock!` is `reload(lock:)`, a `SELECT … FOR UPDATE`, and
150
+ # `with_lock` runs it inside a transaction. `lock!` opens no transaction of its own, so on it the
151
+ # transaction label names the one whose end releases the lock, and over-states a `lock!` outside one.
152
+ INSTANCE_LOCKS = %w[with_lock lock!].freeze
153
+ LOCK = ["io.db.read", "io.db.transaction"].freeze
154
+
106
155
  module_function
107
156
 
108
157
  def attributions
@@ -114,7 +163,11 @@ module Rigor
114
163
  why: "issues the SELECT at the call — this is the materializing " \
115
164
  "half of the builder/materializer split") +
116
165
  rows(BASE, SINGLETON_WRITES, WRITE, singleton: true,
117
- why: "issues an INSERT / UPDATE / DELETE at the call") +
166
+ why: "issues an INSERT at the call and queries nothing first") +
167
+ rows(BASE, SINGLETON_READ_WRITES, READ_WRITE,
168
+ singleton: true,
169
+ why: "issues an UPDATE / DELETE / INSERT at the call, and a SELECT before it or after " \
170
+ "a failed insert") +
118
171
  rows(BASE, TRANSACTIONAL, TRANSACTION, singleton: true,
119
172
  why: "opens a transaction; the block's own origins join " \
120
173
  "by containment, so the row states only the BEGIN")
@@ -125,14 +178,21 @@ module Rigor
125
178
  why: "re-reads the row (`reload`) or runs the validators, whose uniqueness checks query") +
126
179
  rows(BASE, INSTANCE_WRITES, WRITE,
127
180
  why: "persists the record — the write a `db: none` envelope is written to catch") +
128
- rows(BASE, TRANSACTIONAL, TRANSACTION,
129
- why: "opens a transaction / takes a row lock around the block")
181
+ rows(BASE, TRANSACTIONAL - INSTANCE_LOCKS, TRANSACTION, why: "opens a transaction around the block") +
182
+ rows(BASE, INSTANCE_LOCKS, LOCK,
183
+ why: "re-reads the row with `SELECT … FOR UPDATE`; `with_lock` does it inside a transaction")
130
184
  end
131
185
 
132
186
  def relation_rows
133
187
  rows(RELATION, RELATION_MATERIALIZERS, READ,
134
188
  why: "an Enumerable delegation on a Relation: it calls `each`, which runs the query. Not " \
135
- "declared in the bundled RBS because declaring it would change how it types")
189
+ "declared in the bundled RBS because declaring it would change how it types") +
190
+ rows(RELATION, PROXY_WRITERS, PROXY_WRITE,
191
+ why: "a CollectionProxy writer: it changes the association's target and, for a saved " \
192
+ "owner, the rows behind it. Keyed on Relation because that is the type the plugin " \
193
+ "gives an association reader. A plain Relation either has no such method or, through " \
194
+ "its own `delete` / `destroy`, deletes by id or finds and destroys, which stays inside " \
195
+ "the bound. The model's save callbacks are not edged")
136
196
  end
137
197
 
138
198
  # Two rows per selector, because raw SQL is written two ways and only one of them names a type.