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
@@ -6,6 +6,11 @@ require_relative "actionpack/analyzer"
6
6
  require_relative "actionpack/effects"
7
7
  require_relative "actionpack/controller_discoverer"
8
8
  require_relative "actionpack/controller_index"
9
+ require_relative "actionpack/controller_scan"
10
+ require_relative "actionpack/erb_compiler"
11
+ require_relative "actionpack/render_locals"
12
+ require_relative "actionpack/view_assigns"
13
+ require_relative "actionpack/view_units"
9
14
 
10
15
  module Rigor
11
16
  module Plugin
@@ -69,23 +74,58 @@ module Rigor
69
74
  # Bumped 2026-09-02 (#621) — a reopened controller's `def`s are UNIONed rather than clobbered by
70
75
  # the later file in the glob, so a cached 1.0.0 index can be missing the filter targets the merge
71
76
  # restores.
77
+ # Bumped 2026-09-17 (#393) — the plugin gained `template_globs:` and compiles `app/views/**/*.erb`
78
+ # into template units, so the answer for a project with views is a different answer entirely and
79
+ # a cached 1.2.0 run has no view rows in it.
72
80
  # Bumped 2026-09-10 (#534 item 7) — the plugin gained a bundled `sig/` (ADR-25) naming the
73
81
  # `ActionController` namespace and the errors controllers rescue, so the constants stop resolving
74
82
  # to `untyped`. `ActionController::Parameters` and the `ActionDispatch` readers stay undeclared;
75
83
  # `sig/action_controller.rbs` documents why that absence is load-bearing.
76
- version: "1.2.0",
84
+ # Bumped 2026-09-17 (#1047) — the plugin now traces render-site `locals:` into a partial's unit and
85
+ # compiles LAYOUTS (a `yield` rewrite), so a cached 1.3.0 run is missing both the layout units and
86
+ # every seeded local.
87
+ # Bumped 2026-09-18 (#1065) — the claim grew the non-ERB handlers, which the plugin declines and
88
+ # the engine reads as "a template exists here and has no unit". The bump is belt-and-braces: what
89
+ # actually invalidates is the claim itself, through the glob row in `TemplateUnits#digest` and the
90
+ # `claimed_globs` equality the carry checks. A manifest version is in no cache key of its own.
91
+ version: "1.5.0",
77
92
  description: "Validates Action Pack route-helper calls and filter chains inside controllers, and types the request-context readers (`params` / `session` / `request` / `flash`) and their chains.",
78
93
  config_schema: {
79
94
  "controller_search_paths" => { kind: :array, default: ["app/controllers"] },
80
- "view_search_paths" => { kind: :array, default: ["app/views"] }
95
+ "view_search_paths" => { kind: :array, default: ["app/views"] },
96
+ # #393 — whether the ordinary `call.*` type checks are reported INSIDE a compiled template. The
97
+ # `flow.*` family is no longer gated on this knob: #1047 removed the binding gap that made it
98
+ # unsound in a view, and it reports by default like every other family.
99
+ # Off by default: the synthesised `self` is an open receiver and the seeds are deliberately
100
+ # narrow, so the typing half of a view unit is not yet precise enough to be worth a diagnostic
101
+ # per template line, while the EFFECTS half needs no receiver precision at all and is what the
102
+ # view layer is analysed for first. A project that wants `@user.nmae` in `show.html.erb` can
103
+ # turn it on; see `docs/manual/plugins/rigor-actionpack.md`.
104
+ "view_type_checks" => { kind: :boolean, default: false }
81
105
  },
82
106
  consumes: [
83
107
  { plugin_id: "rails-routes", name: :helper_table, optional: true },
84
108
  { plugin_id: "activerecord", name: :model_index, optional: true }
85
109
  ],
86
110
  # ADR-25 (#534 item 7) — the framework-namespace signatures. See `sig/action_controller.rbs` for
87
- # the admission rule and, more importantly, for what it refuses to declare.
111
+ # the admission rule and, more importantly, for what it refuses to declare, and
112
+ # `sig/action_view.rbs` for the one class a template unit's `self` is typed as.
88
113
  signature_paths: ["sig"],
