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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/constant_path"
6
+ require_relative "file_collection"
7
+
8
+ module Rigor
9
+ module Effects
10
+ # The ancestry half of a file's {FileCollection}: what each class declares above itself, what it
11
+ # includes, and where the scan has to admit it could not read the ancestry at all.
12
+ #
13
+ # Split out of {Scanner} because the three questions are one subject with one invariant — a name goes
14
+ # in **as written**, and the propagator, which is the only place the whole project is in view, decides
15
+ # which constant it meant. The scanner owns unit identity and delegates this; the two tables are handed
16
+ # back by {#superclasses} / {#includes} and go into the collection unchanged.
17
+ class AncestryRecorder
18
+ attr_reader :superclasses, :includes
19
+
20
+ def initialize
21
+ @superclasses = {}
22
+ @includes = {}
23
+ end
24
+
25
+ # `class Loud < Base` inside `module Tracer` names `Base`, so the candidates go in and the propagator
26
+ # picks the one the merged project defines.
27
+ #
28
+ # A superclass expression that is **not** a constant path — `class K < Struct.new(:a)`,
29
+ # `< Data.define(:a)`, `< DelegateClass(X)`, `< Sequel::Model(:t)` — records
30
+ # {FileCollection::OPAQUE_ANCESTOR} rather than nothing (#1039). Recording nothing made it
31
+ # indistinguishable from a class with no `<` at all, which is the one reading the constructor rule
32
+ # turns into an answer; such a class inherits a constructor built at load time, which is the
33
+ # opposite of an absent one.
34
+ def record_superclass(full_name, node, prefix)
35
+ return if node.superclass.nil? || opaque?(full_name)
36
+
37
+ superclass = Source::ConstantPath.qualified_name(node.superclass)
38
+ @superclasses[full_name] =
39
+ superclass ? lexical_candidates(superclass, prefix) : [FileCollection::OPAQUE_ANCESTOR]
40
+ end
41
+
42
+ # A class BUILT AT LOAD TIME and assigned to a constant — `Anon = Class.new(Base)`,
43
+ # `Point = Struct.new(:x)`, `Rec = Data.define(:x)`, `Dele = DelegateClass(X)` — records the opaque
44
+ # sentinel (#1039). Without it a later `class Point; def more; end; end` reopening is the only thing
45
+ # the scan sees: the class becomes project-known, nothing says what is above it, and the constructor
46
+ # rule would read that silence as "no constructor anywhere" when the constructor is precisely what
47
+ # the load-time call built. It is the `class K < Struct.new(:a)` case in its other spelling.
48
+ #
49
+ # Only a **call**-valued assignment qualifies. A literal cannot be a class, and a constant-path value
50
+ # (`Alias = Real`) is a second name for a class whose own ancestry the scan already recorded.
51
+ #
52
+ # The sentinel is **sticky**: it overwrites a spelled `< Base` and no later one displaces it, here or
53
+ # in {FileCollection.merge_all}. A reopening's `class Anon < Base` is not more than this says but
54
+ # less — `Anon = Class.new(Base) { def initialize; … end }` puts a constructor in the block, which the
55
+ # scan files under the enclosing namespace and cannot attribute to `Anon` at all, and the spelled
56
+ # superclass says nothing about it. Sticky is also what makes the answer independent of which file a
57
+ # run reads first.
58
+ #
59
+ # `Class.new(Base)` additionally keeps `Base` beside the sentinel. The sentinel still declines
60
+ # `Anon.new` itself, and the parent link is what files `Anon` under `Base` in the subclass index, so
61
+ # a `self.class.new` in `Base` — which the closed-world join says may construct `Anon` — sees an
62
+ # unreadable constructor below it and declines too.
63
+ def record_constant_class(node, prefix)
64
+ value = node.value
65
+ return unless value.is_a?(Prism::CallNode)
66
+
67
+ name = constant_write_name(node, prefix)
68
+ return if name.nil?
69
+
70
+ @superclasses[name] = [FileCollection::OPAQUE_ANCESTOR, *load_time_parent(value, prefix)]
71
+ end
72
+
73
+ # A receiver-less `include` / `prepend` in `class_name`'s body, its constant arguments recorded as
74
+ # written.
75
+ #
76
+ # Both are calls on `self`, like `define_method`, and the include table is the instance ancestry that
77
+ # `super` and the constructor rule walk, so a call is recorded only where `self` is the class the body
78
+ # opened (see {#instance_side_self?}). Inside `class << self`, or a `class_eval` / `instance_eval`
79
+ # block on `singleton_class`, it mixes the module into the singleton class, which is what `extend`
80
+ # does, and the collection is as blind to it as it is to `extend`.
81
+ #
82
+ # @param context — the {DefinitionContext} of the class-body position the call sits at
83
+ def record_includes(class_name, node, prefix, context)
84
+ return unless instance_side_self?(context)
85
+
86
+ names = node.arguments&.arguments&.filter_map { |argument| Source::ConstantPath.qualified_name(argument) }
87
+ candidates = (names || []).flat_map { |name| lexical_candidates(name, prefix) }
88
+ (@includes[class_name] ||= []).concat(candidates) unless candidates.empty?
89
+ end
90
+
91
+ # `alias initialize setup` / `alias_method :initialize, :setup` makes the constructor another
92
+ # method's body, and the scan models no aliases at all (#1039). The opaque sentinel is the minimal
93
+ # honest answer: the ancestry stops being readable, the constructor rule declines, and this class's
94
+ # callers stay exactly as unclaimed as they were before that rule existed.
95
+ def record_initialize_alias(class_name)
96
+ (@includes[class_name] ||= []) << FileCollection::OPAQUE_ANCESTOR
97
+ end
98
+
99
+ # Whether this node aliases the instance-side `initialize`, in either spelling. A `CallNode` qualifies
100
+ # only as a receiver-less `alias_method` whose first symbol argument is the new name.
101
+ #
102
+ # `alias` works on the default definee and `alias_method` on `self`, so the two part in an
103
+ # `instance_eval` block, and inside `class << self` both alias the singleton class's `initialize`,
104
+ # a class method `new` never calls. Where the syntax does not say which class either one is, it
105
+ # records nothing here, as an include there does not (see {#instance_side_self?}).
106
+ #
107
+ # @param context — the {DefinitionContext} of the class-body position the node sits at
108
+ def alias_to_initialize?(node, context)
109
+ case node
110
+ when Prism::AliasMethodNode
111
+ context.definee_singleton == false && literal_name(node.new_name) == "initialize"
112
+ when Prism::CallNode
113
+ node.receiver.nil? && node.name == :alias_method && instance_side_self?(context) &&
114
+ literal_name(first_argument(node)) == "initialize"
115
+ else false
116
+ end
117
+ end
118
+
119
+ # An ancestry name is recorded AS WRITTEN — a single file cannot say which constant it resolves to.
120
+ # So the candidates Ruby's own lexical lookup would try go in, most-qualified first, and the
121
+ # propagator picks the one the merged project actually defines. Same shape as `ScopeIndexer`'s
122
+ # as-written superclass table, resolved at the same point: when the whole project is in view.
123
+ def lexical_candidates(name, prefix)
124
+ return [name] if prefix.empty? || name.start_with?("#{prefix.join('::')}::")
125
+
126
+ prefix.length.downto(1).map { |depth| "#{prefix.first(depth).join('::')}::#{name}" } + [name]
127
+ end
128
+
129
+ private
130
+
131
+ # Whether the syntax shows that a call on `self` written here works on the class the enclosing body
132
+ # opened, or on `Object` through `main` at the top level, so what it declares is that class's instance
133
+ # ancestry.
134
+ #
135
+ # It does not where `self` is the singleton class, nor where the syntax does not say what `self` is:
136
+ # the block of `Class.new` and its kin, an eval on another receiver, `class << obj`. The syntax does not
137
+ # name the class the module goes into there, and filing it under the enclosing class would put it
138
+ # where `super` and the constructor rule look for that class. The class Ruby does put it in misses it,
139
+ # as it would an `extend`, and so does `W` for a `W.class_eval { include M }` inside `class W`, which
140
+ # the context reads as an eval on any other receiver (#1322).
141
+ def instance_side_self?(context)
142
+ !context.self_singleton_class? && context.self_kind != :unknown
143
+ end
144
+
145
+ def opaque?(full_name)
146
+ @superclasses.fetch(full_name, []).include?(FileCollection::OPAQUE_ANCESTOR)
147
+ end
148
+
149
+ # The superclass a `Class.new(Base)` names, as candidates. Read from `Class.new` alone: it is the one
150
+ # load-time builder whose first argument IS the superclass, and inventing an ancestry edge from any
151
+ # other call's first constant argument would let an unrelated class's methods resolve through it.
152
+ def load_time_parent(value, prefix)
153
+ return [] unless value.name == :new && constant_receiver_name(value.receiver) == "Class"
154
+
155
+ argument = value.arguments&.arguments&.first
156
+ name = argument && Source::ConstantPath.qualified_name(argument)
157
+ name ? lexical_candidates(name, prefix) : []
158
+ end
159
+
160
+ def constant_receiver_name(receiver)
161
+ Source::ConstantPath.qualified_name(receiver) if receiver.is_a?(Prism::ConstantReadNode)
162
+ end
163
+
164
+ # `alias_method :initialize, :setup` and `alias_method "initialize", "setup"` are the same
165
+ # declaration; only an interpolated name is beyond the scan.
166
+ def literal_name(node)
167
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
168
+ end
169
+
170
+ # The key a constant assignment declares, qualified by the nesting it is written in.
171
+ def constant_write_name(node, prefix)
172
+ case node
173
+ when Prism::ConstantWriteNode then [*prefix, node.name.to_s].join("::")
174
+ when Prism::ConstantPathWriteNode then qualified_write_name(node, prefix)
175
+ end
176
+ end
177
+
178
+ def qualified_write_name(node, prefix)
179
+ target = Source::ConstantPath.qualified_name(node.target)
180
+ return nil if target.nil?
181
+ return target if prefix.empty? || target.start_with?("#{prefix.join('::')}::")
182
+
183
+ [*prefix, target].join("::")
184
+ end
185
+
186
+ def first_argument(node)
187
+ node.arguments&.arguments&.first
188
+ end
189
+ end
190
+ end
191
+ end
@@ -26,8 +26,12 @@ module Rigor
26
26
  EMPTY_ROWS = {}.freeze
