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,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "controller_scan"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Actionpack < Rigor::Plugin::Base
10
+ # #393 — which controller action renders which template, and what it assigned.
11
+ #
12
+ # A template unit's `ivar_seeds:` are the controller's assigns, and the design note (§ 11.3) names
13
+ # `ScopeIndexer`'s per-method definite-assignment table as the source. That table does not exist yet
14
+ # when a unit is built: `#template_units_for_file` runs on the parent BEFORE any analysis, which is
15
+ # what keeps the plugin off every hot path. So the seeds are derived here, syntactically, from the
16
+ # controller sources the plugin already reads for its filter-chain index.
17
+ #
18
+ # ## The inference is deliberately narrow
19
+ #
20
+ # Only `@user = Model.find(...)`-shaped assignments contribute, and only for a closed set of
21
+ # constructors and finders that **cannot return nil** — `find`, `find_by!`, `sole`, `first!`,
22
+ # `last!`, `new`, `create`, `create!`. A seeded type is a claim the engine acts on, and a wrong one
23
+ # is worse than none: seeding `@user` from `find_by` (which returns nil at runtime) would hand the
24
+ # flow rules a non-nil nominal and license folds that draw diagnostics on correct templates — the
25
+ # trap `Actionpack::STRONG_PARAMS_CHAIN_METHODS` documents at length for `Parameters#[]`. Anything
26
+ # else assigns nothing, the ivar stays unseeded, and the template reads it as `Dynamic`, which
27
+ # taints honestly (ADR-5). A call on that list that returns, or may return, SEVERAL records —
28
+ # `find(a, b)`, `find([1, 2])`, `create([{…}, {…}])` — is excluded by the same rule; see
29
+ # {Builder#single_record?}.
30
+ #
31
+ # Two assignments of the same ivar that disagree drop it for that template, for the same reason.
32
+ #
33
+ # ## Which templates an action reaches
34
+ #
35
+ # The implicit render (`UsersController#show` → `users/show`) plus the explicit forms
36
+ # {Analyzer.render_target_for} already recognises at a render site: `render :edit`,
37
+ # `render "admin/shared/form"`. A partial reached through `render partial:` inherits nothing: its
38
+ # assigns come from whichever template renders it, which this slice does not trace (see the
39
+ # follow-up named in `docs/internal-spec/macro-substrate.md`).
40
+ class ViewAssigns
41
+ # Finders and constructors whose Rails implementation raises rather than returning nil. Anything
42
+ # nil-able is excluded on purpose; see the class comment.
43
+ NON_NIL_PRODUCERS = %i[find find_by! find_sole_by sole first! last! new create create!].freeze
44
+
45
+ # `{ "users/show" => { "@user" => "User" } }`, logical template name (no format, no handler) to
46
+ # seeds. A template's unit looks itself up by the same name with the format stripped.
47
+ attr_reader :by_template
48
+
49
+ def initialize(by_template)
50
+ @by_template = by_template.freeze
51
+ freeze
52
+ end
53
+
54
+ def self.empty
55
+ new({})
56
+ end
57
+
58
+ # `users/show.html` → the seeds recorded for `users/show`. The format is dropped because a
59
+ # controller action renders one logical template across every format it responds to, and the
60
+ # assigns are the same for all of them.
61
+ #
62
+ # A PARTIAL (`users/_card.html`) has no action of its own, but an ivar is not a local: Rails puts
63
+ # the controller's assigns on the view context, so `@user` reads the same inside a partial as in
64
+ # the template that rendered it. Its seeds are therefore the union of the seeds of the templates
65
+ # in its own directory — the directory is the controller, by Rails' own convention — with any ivar
66
+ # the members type differently dropped, the same conflict rule that applies within one action.
67
+ #
68
+ # That is an approximation in one direction only: a partial rendered from ANOTHER controller's
69
+ # view sees that controller's assigns, which are not in this union, so the ivar stays unseeded and
70
+ # reads `Dynamic`. Tracing render sites across templates is the follow-up
71
+ # `docs/internal-spec/macro-substrate.md` names.
72
+ def seeds_for(logical_name)
73
+ name = logical_name.sub(%r{\.[^./]+\z}, "")
74
+ return @by_template.fetch(name, {}) unless partial?(name)
75
+
76
+ directory_seeds(name)
77
+ end
78
+
79
+ def partial?(name)
80
+ File.basename(name).start_with?("_")
81
+ end
82
+
83
+ def directory_seeds(name)
84
+ prefix = name.include?("/") ? "#{File.dirname(name)}/" : ""
85
+ seeds = {}
86
+ conflicts = []
87
+ @by_template.each do |template, assigns|
88
+ next unless template.start_with?(prefix) && !template.delete_prefix(prefix).include?("/")
89
+
90
+ assigns.each do |ivar, type_name|
91
+ conflicts << ivar if seeds.key?(ivar) && seeds[ivar] != type_name
92
+ seeds[ivar] = type_name
93
+ end
94
+ end
95
+ conflicts.each { |ivar| seeds.delete(ivar) }
96
+ seeds
97
+ end
98
+
99
+ # Builds the index from the controller sources under `search_paths`.
100
+ class Builder
101
+ def initialize(io_boundary:, search_paths:)
102
+ @io_boundary = io_boundary
103
+ @search_paths = search_paths
104
+ end
105
+
106
+ def build
107
+ by_template = {}
108
+ controller_files.each { |path| harvest(path, by_template) }
109
+ ViewAssigns.new(by_template.transform_values(&:freeze))
110
+ end
111
+
112
+ private
113
+
114
+ def controller_files
115
+ @search_paths.flat_map do |root|
116
+ absolute = File.expand_path(root)
117
+ next [] unless @io_boundary.directory?(absolute)
118
+
119
+ Dir.glob(File.join(absolute, "**", "*.rb"))
120
+ end
121
+ end
122
+
123
+ def harvest(path, by_template)
124
+ contents = @io_boundary.read_file(path)
125
+ result = Prism.parse(contents)
126
+ return unless result.errors.empty?
127
+
128
+ ControllerScan.each_controller(result.value, []) do |node, namespace|
129
+ harvest_controller(node, namespace, by_template)
130
+ end
131
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
132
+ nil
133
+ end
134
+
135
+ def harvest_controller(node, namespace, by_template)
136
+ segments = namespace + ControllerScan.constant_segments(node.constant_path)
137
+ prefix = ControllerScan.controller_path(segments)
138
+ return if prefix.nil?
139
+
140
+ methods = ControllerScan.method_bodies(node)
141
+ filters = filter_chain(node)
142
+ methods.each do |name, body|
143
+ assigns = filter_assigns(filters, name, methods).merge(ivar_assigns(body))
144
+ next if assigns.empty?
145
+
146
+ templates_for(name, body, prefix).each do |template|
147
+ merge_seeds(by_template, template, assigns)
148
+ end
149
+ end
150
+ end
151
+
152
+ # `before_action :set_user, only: %i[show edit]` → `[[:set_user, [:show, :edit], nil]]`.
153
+ # `except:` rides the third slot. `prepend_before_action` is the same chain; `after_action` and
154
+ # `around_action` are not — an assign made after the render cannot be a seed.
155
+ def filter_chain(node)
156
+ body = node.body
157
+ return [] if body.nil?
158
+
159
+ body.child_nodes.compact.flat_map do |child|
160
+ next [] unless child.is_a?(Prism::CallNode) && child.receiver.nil?
161
+ next [] unless %i[before_action prepend_before_action].include?(child.name)
162
+
163
+ filter_entries(child)
164
+ end
165
+ end
166
+
167
+ def filter_entries(call)
168
+ arguments = call.arguments&.arguments || []
169
+ options = arguments.last.is_a?(Prism::KeywordHashNode) ? arguments.pop : nil
170
+ names = arguments.filter_map { |argument| argument.is_a?(Prism::SymbolNode) ? argument.unescaped.to_sym : nil }
171
+ only = symbol_list(options, :only)
172
+ except = symbol_list(options, :except)
173
+ conditional = %i[if unless].any? { |key| option?(options, key) }
174
+ names.map { |name| [name, only, except, conditional] }
175
+ end
176
+
177
+ def option?(options, key)
178
+ !pair_for(options, key).nil?
179
+ end
180
+
181
+ def symbol_list(options, key)
182
+ pair = pair_for(options, key)
183
+ return nil if pair.nil?
184
+
185
+ symbols_in(pair.value)
186
+ end
187
+
188
+ def pair_for(options, key)
189
+ return nil if options.nil?
190
+
191
+ options.elements.find do |element|
192
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
193
+ element.key.unescaped.to_sym == key
194
+ end
195
+ end
196
+
197
+ def symbols_in(node)
198
+ case node
199
+ when Prism::SymbolNode then [node.unescaped.to_sym]
200
+ when Prism::ArrayNode then node.elements.filter_map { |e| e.unescaped.to_sym if e.is_a?(Prism::SymbolNode) }
201
+ else []
202
+ end
203
+ end
204
+
205
+ # The assigns every `before_action` that UNCONDITIONALLY runs for `action` made, in chain order,
206
+ # so a later filter's assignment of the same ivar wins — and the action's own assignment wins
207
+ # over all of them, which is why the caller merges this UNDER the action's.
208
+ #
209
+ # `only:` / `except:` are decided here, per action, because they are static. `if:` / `unless:`
210
+ # cannot be: the filter may not run, so nothing it assigns is definite, and seeding from it
211
+ # would claim a non-nil type for an ivar that is nil at render time — see {#definite?}.
212
+ def filter_assigns(filters, action, methods)
213
+ filters.each_with_object({}) do |(name, only, except, conditional), seeds|
214
+ next if conditional
215
+ next if only && !only.include?(action)
216
+ next if except&.include?(action)
217
+
218
+ body = methods[name]
219
+ next if body.nil?
220
+
221
+ seeds.merge!(ivar_assigns(body))
222
+ end
223
+ end
224
+
225
+ # `@user = User.find(params[:id])` → `{ "@user" => "User" }`.
226
+ #
227
+ # **Definite assignments only.** An assignment the method may not reach — inside an `if`, a
228
+ # `case`, a `rescue`, a loop, or a block that may not run — does not contribute, because a seed
229
+ # is a claim about what the template will FIND, and `@user = User.find(1) if params[:pick]`
230
+ # leaves `@user` nil on the other path. A non-nil nominal standing in for a runtime nil is the
231
+ # `Parameters#[]` trap (`Actionpack::STRONG_PARAMS_CHAIN_METHODS`): the flow rules act on it and
232
+ # report live branches, which is exactly what a reader of a template would see with
233
+ # `view_type_checks:` on. The unseeded ivar reads `Dynamic` instead and taints honestly (ADR-5).
234
+ #
235
+ # Two disagreeing types drop the ivar for the same reason.
236
+ def ivar_assigns(body)
237
+ seeds = {}
238
+ conflicts = []
239
+ walk_assignments(body) do |name, type_name|
240
+ existing = seeds[name]
241
+ conflicts << name if existing && existing != type_name
242
+ seeds[name] = type_name
243
+ end
244
+ conflicts.each { |name| seeds.delete(name) }
245
+ seeds
246
+ end
247
+
248
+ # The nodes a method body reaches on EVERY path: its own statement list, a parenthesised
249
+ # expression, and a `begin` that cannot be cut short. Everything else — `if` / `unless` /
250
+ # `case` / `while` / `until` / `rescue` / `for`, and any block — is a branch, so the walk stops
251
+ # there rather than descending.
252
+ def walk_assignments(node, &)
253
+ return unless node.is_a?(Prism::Node)
254
+
255
+ if node.is_a?(Prism::InstanceVariableWriteNode)
256
+ type_name = produced_type(node.value)
257
+ yield node.name.to_s, type_name if type_name
258
+ return
259
+ end
260
+ return unless definite?(node)
261
+
262
+ node.rigor_each_child { |child| walk_assignments(child, &) }
263
+ end
264
+
265
+ # A `BeginNode` carrying a `rescue_clause` is NOT every-path, and that covers `def show; … rescue
266
+ # …; end` as well as an explicit `begin`: an exception raised by the first statement leaves the
267
+ # rest unassigned, and if the rescue renders anything the template reads a nil ivar. An
268
+ # `ensure`-only `begin` has no such exit and stays. The modifier form
269
+ # (`@u = User.find(1) rescue render :missing`) never seeds either — its value node is a
270
+ # `RescueModifierNode`, which {#produced_type} does not recognise.
271
+ def definite?(node)
272
+ return node.rescue_clause.nil? if node.is_a?(Prism::BeginNode)
273
+
274
+ node.is_a?(Prism::StatementsNode) || node.is_a?(Prism::ParenthesesNode)
275
+ end
276
+
277
+ # The narrow inference. `Model.find(…)` / `Model.new` → `"Model"`, and nothing else.
278
+ def produced_type(value)
279
+ return nil unless value.is_a?(Prism::CallNode)
280
+ return nil unless NON_NIL_PRODUCERS.include?(value.name)
281
+ return nil unless single_record?(value)
282
+
283
+ receiver = value.receiver
284
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
285
+
286
+ name = ControllerScan.constant_segments(receiver).join("::")
287
+ name.empty? ? nil : name
288
+ end
289
+
290
+ # Argument shapes that can stand for several ids, or several attribute hashes, at once.
291
+ LIST_ARGUMENTS = [Prism::SplatNode, Prism::ArrayNode, Prism::ForwardingArgumentsNode].freeze
292
+ private_constant :LIST_ARGUMENTS
293
+
294
+ # Whether the call returns ONE record. A seed names a class and nothing more — `resolve` in
295
+ # `Analysis::TemplateUnits` looks the string up as a nominal — so an `Array[Model]` result has no
296
+ # spelling here, and seeding the element type would contradict rigor-activerecord, which types the
297
+ # controller's own `Model.find(a, b)` as `Array[Model]` (#1321).
298
+ #
299
+ # `find` therefore seeds only for exactly one plain positional argument and no block. Two or more
300
+ # ids return an Array. So may `find([1, 2])` and `find(*ids)`: rigor-activerecord keeps the model
301
+ # for both, because a composite key's tuple is one record, but the view declines where the runtime
302
+ # answer may be an Array, which leaves it `Dynamic` rather than contradicting the controller. A
303
+ # block hands the call to `Enumerable#find`, which the arity rule does not describe. One argument
304
+ # that merely EVALUATES to an Array (`find(params[:ids])`, `create(rows)`) cannot be told apart from
305
+ # one id or one attribute hash — the limit the bundled `Relation#find` RBS states too — and keeps
306
+ # the model, as it does there. `create` / `create!` given an Array of attribute hashes return one
307
+ # record per hash.
308
+ def single_record?(call)
309
+ arguments = call.arguments&.arguments || []
310
+ case call.name
311
+ when :find
312
+ arguments.size == 1 && call.block.nil? && !list_argument?(arguments.first) &&
313
+ !arguments.first.is_a?(Prism::KeywordHashNode)
314
+ when :create, :create!
315
+ arguments.none? { |argument| list_argument?(argument) }
316
+ else
317
+ true
318
+ end
319
+ end
320
+
321
+ def list_argument?(node)
322
+ LIST_ARGUMENTS.any? { |klass| node.is_a?(klass) }
323
+ end
324
+
325
+ # The implicit render plus every explicit one the body spells. An action that renders nothing
326
+ # recognisable still gets its implicit template — that is Rails' default, not a guess.
327
+ def templates_for(action, body, prefix)
328
+ templates = ["#{prefix}/#{action}"]
329
+ ControllerScan.each_render(body) do |node|
330
+ target = render_target(node, prefix)
331
+ templates << target if target
332
+ end
333
+ templates.uniq
334
+ end
335
+
336
+ # `render :edit` and `render "admin/shared/form"` only. `render partial:` is excluded: a partial's
337
+ # bindings are the render site's `locals:` — which {RenderLocals} reads — not the action's
338
+ # assigns, and seeding it with the latter would state something the call site did not.
339
+ def render_target(node, prefix)
340
+ case (first = node.arguments&.arguments&.first)
341
+ when Prism::SymbolNode then "#{prefix}/#{first.unescaped}"
342
+ when Prism::StringNode then first.unescaped
343
+ end
344
+ end
345
+
346
+ # A template rendered by two actions gets the UNION of their assigns, and an ivar the two type
347
+ # differently is dropped — the same rule {#ivar_assigns} applies within one action, for the same
348
+ # reason. Rails really does render one template from several actions (`new` and `create` on a
349
+ # validation failure), and the seeds have to describe both.
350
+ def merge_seeds(by_template, template, assigns)
351
+ existing = by_template[template]
352
+ if existing.nil?
353
+ by_template[template] = assigns.dup
354
+ return
355
+ end
356
+
357
+ assigns.each do |name, type_name|
358
+ if existing.key?(name) && existing[name] != type_name
359
+ existing[name] = nil
360
+ else
361
+ existing[name] ||= type_name
362
+ end
363
+ end
364
+ existing.compact!
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Actionpack < Rigor::Plugin::Base
6
+ # #393 — the naming and binding rules that turn one ERB file into a {Plugin::TemplateUnit}.
7
+ # The compiler is {ErbCompiler}; the controller-side seeds are {ViewAssigns}.
8
+ module ViewUnits
9
+ # The `self` every view body is typed as. A bare, RBS-declared, OPEN nominal: `sig/action_view.rbs`
10
+ # names the class so the constant resolves and `open_receivers:` keeps its method surface lenient,
11
+ # which is what makes `link_to`, `form_with`, `t`, `turbo_frame_tag` and every other helper —
12
+ # including the project's own `ApplicationHelper` methods, which are implicit-self calls on this
13
+ # receiver — resolve lenient-to-`Dynamic` rather than drawing a finding per line.
14
+ #
15
+ # Enumerating the helper surface in RBS was considered and refused, for the reason
16
+ # `sig/action_controller.rbs` gives about `ActionController::Parameters`: a declared class drops
17
+ # every member its signature omits, and no signature completes a surface that `helper_method`,
18
+ # every `*Helper` module in the project and a dozen gems all extend. The per-controller view class
19
+ # the design note describes (§ 11.3 — `ActionView::Base` + `ApplicationHelper` + `<C>Helper` +
20
+ # route helpers) is the same shape one level finer, and needs a way for a plugin to synthesise a
21
+ # class; it is the follow-up named in `docs/internal-spec/macro-substrate.md`.
22
+ SELF_TYPE = "ActionView::Base"
23
+
24
+ # The type a strict-locals name is seeded with. Deliberately NOT declared in `sig/action_view.rbs`
25
+ # and deliberately not a real class: the engine binds an unresolvable name to `Dynamic[top]`
26
+ # (`Analysis::TemplateUnits#resolve`), which is the honest reading — the comment states the
27
+ # parameter's NAME, and Rails' strict-locals syntax carries no type. What the seed buys is the
28
+ # name: without it Prism reads a bare `user` as a method call and the binding is never consulted.
29
+ UNKNOWN_LOCAL = "ActionView::UnknownLocal"
30
+
31
+ # Rails 7.1's strict-locals magic comment: `<%# locals: (user:, size: :md) %>`. Only the first one
32
+ # counts, as in Rails, and only the names are read — a default value is a default, not a type.
33
+ STRICT_LOCALS = /<%#-?\s*locals:\s*\(([^)]*)\)\s*-?%>/
34
+ private_constant :STRICT_LOCALS
35
+
36
+ LOCAL_NAME = /([a-z_][A-Za-z0-9_]*):/
37
+ private_constant :LOCAL_NAME
38
+
39
+ # #1047 — the ways a template tests for its OWN optional local, each capturing the name:
40
+ # `defined?(size)` / `defined? size`, `local_assigns[:size]`, and `local_assigns.key?(:size)` with
41
+ # its `has_key?` / `include?` / `fetch` spellings.
42
+ #
43
+ # The `defined?` forms take the name only when it is the WHOLE operand — `defined?(size)` closed by
44
+ # its parenthesis, or `defined? size` followed by the end of the expression — so
45
+ # `defined?(link_to "x", y)` and `defined?(obj.meth)` name nothing.
46
+ SELF_DECLARED = [
47
+ /\bdefined\?\s*\(\s*([a-z_][A-Za-z0-9_]*)\s*\)/,
48
+ /\bdefined\?[ \t]+([a-z_][A-Za-z0-9_]*)(?=\s*(?:%>|\)|&&|\|\||;|\n|\z|\b(?:and|or|then|if|unless)\b))/,
49
+ /\blocal_assigns\s*\[\s*:([a-z_][A-Za-z0-9_]*)\s*\]/,
50
+ /\blocal_assigns\s*\.\s*(?:key\?|has_key\?|include\?|fetch)\s*\(?\s*:([a-z_][A-Za-z0-9_]*)/
51
+ ].freeze
52
+ private_constant :SELF_DECLARED
53
+
54
+ # A tested name that is a Ruby keyword or a pseudo-variable is not a local: `defined?(super)`,
55
+ # `defined?(yield)` and `defined?(self)` all read something else.
56
+ NOT_LOCALS = %w[
57
+ super yield self nil true false __method__ __FILE__ __LINE__ __dir__ __encoding__ binding
58
+ block_given
59
+ ].freeze
60
+ private_constant :NOT_LOCALS
61
+
62
+ # Every ERB tag that holds Ruby — the only place a template's own tests live. `<%%` is literal text
63
+ # and `<%#` is a comment, so `<%# if defined?(old_local) %>` declares nothing.
64
+ RUBY_TAG = /<%(?![%#]).*?%>/m
65
+ private_constant :RUBY_TAG
66
+
67
+ module_function
68
+
69
+ # `app/views/users/show.html.erb` → `users/show.html`; `app/views/users/_card.html.erb` →
70
+ # `users/_card.html`. The handler is dropped and the format is kept, which is Rails' own logical
71
+ # name and what the seam asks for so an ERB → Haml rewrite is not a rename. A template with no
72
+ # format segment (`users/show.erb`) keeps the name it has.
73
+ def logical_name(path, roots)
74
+ relative = strip_root(path, roots)
75
+ relative.sub(/\.erb\z/, "")
76
+ end
77
+
78
+ def strip_root(path, roots)
79
+ roots.each do |root|
80
+ prefix = "#{root.to_s.sub(%r{\A\./}, '').chomp('/')}/"
81
+ return path.delete_prefix(prefix) if path.start_with?(prefix)
82
+ end
83
+ path
84
+ end
85
+
86
+ # #1047 — `{ "path" => UNKNOWN_LOCAL }` for every bare local a template tests for itself:
87
+ #
88
+ # <% path = nil unless defined? path %>
89
+ # <% size = local_assigns.fetch(:size, :md) %>
90
+ #
91
+ # That test IS the author's declaration that `path` is an optional local, and it needs no render
92
+ # site to be believed. It matters because a render site is not always readable: `locals: opts`
93
+ # passes a computed hash, `locals: { **opts }` a splat, a helper in `app/helpers` renders without
94
+ # being scanned, and an optional local with a default is by definition passed nowhere. Leaving any
95
+ # of those names unbound makes the preamble a fresh nil assignment in the compiled Ruby, and the
96
+ # `flow.` rules fold every branch it guards on a partial Rails renders correctly. Binding it costs
97
+ # nothing — over-binding is the safe direction ({RenderLocals}) — so every such name is seeded,
98
+ # typed `Dynamic`.
99
+ #
100
+ # A name the template ALSO assigns without such a test (`<% total = 0 %>`) is untouched: it is only
101
+ # seeded when it is tested, and a test on a name the body always assigns first is dead code the
102
+ # binding cannot make worse.
103
+ #
104
+ # A name a project HELPER defines is not seeded (`helpers:`, the `def` names under `app/helpers`):
105
+ # `<% if defined?(current_user) && current_user %>` in a shared partial tests a helper, and binding
106
+ # it as a local would turn every later `current_user` from a call into a `Dynamic` read. The trade
107
+ # left is stated in the manual: a helper that a gem or a concern defines is not seen by that scan,
108
+ # so the name is still seeded — which reads `Dynamic` where it would otherwise have been an
109
+ # open-receiver call, and no finding either way.
110
+ def self_declared_locals(source, helpers: {})
111
+ source.scan(RUBY_TAG).each_with_object({}) do |tag, locals|
112
+ SELF_DECLARED.each do |pattern|
113
+ tag.scan(pattern).flatten.each do |name|
114
+ next if NOT_LOCALS.include?(name) || helpers.key?(name)
115
+
116
+ locals[name] = UNKNOWN_LOCAL
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # `{ "user" => UNKNOWN_LOCAL }` for a template carrying the strict-locals comment, `{}` otherwise.
123
+ def strict_locals(source)
124
+ match = STRICT_LOCALS.match(source)
125
+ return {} if match.nil?
126
+
127
+ match[1].scan(LOCAL_NAME).flatten.to_h { |name| [name, UNKNOWN_LOCAL] }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end