114
+ # #393 — the ERB templates this plugin compiles into {Plugin::TemplateUnit}s. A manifest row is
115
+ # read without running plugin code, so it cannot consult `view_search_paths:`; a project that
116
+ # moves its views away from `app/views` gets no units, which is the quiet answer rather than a
117
+ # wrong one. Both `show.html.erb` and the bare `show.erb` shape are claimed.
118
+ # #1065 — the non-ERB handlers are claimed and DECLINED. Nothing here is compiled, and
119
+ # {#template_units_for_file} returns `[]` for every one of them; what the claim buys is the
120
+ # engine knowing a template exists at that logical name, which is what stops a `.js` render's
121
+ # format fallback from resolving past `_row.js.haml` onto `_row.html.erb`. Action View runs the
122
+ # Haml file. The list is the handlers a Rails app actually keeps under `app/views` — the common
123
+ # third-party template gems, plus Action View's own `builder` and `ruby`. It is NOT exhaustive:
124
+ # `raw` and `html` are Action View handlers too and are deliberately left out, because a claim on
125
+ # `*.html` would key a file whose name carries no format at all. A handler nobody claims is
126
+ # invisible, and its fallback behaves as it did before this claim existed.
127
+ template_globs: ["app/views/**/*.erb",
128
+ "app/views/**/*.{haml,slim,jbuilder,builder,rabl,ruby}"],
89
129
  # ADR-26 — every class the bundled signature names is declared so the CONSTANT resolves; none of
90
130
  # them enumerates a method surface, so `ParameterMissing#param` and its siblings must stay
91
131
  # lenient rather than becoming `call.undefined-method` on a rescue body.
@@ -101,7 +141,10 @@ module Rigor
101
141
  "ActionController::InvalidCrossOriginRequest",
102
142
  "ActionController::MissingExactTemplate",
103
143
  "ActionController::ParameterMissing",
104
- "ActionController::UnpermittedParameters"
144
+ "ActionController::UnpermittedParameters",
145
+ # #393 — the view context every template unit's `self` is typed as. Declared in
146
+ # `sig/action_view.rbs` so the name resolves, open so the helper surface stays lenient.
147
+ "ActionView::Base"
105
148
  ],
106
149
  # ADR-103 WD10 / WD14 (#387) — see {Effects} for what each row is and why.
107
150
  effect_root: "rails",
@@ -123,10 +166,202 @@ module Rigor
123
166
  ).discover
124
167
  end
125
168
 
169
+ # #393 / #1047 — the diagnostic family a compiled template does not report while `view_type_checks:`
170
+ # is off (its default). Measured rather than assumed, on the private corpus copies recorded in
171
+ # `docs/notes/20260917-erb-template-units.md` and `docs/notes/20260917-render-locals-and-layouts.md`.
172
+ #
173
+ # `call.` is the receiver-precision family. `self` is an open `ActionView::Base` and the seeds are
174
+ # deliberately narrow, so what this family would report about a view is mostly a statement about what
175
+ # the synthesis does not know yet.
176
+ #
177
+ # **`flow.` was here and is not any more (#1047).** It was suppressed for one measured reason: with
178
+ # no render-site `locals:`, redmine's standard optional-local preamble
179
+ # (`<% path = nil unless defined? path %>` in `app/views/common/_other.html.erb`) really did assign
180
+ # nil in the compiled Ruby, and the flow rules folded three live branches on a partial Rails renders
181
+ # correctly. {RenderLocals} binds those names from the three sites that render that partial, the
182
+ # preamble stops being a fresh assignment, and the corpus re-measured with `flow.` UNSUPPRESSED is
183
+ # byte-identical to the corpus with it suppressed — on redmine (506 templates, layouts included) and
184
+ # on mastodon (46). The suppression was a stand-in for a missing binding, so it goes when the binding
185
+ # arrives rather than staying as a habit.
186
+ SUPPRESSED_VIEW_RULES = ["call."].freeze
187
+
188
+ # #1047 — where the project's view helpers live, read only for the names they `def`: a template's
189
+ # `defined?(current_user)` tests a HELPER, not an optional local, and must not seed one
190
+ # ({ViewUnits.self_declared_locals}). Rails' own convention and not a config knob, for the reason
191
+ # `template_globs:` is not one either.
192
+ HELPER_SEARCH_PATHS = ["app/helpers"].freeze
193
+
126
194
  def init(_services)