27
27
  private_constant :EMPTY_ROWS
28
28
 
29
- def self.empty
30
- @empty ||= new({})
29
+ # The run's empty table. Eagerly built on the main Ractor at load time (bottom of the class body)
30
+ # rather than memoised on first use: `@empty ||= new({})` is a class-ivar WRITE, which a non-main
31
+ # Ractor may not perform, and every project that configures no attribution reaches it — including
32
+ # from inside {Analysis::WorkerSession#initialize}, which is where a Ractor-pool worker runs (#1055).
33
+ class << self
34
+ attr_reader :empty
31
35
  end
32
36
 
33
37
  # @param table — `Configuration#effects_attribution` — method key to
@@ -69,6 +73,11 @@ module Rigor
69
73
  out[key.to_s] = set unless set.empty?
70
74
  end.freeze
71
75
  end
76
+
77
+ # Populates the `@empty` singleton on the main Ractor at load time. `Ractor.make_shareable` rather
78
+ # than `freeze` so a worker's READ of the class ivar is legal too: a non-main Ractor may read a
79
+ # class/module ivar only when the value is deeply shareable.
80
+ @empty = Ractor.make_shareable(new({}))
72
81
  end
73
82
  end
74
83
  end
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # Callee resolution for plugin attribution rows (#1048; ADR-103 WD10 / WD13; design note § 11.2).
8
+ #
9
+ # A framework method can be an **edge** as well as a label. `render :show` inside `UsersController`
10
+ # runs `app/views/users/show.html.erb`, synchronously and in-process, and since #393 that template is
11
+ # an effect unit keyed `view:users/show.html` sitting in the very same summaries table. Nothing
12
+ # produced the edge, because the only plugin-facing edge surface ({Plugin::EffectEdge}) names a
13
+ # receiver *class* and mints units on a class body, and {Plugin::EffectAttribution} carried labels but
14
+ # no callee.
15
+ #
16
+ # This module is the missing half, and it is deliberately shaped exactly like {Narrowing}: the plugin
17
+ # writes a **rule name** on its row (`callee: "rails_render"`) and the engine owns the strategy. A
18
+ # block would have to run inside the per-file effect scan — the one place ADR-103 WD13 forbids
19
+ # anything that resolves, walks or types — and would not survive the fork-pool / Ractor boundary. A
20
+ # name is a String: declarative, Marshal-clean, and reviewable in the plugin's manifest.
21
+ #
22
+ # ## What a rule may read
23
+ #
24
+ # **The call's own argument literals, the unit's owner class, and the unit's own key.** No dataflow,
25
+ # no typer question, no filesystem. A rule that needed to know whether `app/views/users/show.html.erb`
26
+ # exists would make a plugin row's meaning a function of the view tree, which the scan does not have
27
+ # and must not read; instead an unresolvable answer is `nil` and a resolvable-looking one that no unit
28
+ # answers is **dropped by the propagator**, which restores the row's taint from
29
+ # {FileCollection::Edge#taint_if_unresolved}. So "the template is not in the table" and "the argument
30
+ # was computed" both keep the `template-not-analysed` taint, and only a render that reached a real
31
+ # unit clears it.
32
+ #
33
+ # ## Two kinds of rule
34
+ #
35
+ # - a **site rule** ({SITE_RULES}) answers for one call node — `render :show`, `render partial: "card"`;
36
+ # - a **unit rule** ({UNIT_RULES}) answers for a whole unit and reads no node at all. Rails' *implicit*
37
+ # render is the case that needs one: an action that falls off its end without rendering still renders
38
+ # `<controller>/<action>`, and the fact that produces the edge is the **absence** of a call. Only the
39
+ # unit scan can observe that, which is why the rule is applied there rather than in
40
+ # {FrameworkUnits} — a class body cannot see which of its methods responded.
41
+ #
42
+ # A unit rule contributes an **edge and nothing else**: no labels, no taint. That is what keeps it
43
+ # FP-safe on the private helper a controller also defines — `def load_user` gets an edge to
44
+ # `view:users/load_user.html`, no unit answers it, and the unit is exactly as it was.
45
+ module CalleeRule
46
+ # The callee an applied rule names, as the two halves {FileCollection::Edge} carries. The key the
47
+ # propagator reconstructs is `"#{receiver}.#{selector}"` — `view:users/show` + `html`.
48
+ #
49
+ # `fallbacks` is the ordered list of selectors the propagator retries when `selector` resolves to
50
+ # nothing (#1065), or nil for none. The rule only COPIES it off the plugin's table: whether
51
+ # `view:users/_row.js` exists is a question about the merged table, which the scan cannot ask.
52
+ Callee = Data.define(:receiver, :selector, :fallbacks) do
53
+ def initialize(fallbacks: nil, **) = super
54
+ end
55
+
56
+ # Must agree with {MethodKey::TEMPLATE_UNIT_PREFIX} and `Plugin::TemplateUnit::KEY_PREFIX`; pinned
57
+ # equal by spec.
58
+ TEMPLATE_PREFIX = "view:"
59
+
60
+ # The format a controller action renders absent a `formats:` override — Rails' own default, and the
61
+ # only one a `render :show` with no other evidence may be read as.
62
+ DEFAULT_FORMAT = "html"
63
+
64
+ # Rules that answer for one call node.
65
+ SITE_RULES = %w[rails_render rails_render_partial].freeze
66
+
67
+ # Rules that answer for a whole unit, from its owner and its own key. A UNIT rule may additionally
68
+ # be applied **per format arm** of a `respond_to` block (#1071): the arm is the unit's own body, and
69
+ # the arm's format is a literal the unit scan read, so it travels as data the same way the unit's key
70
+ # does.
71
+ UNIT_RULES = %w[rails_implicit_render].freeze
72
+
73
+ RULES = (SITE_RULES + UNIT_RULES).freeze
74
+
75
+ # `render json:` / `plain:` / `inline:` and friends render no template. They are listed so the rule
76
+ # can decline rather than read a `partial:` that is not there — declining keeps the row's taint,
77
+ # which is the conservative answer for a shape this rule does not model.
78
+ NON_TEMPLATE_OPTIONS = %w[json xml plain text html body js inline file nothing].freeze
79
+
80
+ # Template handlers, which a logical name never carries: `Plugin::TemplateUnit#logical_name` is
81
+ # `users/show.html`, not `users/show.html.erb`. An author may still write the handler out —
82
+ # `render template: "users/show.html.erb"` is legal Rails — so it is stripped rather than left to
83
+ # build a key no unit could ever answer.
84
+ HANDLERS = %w[erb haml slim jbuilder builder rabl ruby].freeze
85
+
86
+ module_function
87
+
88
+ def known?(name)
89
+ RULES.include?(name.to_s)
90
+ end
91
+
92
+ def site_rule?(name)
93
+ SITE_RULES.include?(name.to_s)
94
+ end
95
+
96
+ def unit_rule?(name)
97
+ UNIT_RULES.include?(name.to_s)
98
+ end
99
+
100
+ # Applies a site rule to one call node.
101
+ #
102
+ # @param name — the row's `callee:`
103
+ # @param node — the `Prism::CallNode` the row matched
104
+ # @param owner_class — the unit's owner (`"UsersController"`, `"ActionView::Base"`)
105
+ # @param unit_key — the unit's own key: a selector for a method, `view:users/show.html` for a
106
+ # template unit
107
+ # @param fallbacks — the row's `callee_fallbacks:` table (#1065). Only a rule whose selector the
108
+ # CONTEXT supplied consults it; see {rails_render_partial}.
109
+ # @return the {Callee} the rule named, or nil whenever it cannot settle the target from
110
+ # literals alone
111
+ def site(name, node, owner_class:, unit_key: nil, fallbacks: nil)
112
+ case name.to_s
113
+ when "rails_render" then rails_render(node, owner_class)
114
+ when "rails_render_partial" then rails_render_partial(node, unit_key, fallbacks)
115
+ end
116
+ end
117
+
118
+ # Applies a unit rule. Reads no node; a `format:` from a `respond_to` arm (#1071) narrows the
119
+ # template the rule names from the default to that arm's own. Nil, or absent (`DEFAULT_FORMAT`).
120
+ #
121
+ # @return the {Callee} the rule named, or nil.
122
+ def unit(name, owner_class:, unit_key: nil, format: nil)
123
+ case name.to_s
124
+ when "rails_implicit_render" then rails_implicit_render(owner_class, unit_key, format: format)
125
+ end
126
+ end
127
+
128
+ # `render` inside a controller. The positional form names an **action template**
129
+ # (`render :show` → `users/show`), which is the one place a controller and a view disagree about
130
+ # what a bare string means.
131
+ #
132
+ # Never consults a format fallback (#1065). The format here is either one the author wrote, or
133
+ # Rails' `html` default standing in for a REQUEST format the rule cannot see — and the lookup order
134
+ # a controller-side render follows is derived from that request (`request.formats`, an `Accept`
135
+ # header), not from anything in the source.
136
+ def rails_render(node, owner_class)
137
+ directory = controller_directory(owner_class)
138
+ return nil if directory.nil?
139
+
140
+ format = format_for(node, DEFAULT_FORMAT)
141
+ return nil if format.nil?
142
+
143
+ name = template_name(node, directory) || partial_name(node, directory, layout: false)
144
+ name.nil? ? nil : template_callee(name, format)
145
+ end
146
+
147
+ # `render` inside a template. A bare positional argument is a **partial** here, and so is `layout:`
148
+ # — in a view `render layout: "shared/wrapper"` names `shared/_wrapper`, not an `app/views/layouts`
149
+ # file, because `RenderingHelper#render` rewrites `layout:` to `partial:` when a block is given.
150
+ # Since #1047 a layout compiles to a unit like any other template, so such an edge resolves where
151
+ # the named partial exists and keeps its taint where it does not.
152
+ #
153
+ # **The format fallback (#1065).** The format travels from the enclosing unit, and while a `.js.erb`
154
+ # template is rendering Rails' lookup context holds `[:js, :html]` — `LookupContext#formats=` appends
155
+ # `:html` to a lone `:js`, and `AbstractRenderer#prepend_formats` puts the template's own format in
156
+ # front of the request's. So `render partial: "watchers"` from `_set_watcher.js.erb` runs
157
+ # `_watchers.js.erb` where one exists and `_watchers.html.erb` otherwise. The rule copies the row's
158
+ # table for the inherited format onto the callee, and the propagator takes the first key that
159
+ # resolves.
160
+ #
161
+ # The table is consulted only for an INHERITED format. A `formats:` / `format:` keyword or a format
162
+ # spelled into the name is the author's word, and the fallback stands down: that is the direction
163
+ # that keeps a taint rather than guessing at a lookup the call overrode.
164
+ def rails_render_partial(node, unit_key, fallbacks)
165
+ directory, inherited = template_context(unit_key)
166
+ return nil if directory.nil?
167
+
168
+ format = format_for(node, inherited)
169
+ return nil if format.nil?
170
+
171
+ retry_formats = format_keyword?(node) ? nil : fallbacks&.fetch(format, nil)
172
+ explicit = keyword_name(node, "template")
173
+ return template_callee(qualify(explicit, directory), format, retry_formats) if explicit
174
+
175
+ name = partial_name(node, directory) || positional_partial(node, directory)
176
+ name.nil? ? nil : template_callee(name, format, retry_formats)
177
+ end
178
+
179
+ # Rails' implicit render: an action that never rendered still renders `<controller>/<action>`. A
180
+ # `respond_to` arm (#1071) is the same convention per format: `format.js` with no responding block
181
+ # renders `<controller>/<action>.js`, so the rule narrows the format it answers for, and an arm that
182
+ # IS answered outright is not applied at all (the unit scan decides that half and calls with the
183
+ # arm's format only for arms that still fall through).
184
+ def rails_implicit_render(owner_class, unit_key, format: nil)
185
+ directory = controller_directory(owner_class)
186
+ return nil if directory.nil? || unit_key.nil?
187
+
188
+ action = unit_key.to_s
189
+ return nil unless /\A[a-z_][A-Za-z0-9_]*[?!=]?\z/.match?(action)
190
+ return nil if action.end_with?("?", "!", "=")
191
+
192
+ template_callee("#{directory}/#{action}", format || DEFAULT_FORMAT)
193
+ end
194
+
195
+ # `UsersController` → `users`; `Admin::UsersController` → `admin/users`. A class whose name does not
196
+ # end in `Controller` is not one this rule can read a view directory off, and answers nil.
197
+ def controller_directory(owner_class)
198
+ return nil if owner_class.nil?
199
+
200
+ segments = owner_class.to_s.split("::")
201
+ last = segments.pop
202
+ return nil unless last&.end_with?("Controller") && last != "Controller"
203
+
204
+ segments.push(last.delete_suffix("Controller"))
205
+ segments.map { |segment| underscore(segment) }.join("/")
206
+ end
207
+
208
+ # `[directory, format]` for a template unit's own key — `view:users/show.html` → `["users", "html"]`.
209
+ # A partial rendered from a `.json` template is a `.json` partial, which is why the format travels.
210
+ def template_context(unit_key)
211
+ key = unit_key.to_s
212
+ return [nil, nil] unless key.start_with?(TEMPLATE_PREFIX)
213
+
214
+ logical = key.delete_prefix(TEMPLATE_PREFIX)
215
+ name, _, format = logical.rpartition(".")
216
+ return [nil, nil] if name.empty?
217
+
218
+ directory = name.include?("/") ? name[0...name.rindex("/")] : ""
219
+ [directory, format]
220
+ end
221
+
222
+ # The explicit template spellings: `template: "users/show"` and `action: :edit`.
223
+ def template_name(node, directory)
224
+ explicit = keyword_name(node, "template")
225
+ return qualify(explicit, directory) if explicit
226
+
227
+ action = keyword_name(node, "action")
228
+ return "#{directory}/#{action}" if action && !action.include?("/")
229
+ return action if action
230
+
231
+ positional_template(node, directory)
232
+ end
233
+
234
+ # `render :show` / `render "show"` / `render "admin/form"`, but only when no option keyword the rule
235
+ # does not model is present.
236
+ def positional_template(node, directory)
237
+ name = literal_name(positional(node).first)
238
+ return nil if name.nil?
239
+
240
+ qualify(name, directory)
241
+ end
242
+
243
+ # `render "card"` inside a template — the same literal, read as a partial.
244
+ def positional_partial(node, directory)
245
+ name = literal_name(positional(node).first)
246
+ name.nil? ? nil : partialize(qualify(name, directory))
247
+ end
248
+
249
+ # `partial: "card"` / `partial: "users/card"`, and the view-side `layout:`. `collection:` changes
250
+ # how many times the partial runs and not which one, so it is read and ignored on purpose: an
251
+ # effect summary is an upper bound over the body, not a count.
252
+ def partial_name(node, directory, layout: true)
253
+ name = keyword_name(node, "partial") || (layout ? keyword_name(node, "layout") : nil)
254
+ name.nil? ? nil : partialize(qualify(name, directory))
255
+ end
256
+
257
+ # The requested format, or nil to decline. Absent is `fallback`; a literal narrows; anything
258
+ # computed is genuinely unknown and must not be guessed at `html`.
259
+ def format_for(node, fallback)
260
+ return nil if non_template?(node)
261
+
262
+ value = keyword_argument(node, "formats") || keyword_argument(node, "format")
263
+ return fallback if value.nil?
264
+
265
+ literal = literal_name(value) || literal_name(array_head(value))
266
+ literal&.split(".")&.last
267
+ end
268
+
269
+ def format_keyword?(node)
270
+ !(keyword_argument(node, "formats") || keyword_argument(node, "format")).nil?
271
+ end
272
+
273
+ def non_template?(node)
274
+ NON_TEMPLATE_OPTIONS.any? { |option| keyword_argument(node, option) }
275
+ end
276
+
277
+ def array_head(node)
278
+ node.elements.first if node.is_a?(Prism::ArrayNode)
279
+ end
280
+
281
+ # `fallbacks` survives only when the name spelled no format of its own: `render "row.json"` from a
282
+ # `.js` template is the author naming `json`, and a `js` table has nothing to say about it.
283
+ def template_callee(name, format, fallbacks = nil)
284
+ spelled = split_suffixes(name, nil).last
285
+ name, format = split_suffixes(name, format)
286
+ return nil if name.nil? || name.empty? || format.nil? || format.empty?
287
+ return nil if name.include?(" ") || format.include?(" ") || format.include?(".")
288
+
289
+ fallbacks = nil unless spelled.nil? && fallbacks && !fallbacks.empty?
290
+ Callee.new(receiver: "#{TEMPLATE_PREFIX}#{name}", selector: format, fallbacks: fallbacks)
291
+ end
292
+
293
+ # Splits a written handler and format off the name's last segment, so `render "show.json"` names
294
+ # `view:users/show` + `json` and `render template: "users/show.html.erb"` names
295
+ # `view:users/show` + `html` rather than the impossible `view:users/show.html.erb.html`. A name
296
+ # that spells a format wins over the rule's default, because the author said so; a name that
297
+ # spells only a handler keeps the default.
298
+ def split_suffixes(name, format)
299
+ return [name, format] if name.nil?
300
+
301
+ directory, separator, base = name.rpartition("/")
302
+ segments = base.split(".")
303
+ return [name, format] if segments.length <= 1
304
+
305
+ stem = segments.shift
306
+ segments.reject! { |segment| HANDLERS.include?(segment) }
307
+ ["#{directory}#{separator}#{stem}", segments.first || format]
308
+ end
309
+
310
+ # `"card"` in `users` → `"users/_card"`; `"admin/card"` → `"admin/_card"`. A name already spelled
311
+ # with the underscore keeps it, because that is what Rails accepts too.
312
+ def partialize(name)
313
+ return nil if name.nil?
314
+
315
+ directory, separator, base = name.rpartition("/")
316
+ return base.start_with?("_") ? name : "_#{name}" if separator.empty?
317
+
318
+ base.start_with?("_") ? name : "#{directory}/_#{base}"
319
+ end
320
+
321
+ # A name with a `/` is rooted at the views directory; a bare one is relative to the rendering
322
+ # unit's own directory.
323
+ def qualify(name, directory)
324
+ return nil if name.nil? || name.empty?
325
+ return name if name.include?("/") || directory.nil? || directory.empty?
326
+
327
+ "#{directory}/#{name}"
328
+ end
329
+
330
+ def keyword_name(node, key)
331
+ literal_name(keyword_argument(node, key))
332
+ end
333
+
334
+ def literal_name(value)
335
+ case value
336
+ when Prism::SymbolNode, Prism::StringNode then value.unescaped
337
+ end
338
+ end
339
+
340
+ def positional(node)
341
+ node.arguments&.arguments&.grep_v(Prism::KeywordHashNode) || []
342
+ end
343
+
344
+ def keyword_argument(node, name)
345
+ hash = node.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
346
+ pair = hash&.elements&.find do |element|
347
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
348
+ element.key.unescaped == name
349
+ end
350
+ pair&.value
351
+ end
352
+
353
+ # `ActiveStorage` → `active_storage`. The engine's own reading of a constant segment; deliberately
354
+ # not an inflector call, because a rule may read nothing outside the strings it was given.
355
+ def underscore(segment)
356
+ segment.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
357
+ end
358
+
359
+ private_class_method :rails_render, :rails_render_partial, :rails_implicit_render,
360
+ :controller_directory, :template_context, :template_name,
361
+ :positional_template, :positional_partial, :partial_name,
362
+ :format_for, :format_keyword?, :non_template?, :array_head, :template_callee,
363
+ :split_suffixes, :partialize,
364
+ :qualify, :keyword_name, :literal_name, :positional, :keyword_argument,
365
+ :underscore
366
+ end
367
+ end
368
+ end
@@ -36,14 +36,17 @@ module Rigor
36
36
  class Error < StandardError