127
195
  @controller_search_paths = Array(config.fetch("controller_search_paths")).map(&:to_s)
128
196
  @view_search_paths = Array(config.fetch("view_search_paths")).map(&:to_s)
197
+ @view_type_checks = config.fetch("view_type_checks") ? true : false
198
+ end
199
+
200
+ # #393 — one ERB template compiled into one {Plugin::TemplateUnit}. Called once per matched file, on
201
+ # the parent, before any analysis (ADR-16 Tier D as revived by #392).
202
+ #
203
+ # The three synthesised bindings and where each comes from:
204
+ #
205
+ # - `self_type:` — {ViewUnits::SELF_TYPE}, a declared-but-open `ActionView::Base`, so every helper
206
+ # call resolves lenient rather than drawing a finding per line.
207
+ # - `locals:` — the names every RENDER SITE passes ({RenderLocals}, #1047), overlaid with the Rails
208
+ # 7.1 strict-locals comment where a template carries one. The seed is what makes Prism read `user`
209
+ # as a local rather than as a method call, which is what a `defined? user` preamble needs.
210
+ # - `ivar_seeds:` — the assigns of the controller actions that render this template, through
211
+ # {ViewAssigns}. A partial has none: its bindings belong to the render site.
212
+ #
213
+ # A compiler whose line numbering could not be measured declines the file rather than reporting at
214
+ # lines it cannot vouch for — {ErbCompiler::Unmappable} propagates, and the seam turns it into one
215
+ # `:plugin_loader` `runtime-error` row naming the template.
216
+ #
217
+ # ## Compiled Ruby that does not parse declines the file, silently
218
+ #
219
+ # A template is not Ruby, and the compiled form of one is not always Ruby either: a `case` split
220
+ # across tags with markup between the `case` and its first `when` is invalid anywhere. Handing such a
221
+ # body to the engine would put TWO parse diagnostics on a template Rails renders perfectly — a false
222
+ # positive per file, which is the one outcome this feature may not have (AGENTS.md). So the compiled
223
+ # Ruby is parsed and a body that does not parse declines the file through the seam's own `[]` door:
224
+ # no unit, no diagnostic, no effects.
225
+ #
226
+ # A LAYOUT used to be the bulk of that residue — `<%= yield %>` is legal ERB and illegal Ruby outside
227
+ # a method — and since #1047 it is not: {ErbCompiler::YIELD_METHOD} rewrites the keyword into a call
228
+ # on the view context, so all four of redmine's layouts and both of mastodon's compile. The residue
229
+ # is one `case` under stdlib ERB and none under Erubi.
230
+ #
231
+ # The cost is one Prism parse per template on the parent, and since #1047 both that parse and the
232
+ # compile are the ones {RenderLocals} already performed while building its index.
233
+ def template_units_for_file(path:, source:)
234
+ # #1065 — a claimed non-ERB handler is read for its NAME and nothing else: this plugin compiles
235
+ # ERB, and a `.haml` body handed to an ERB compiler would be garbage rather than a decline.
236
+ return [] unless path.end_with?(ERB_SUFFIX)
237
+
238
+ name = ViewUnits.logical_name(path, @view_search_paths)
239
+ # Scrubbed ONCE, here, and handed to both readers. Scrubbing inside the compiler alone left
240
+ # `ViewUnits.strict_locals` matching a Regexp against the raw bytes, and a single invalid byte
241
+ # anywhere in a template carrying a strict-locals comment raised out of the hook — which the seam
242
+ # correctly turns into an `error`-severity `:plugin_loader` row, so one mis-encoded view failed
243
+ # the whole run.
244
+ text = ErbCompiler.scrub(source)
245
+ revalidate_indexes
246
+ compiled, line_map, transform, parsed = render_locals.compiled_for(path, text) || compile_now(text)
247
+ return [] unless parsed
248
+
249
+ [
250
+ Rigor::Plugin::TemplateUnit.new(
251
+ logical_name: name, path: path, ruby_source: compiled, line_map: line_map,
252
+ self_type: ViewUnits::SELF_TYPE,
253
+ locals: template_locals(name, text),
254
+ ivar_seeds: view_assigns.seeds_for(name), transform_id: transform,
255
+ suppressed_rules: @view_type_checks ? [] : SUPPRESSED_VIEW_RULES
256
+ )
257
+ ]
258
+ end
259
+
260
+ # The compile the {RenderLocals} cache could not answer — an editor's in-flight bytes, or a template
261
+ # the index never globbed. Same four values the cache carries.
262
+ # The one handler this plugin compiles; every other claimed template is declined for its name.
263
+ ERB_SUFFIX = ".erb"
264
+
265
+ def compile_now(text)
266
+ compiled, line_map, transform = ErbCompiler.compile(text)
267
+ [compiled, line_map, transform, Prism.parse(compiled).errors.empty?]
268
+ end
269
+ private :compile_now
270
+
271
+ # A template's locals, from three sources that each say a name is bound, weakest first so the
272
+ # strongest wins a type: every name the template itself tests for with `defined?` or `local_assigns`
273
+ # (its author's own optional-local declaration, which needs no render site — #1047's review found
274
+ # the render-site index alone misses a `locals: opts` site, a helper-side `render` and a local no
275
+ # site passes at all), the render sites {RenderLocals} could read, and the strict-locals comment.
276
+ def template_locals(name, text)
277
+ ViewUnits.self_declared_locals(text, helpers: render_locals.helper_methods)
278
+ .merge(render_locals.seeds_for(name))
279
+ .merge(ViewUnits.strict_locals(text))
280
+ end
281
+ private :template_locals
282
+
283
+ # #1047 — the two parent-side indexes are memoised on this instance, and a long-lived
284
+ # `LanguageServer::ProjectContext` keeps the instance across publishes. A memo that is never
285
+ # revalidated keeps a render site's locals after the site changed on disk, which is a `flow.` row
286
+ # on a correct partial until the process restarts.
287
+ #
288
+ # So the indexes are revalidated once per COLLECTION PASS, against a fingerprint of every controller,
289
+ # helper and template they read ({RenderLocals::Builder.fingerprint}: one glob and one `stat` per
290
+ # file). The collector announces a pass through {#template_units_pass_started}; the check itself is
291
+ # deferred to the pass's first `#template_units_for_file`, so a warm pass that carries every unit and
292
+ # compiles nothing pays nothing. Inferring the pass from the order of paths was tried and is not
293
+ # enough: a warm pass offers only the editor's buffer, so switching buffers after a render site
294
+ # changed on disk was served from the stale index whichever way the two paths sorted.
295
+ #
296
+ # Revalidating at the FIRST call of the pass rather than when an edited template's own bytes arrive is
297
+ # load-bearing too: `_card.html.erb` globs before `show.html.erb`, so a reset triggered by `show` would
298
+ # come after `_card`'s unit had already been built from the stale index. The collector's whole-claim
299
+ # carry decision ({Analysis::TemplateUnitCollector}) is the other half: it is what re-offers `_card`.
300
+ def template_units_pass_started
301
+ @index_pass_pending = true
302
+ end
303
+
304
+ def revalidate_indexes
305
+ return unless @index_pass_pending
306
+
307
+ @index_pass_pending = false
308
+ drop_indexes unless indexes_fresh?
309
+ end
310
+ private :revalidate_indexes
311
+
312
+ def indexes_fresh?
313
+ return true if @render_locals.nil?
314
+
315
+ @render_locals.fingerprint == index_fingerprint
316
+ end
317
+ private :indexes_fresh?
318
+
319
+ def drop_indexes
320
+ @render_locals = nil
321
+ @view_assigns = nil
322
+ end
323
+ private :drop_indexes
324
+
325
+ def index_fingerprint
326
+ RenderLocals::Builder.fingerprint(
327
+ io_boundary: io_boundary, controller_search_paths: @controller_search_paths,
328
+ view_search_paths: @view_search_paths, helper_search_paths: HELPER_SEARCH_PATHS
329
+ )
330
+ rescue StandardError
331
+ nil
332
+ end
333
+ private :index_fingerprint
334
+
335
+ # The controller-assigns index, built once per run on the parent. Deliberately NOT a `producer` —
336
+ # a producer's value is cached against the run key, and this is consulted from the template-unit
337
+ # hook, which runs before the analysis a producer belongs to.
338
+ def view_assigns
339
+ @view_assigns ||= begin
340
+ ViewAssigns::Builder.new(io_boundary: io_boundary, search_paths: @controller_search_paths).build
341
+ rescue StandardError
342
+ ViewAssigns.empty
343
+ end
344
+ end
345
+ private :view_assigns
346
+
347
+ # #1047 — the render-site `locals:` index, and the compiled source every template was read through
348
+ # while it was built. Lazy and parent-only for the same reason {#view_assigns} is: it answers a
349
+ # question the analysis has not begun to ask yet. See {RenderLocals} for what a site contributes, and
350
+ # {#revalidate_indexes} for when a memoised index is dropped. A builder that raised is memoised as
351
+ # the empty index too — otherwise every hook call re-globbed and recompiled the project to fail
352
+ # again — and its nil fingerprint never equals a real one, so the next pass retries it.
353
+ def render_locals
354
+ @render_locals ||= begin
355
+ RenderLocals::Builder.new(
356
+ io_boundary: io_boundary, controller_search_paths: @controller_search_paths,
357
+ view_search_paths: @view_search_paths, helper_search_paths: HELPER_SEARCH_PATHS,
358
+ view_assigns: view_assigns
359
+ ).build
360
+ rescue StandardError
361
+ RenderLocals.empty
362
+ end
129
363
  end
364
+ private :render_locals
130
365
 
131
366
  # ADR-37 — the four Action Pack phases run per-call over the engine-owned walk. Each rule gates on
132
367
  # `controller_file?(path)` (the plugin only validates files under `controller_search_paths`,
@@ -0,0 +1,43 @@
1
+ # Action View's namespace — issue #393.
2
+ #
3
+ # `ActionView::Base` is the `self_type:` of every ERB template unit the plugin compiles. It is declared
4
+ # here for one reason: so that the NAME resolves. `Analysis::TemplateUnits#seed` binds an unresolvable
5
+ # `self_type:` to `Dynamic[top]`, which is silent but also tells the engine nothing, and a declared
6
+ # receiver is what lets a later slice put a real helper surface behind it.
7
+ #
8
+ # ## Why it enumerates nothing
9
+ #
10
+ # The same argument `action_controller.rbs` makes about `ActionController::Parameters`, one step stronger.
11
+ # A template's implicit-self calls are `link_to`, `form_with`, `t`, `l`, `cache`, `content_for`,
12
+ # `image_tag`, `turbo_frame_tag`, every `*_path` / `*_url` route helper, every method in the project's own
13
+ # `ApplicationHelper` and `<Controller>Helper`, every `helper_method` a controller exported, and whatever
14
+ # the project's gems mixed in. No signature completes that surface, and a declared class DROPS every
15
+ # member its signature omits — so enumerating the ten helpers the design note names would turn the other
16
+ # thousand into `call.undefined-method` on working templates.
17
+ #
18
+ # So the class is named and left empty, and `open_receivers:` in the manifest (ADR-26) keeps its whole
19
+ # method surface lenient. That is the "lenient / open receivers, never closed" posture stated as code.
20
+ #
21
+ # `ActionView::UnknownLocal` is deliberately ABSENT — see `ViewUnits::UNKNOWN_LOCAL`. It is the name a
22
+ # strict-locals parameter is seeded with precisely because nothing resolves it, so the engine binds
23
+ # `Dynamic[top]` while the local's NAME still reaches the parse.
24
+
25
+ # ## The one declared member, and why it is not a helper
26
+ #
27
+ # `__rigor_yield` is issue #1047's reading of a LAYOUT's `yield`. `<%= yield %>` is legal ERB and illegal
28
+ # Ruby outside a method body, so `ErbCompiler` rewrites the keyword into a call on this receiver before
29
+ # either compiler runs — the template-unit seam does not wrap a unit's body in a synthesised method, so the
30
+ # body has to parse as written. The name is not Rails', is not reachable from any template a user writes,
31
+ # and exists only so the rewritten body resolves.
32
+ #
33
+ # It returns `String` and nothing narrower. Rails' `yield` hands back whatever the inner template's output
34
+ # buffer holds and `yield :sidebar` hands back a `content_for` buffer (an empty `SafeBuffer`, never nil,
35
+ # when nothing was provided); a lenient `String` is the
36
+ # widest honest reading of that, and it is what keeps a layout from seeding a type the analyzer could not
37
+ # justify. `*untyped` covers `yield`, `yield :sidebar` and `yield(:sidebar)` alike.
38
+
39
+ module ActionView
40
+ class Base
41
+ def __rigor_yield: (*untyped) -> String
42
+ end
43
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/source/node_children"
4
+
5
+ require "prism"
6
+
7
+ require_relative "serializer_index"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ class ActiveModelSerializers < Rigor::Plugin::Base
12
+ # Walks the configured serializer-search paths through the plugin's `IoBoundary`, parses each `.rb`
13
+ # file with Prism, records every `class X < Y` it finds with the facts the derivation needs, and
14
+ # keeps the classes whose ancestry closes back to a configured root:
15
+ #
16
+ # class ApplicationSerializer < ActiveModel::Serializer # root child
17
+ # class REST::AccountSerializer < ApplicationSerializer # reached through it
18
+ #
19
+ # The closure is what a direct-superclass match (the shape `rigor-pundit`'s policy discoverer uses)
20
+ # would miss, and a project base serializer is the common case rather than the exotic one. It is
21
+ # also the ONLY gate: a class the closure does not reach is not a serializer here, whatever it is
22
+ # called, because `object` is an ordinary name that any class may define.
23
+ #
24
+ # Every class in the scanned tree is recorded first and filtered afterwards, because the file that
25
+ # defines a base serializer may be parsed after the files that inherit from it.
26
+ class SerializerDiscoverer
27
+ # A `class << self` body is class-level for the same reason a `def self.x` body is, and it is a
28
+ # separate node type: without this arm a receiver-less `def object` inside one would register as
29
+ # an instance method, and an `object.<name>` there as a read of the resource.
30
+ SKIPPED_SCOPES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
31
+
32
+ # AMS's resource-backed declaration macros. Each names something the serializer renders by
33
+ # calling it on the resource, unless the serializer defines a method of that name itself.
34
+ DECLARATION_MACROS = %i[attributes attribute has_many has_one belongs_to].freeze
35
+
36
+ # Macros that DEFINE a method on the serializer rather than declaring one to read off the
37
+ # resource. `delegate :object, to: :wrapper` and `attr_reader :object` are both real ways to
38
+ # override AMS's reader, and neither writes a `def` for an ancestor walk to find; a name defined
39
+ # this way is also not a name the resource has to answer.
40
+ DEFINITION_MACROS = %i[attr_reader attr_accessor attr_writer delegate alias_method].freeze
41
+
42
+ # `object.tap`, `object.nil?`, `object.is_a?` and their siblings say nothing about which class the
43
+ # resource is, so they are not evidence.
44
+ #
45
+ # Listed rather than read from `Object.instance_methods`, which was the first shape and is
46
+ # process-dependent: `to_json`, `to_yaml` and `pretty_print` are on Object only because some
47
+ # library in the ANALYSER's process required them, so whether a serializer derived could change
48
+ # with Rigor's own load order. These are the public instance methods every Ruby object has from
49
+ # `Object` / `Kernel` alone.
50
+ UNIVERSAL_METHODS = %w[
51
+ ! != !~ <=> == === =~ __id__ __send__ class clone define_singleton_method display dup
52
+ enum_for eql? equal? extend freeze frozen? hash inspect instance_of? instance_variable_defined?
53
+ instance_variable_get instance_variable_set instance_variables is_a? itself kind_of? method
54
+ methods nil? object_id private_methods public_method public_methods public_send
55
+ remove_instance_variable respond_to? send singleton_class singleton_method singleton_methods
56
+ taint tainted? tap then to_enum to_s trust untaint untrust untrusted? yield_self
57
+ ].to_set.freeze
58
+
59
+ def initialize(io_boundary:, search_paths:, base_classes:)
60
+ @io_boundary = io_boundary
61
+ @search_paths = search_paths
62
+ @base_classes = base_classes.map { |name| name.to_s.delete_prefix("::") }
63
+ end
64
+
65
+ def discover
66
+ candidates = []
67
+ ruby_files_under(@search_paths).each do |path|
68
+ contents = read_safely(path)
69
+ next if contents.nil?
70
+
71
+ walk(Prism.parse(contents).value, []) do |entry_fields|
72
+ candidates << SerializerIndex::Entry.new(file_path: path, **entry_fields)
73
+ end
74
+ end
75
+ SerializerIndex.new(close_over_roots(candidates))
76
+ end
77
+
78
+ private
79
+
80
+ # Keeps only the classes whose declared-superclass chain reaches one of the roots. Iterating to a
81
+ # fixed point rather than recursing keeps a cyclic `class A < B; class B < A` source — which Ruby
82
+ # rejects but a static parse can still be handed — from recursing forever.
83
+ def close_over_roots(candidates)
84
+ by_name = candidates.to_h { |entry| [entry.class_name, entry] }
85
+ reached = @base_classes.to_set
86
+ loop do
87
+ grown = candidates.select do |entry|
88
+ !reached.include?(entry.class_name) && reached.include?(entry.superclass_name)
89
+ end
90
+ break if grown.empty?
91
+
92
+ grown.each { |entry| reached << entry.class_name }
93
+ end
94
+ reached.filter_map { |name| by_name[name] }
95
+ end
96
+
97
+ def read_safely(path)
98
+ @io_boundary.read_file(path)
99
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
100
+ nil
101
+ end
102
+
103
+ def ruby_files_under(roots)
104
+ roots.flat_map do |root|
105
+ absolute = File.expand_path(root)
106
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
107
+ next [] unless @io_boundary.directory?(absolute)
108
+
109
+ Dir.glob(File.join(absolute, "**", "*.rb"))
110
+ end
111
+ end
112
+
113
+ def walk(node, lexical_path, &)
114
+ return if node.nil?
115
+
116
+ case node
117
+ when Prism::ClassNode then visit_class(node, lexical_path, &)
118
+ when Prism::ModuleNode then visit_module(node, lexical_path, &)
119
+ else
120
+ node.rigor_each_child { |child| walk(child, lexical_path, &) }
121
+ end
122
+ end
123
+
124
+ def visit_class(node, lexical_path, &)
125
+ local_name = constant_path_name(node.constant_path)
126
+ return if local_name.nil?
127
+
128
+ superclass = node.superclass ? constant_path_name(node.superclass) : nil
129
+ unless superclass.nil?
130
+ yield({ class_name: qualify(lexical_path, local_name),
131
+ superclass_name: superclass.delete_prefix("::"),
132
+ **collect_facts(node.body) })
133
+ end
134
+
135
+ walk(node.body, lexical_path + [local_name], &) if node.body
136
+ end
137
+
138
+ def visit_module(node, lexical_path, &)
139
+ local_name = constant_path_name(node.constant_path)
140
+ return if local_name.nil?
141
+
142
+ walk(node.body, lexical_path + [local_name], &) if node.body
143
+ end
144
+
145
+ # The three name sets, gathered in ONE pass over the class body. A nested class or module is not
146
+ # descended into — its `def`s and its `object` reads belong to it, not to this class.
147
+ def collect_facts(body)
148
+ facts = { declared_names: Set.new, object_reads: Set.new, own_method_names: Set.new }
149
+ collect_from(body, facts)
150
+ facts.transform_values { |set| set.to_a.sort.freeze }
151
+ end
152
+
153
+ def collect_from(node, facts)
154
+ return if node.nil? || SKIPPED_SCOPES.any? { |kind| node.is_a?(kind) }
155
+
156
+ case node
157
+ when Prism::DefNode
158
+ # A `def self.x` body is class-level: its `object` would be a NoMethodError at run time, so
159
+ # neither its name nor its reads are evidence about the resource.
160
+ return unless node.receiver.nil?
161
+
162
+ facts[:own_method_names] << node.name.to_s
163
+ when Prism::CallNode then record_call(node, facts)
164
+ end
165
+ node.rigor_each_child { |child| collect_from(child, facts) }
166
+ end
167
+
168
+ def record_call(node, facts)
169
+ if node.receiver.nil? && DECLARATION_MACROS.include?(node.name)
170
+ symbol_arguments(node).each { |name| facts[:declared_names] << name }
171
+ elsif node.receiver.nil? && DEFINITION_MACROS.include?(node.name)
172
+ symbol_arguments(node).each { |name| facts[:own_method_names] << name }
173
+ elsif object_reader?(node.receiver) && !UNIVERSAL_METHODS.include?(node.name.to_s)
174
+ facts[:object_reads] << node.name.to_s
175
+ end
176
+ end
177
+
178
+ def object_reader?(receiver)
179
+ receiver.is_a?(Prism::CallNode) && receiver.name == :object && receiver.receiver.nil? &&
180
+ receiver.arguments.nil?
181
+ end
182
+
183
+ def symbol_arguments(node)
184
+ (node.arguments&.arguments || []).filter_map do |argument|
185
+ argument.unescaped if argument.is_a?(Prism::SymbolNode)
186
+ end
187
+ end
188
+
189
+ # `class REST::AccountSerializer` inside no module is already fully qualified; a class written as
190
+ # `class AccountSerializer` inside `module REST` is not. A name the source rooted explicitly
191
+ # (`class ::AccountSerializer`) keeps neither the root marker nor the lexical prefix.
192
+ def qualify(lexical_path, local_name)
193
+ return local_name.delete_prefix("::") if local_name.start_with?("::")
194
+
195
+ (lexical_path + [local_name]).join("::")
196
+ end
197
+
198
+ def constant_path_name(node)
199
+ case node
200
+ when Prism::ConstantReadNode then node.name.to_s
201
+ when Prism::ConstantPathNode then constant_path_parts(node)
202
+ end
203
+ end
204
+
205
+ def constant_path_parts(node)
206
+ parts = []
207
+ current = node
208
+ while current.is_a?(Prism::ConstantPathNode)
209
+ parts.unshift(current.name.to_s)
210
+ current = current.parent
211
+ end
212
+ case current
213
+ when nil then "::#{parts.join('::')}"
214
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class ActiveModelSerializers < Rigor::Plugin::Base
6
+ # The serializer classes {SerializerDiscoverer} found under the configured search paths, keyed by
7
+ # the class's fully-qualified name as the source spells it (`"REST::AccountSerializer"`).
8
+ #
9
+ # Each entry carries the declared superclass (so the discoverer can close the ancestry chain) and
10
+ # the three name sets that let a candidate model be CHECKED rather than guessed:
11
+ #
12
+ # - `declared_names` — the names the serializer will read off the resource at render time: the
13
+ # symbols of `attributes` / `attribute` / `has_many` / `has_one` / `belongs_to`, minus the ones
14
+ # the serializer defines itself (AMS calls the serializer's own method when it has one, and only
15
+ # falls through to `object.<name>` when it does not).
16
+ # - `object_reads` — every `object.<name>` the body writes, minus the methods every Object has.
17
+ # - `own_method_names` — the serializer's own instance `def`s, which is both what subtracts from
18
+ # `declared_names` and how an explicit `def object` is detected.
19
+ class SerializerIndex
20
+ Entry = Data.define(:class_name, :superclass_name, :file_path, :declared_names, :object_reads,
21
+ :own_method_names) do
22
+ # The declarations AMS will render by calling the name on the resource — unless an ancestor of
23
+ # this serializer defines it, which only the engine's ancestor walk can say, so that half is
24
+ # left to the caller.
25
+ def unhandled_declarations = declared_names - own_method_names
26
+
27
+ # Reads of the resource itself. Unlike a declaration, one of these is a read of the resource
28
+ # whatever the serializer or its ancestors define.
29
+ def resource_reads = object_reads
30
+
31
+ def defines_object? = own_method_names.include?("object")
32
+ end
33
+
34
+ attr_reader :entries
35
+
36
+ def initialize(entries)
37
+ @entries = entries.freeze
38
+ @by_name = entries.to_h { |entry| [entry.class_name, entry] }.freeze
39
+ freeze
40
+ end
41
+
42
+ def find(class_name) = @by_name[derooted(class_name)]
43
+ def known?(class_name) = @by_name.key?(derooted(class_name))
44
+ def empty? = @entries.empty?
45
+ def size = @entries.size
46
+ def names = @by_name.keys
47
+
48
+ private
49
+
50
+ # A query may arrive rooted (`::REST::AccountSerializer`) while entries are keyed by the
51
+ # de-rooted spelling, the same normalisation `rigor-activerecord`'s ModelIndex settled on in #583.
52
+ def derooted(class_name) = class_name.to_s.delete_prefix("::")
53
+ end
54
+ end
55
+ end
56
+ end