37
37
  end
38
38
 
39
- DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__)
39
+ DATA_PATH = File.expand_path("../../../data/effects/core.yml", __dir__).freeze
40
40
 
41
41
  # The mutator sets a value class may name, by reference. Adding a name here is the only way a
42
- # class gets one — the data file may not spell a selector list of its own.
42
+ # class gets one — the data file may not spell a selector list of its own. Each is the set
43
+ # {MutationClassifier#mutating?} reads for that class, so a posture's answer and the classifier's
44
+ # cannot disagree: `hash` is the classifier's union of the widening's two Hash tables, because
45
+ # `compare_by_identity` mutates the receiver although the widening keeps it off `HASH_MUTATORS`.
43
46
  MUTATOR_SETS = {
44
47
  "array" => Inference::MutationWidening::ARRAY_MUTATORS,
45
- "hash" => Inference::MutationWidening::HASH_MUTATORS,
46
- "string" => MutationClassifier::STRING_MUTATORS
48
+ "hash" => MutationClassifier::HASH_MUTATORS,
49
+ "string" => Inference::StringMutation::MUTATORS
47
50
  }.freeze
48
51
 
49
52
  NO_MUTATORS = Set[].freeze
@@ -39,12 +39,16 @@ module Rigor
39
39
 
40
40
  # Per-file, per-thread accumulator. Not frozen and never shared: it lives for one `analyze_file`.
41
41
  class Accumulator
42
- attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts
42
+ attr_reader :path, :calls, :attribution, :envelopes, :plugin_facts, :unit_key, :unit_owner
43
43
  attr_accessor :root
44
44
 
45
45
  def initialize(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
46
- plugin_facts: PluginFacts.empty)
46
+ plugin_facts: PluginFacts.empty, unit_key: nil, unit_owner: nil)
47
47
  @path = path
48
+ # #392 — set for a template unit, where the whole file is ONE effect unit keyed
49
+ # `view:<logical_name>` under the plugin's declared `self`.
50
+ @unit_key = unit_key
51
+ @unit_owner = unit_owner
48
52
  @attribution = attribution
49
53
  @envelopes = envelopes
50
54
  @plugin_facts = plugin_facts
@@ -92,10 +96,11 @@ module Rigor
92
96
  # {EnvelopeIndex} of #386, carried the same way and for the same reason — the declared lane a call
93
97
  # site imports must not depend on which process typed the file.
94
98
  def collect_for(path, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
95
- plugin_facts: PluginFacts.empty)
99
+ plugin_facts: PluginFacts.empty, unit_key: nil, unit_owner: nil)
96
100
  previous = Thread.current[KEY]
97
101
  accumulator = Accumulator.new(path.to_s, attribution: attribution, envelopes: envelopes,
98
- plugin_facts: plugin_facts)
102
+ plugin_facts: plugin_facts, unit_key: unit_key,
103
+ unit_owner: unit_owner)
99
104
  Thread.current[KEY] = accumulator
100
105
  @mutex.synchronize { @active_count += 1 }
101
106
  yield
@@ -207,7 +212,8 @@ module Rigor
207
212
  Scanner.scan(
208
213
  root: accumulator.root, path: accumulator.path, calls: accumulator.calls,
209
214
  attribution: accumulator.attribution, envelopes: accumulator.envelopes,
210
- plugin_facts: accumulator.plugin_facts
215
+ plugin_facts: accumulator.plugin_facts, unit_key: accumulator.unit_key,
216
+ unit_owner: accumulator.unit_owner
211
217
  )
212
218
  rescue StandardError
213
219
  FileCollection.new(path: accumulator.path, failed: true)