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
@@ -5,7 +5,9 @@ require "prism"
5
5
  require_relative "../source/constant_path"
6
6
  require_relative "../source/node_children"
7
7
  require_relative "attribution"
8
+ require_relative "callee_rule"
8
9
  require_relative "catalog"
10
+ require_relative "definition_context"
9
11
  require_relative "envelope_index"
10
12
  require_relative "file_collection"
11
13
  require_relative "label_set"
@@ -35,12 +37,62 @@ module Rigor
35
37
  # Long by construction: the walk carries one `when` per Ruby construct that originates an effect, and
36
38
  # splitting that table across classes would put the vocabulary in one file and the reasons in another.
37
39
  class UnitScan # rubocop:disable Metrics/ClassLength
38
- # `$~` and friends are frame-local, not global state: a read of one is not `global.read`. (Prism
39
- # gives `$1` and `$&` node types of their own, so only the named specials need listing.)
40
- FRAME_LOCAL_GLOBALS = %w[$~ $_ $& $` $' $+ $!].to_set.freeze
40
+ # `$~` and `$_` are frame-local, not global state (#1363): Ruby keeps them in the special-variable slot
41
+ # of the body that runs them. The blocks that body creates reach the same slot, except the root block
42
+ # of a `Thread.new`, `Fiber.new` or `Ractor.new`, which has its own; a call into a method defined with
43
+ # `def` does not reach it. A read of one is not `global.read`, and a write (`$_ = line`, `$~ = nil`)
44
+ # binds only that slot, so it earns no label, as a local-variable write earns none — except in a
45
+ # `define_method` body (`@shared_slot`), which runs on the slot of the body that defined it and so
46
+ # shares it with every sibling defined there. The rest of the match family (`$&`, `` $` ``, `$'`, `$+`,
47
+ # `$1`…) are Prism nodes of their own that this scan does not colour, and none can be assigned.
48
+ FRAME_LOCAL_GLOBALS = %i[$~ $_].to_set.freeze
49
+
50
+ # `$!` and `$@` are the exception being rescued and its backtrace. They are not frame-local: a read
51
+ # reaches the dynamically enclosing rescue clause, whichever frame runs it — a caller's, or a callee's
52
+ # that yields to a block written here. So `$!` is an implicit argument of the running call rather than
53
+ # program state, and a read of it is not `global.read`. A read of `$@` reads that exception's backtrace
54
+ # as `e.backtrace` would, and a read of an object's state is never labelled. A write is another matter
55
+ # and stays `gvar-write`: `$@ = bt` changes that object, which the rescuing frame observes
56
+ # (`rescue => e` sees the new backtrace). Ruby refuses `$! = x`.
57
+ RESCUED_EXCEPTION_GLOBALS = %i[$! $@].to_set.freeze
58
+
59
+ # The globals whose read is not `global.read`.
60
+ UNCOLOURED_READS = (FRAME_LOCAL_GLOBALS | RESCUED_EXCEPTION_GLOBALS).freeze
41
61
 
42
62
  REFLECTIVE_SEND = %i[send public_send __send__].to_set.freeze
43
63
 
64
+ # The constructs under which a call may not run (#1048). Only the `responds:` bit reads this, and
65
+ # only to refuse to record a response the body might not perform: `render :edit if x` leaves the
66
+ # other path taking Rails' implicit render.
67
+ #
68
+ # The modifier forms need no entry of their own — Prism spells `render :x if y` as an ordinary
69
+ # `IfNode` — and `RescueNode` is the rescue CLAUSE rather than the body it guards, so a `render` in
70
+ # the `begin` half of `begin … rescue … end` is at depth zero, which is right: that half runs.
71
+ #
72
+ # A `LambdaNode` is here for the same reason and needs no exemption: `@after = -> { redirect_to
73
+ # "/" }` stores a response rather than performing one, and the body may never be called at all.
74
+ #
75
+ # A **block** is branching too, but is answered by {#branching?} rather than listed here, because
76
+ # only the CALL that owns it can say whether it is one. `User.transaction { redirect_to "/" }`,
77
+ # `[1].each { redirect_to "/" }` and `x.presence&.then { … }` all contain a call the body may not
78
+ # make, and recording a response from one would drop the implicit-render edge AND leave the unit
79
+ # reading exhaustive — the combination this bit exists to prevent.
80
+ BRANCHING = [
81
+ Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::CaseMatchNode,
82
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode,
83
+ Prism::RescueNode, Prism::RescueModifierNode, Prism::AndNode, Prism::OrNode,
84
+ Prism::LambdaNode
85
+ ].to_set.freeze
86
+
87
+ # The one block a response may be recorded through: `respond_to`'s own. It is the format dispatcher
88
+ # rather than a conditional, and it always runs — but its ARMS do not, so `f.html { … }` is an
89
+ # ordinary branching block and a `render json:` in the `f.json` arm no longer stands the `f.html`
90
+ # arm's implicit render down (#1048). The dispatcher's other half is the per-arm conventional edge
91
+ # (#1071): each `format.<fmt>` arm the body spells renders `<action>.<fmt>` where the arm's block
92
+ # does not answer on every path of its own body, exactly as an uninstrumented action renders
93
+ # `<action>.html`. The arms are read off the same syntax that marks the block transparent.
94
+ DISPATCH_SELECTORS = %i[respond_to respond_with].to_set.freeze
95
+
44
96
  # Selectors a per-class POSTURE default must never answer for, because a more specific reading of
45
97
  # the same site exists and would be swallowed: `send` and friends are the `dynamic-send` taint, and
46
98
  # `call` is the `opaque-callable` one. An explicit ROW still wins (`Fiddle::Function#call` is
@@ -85,9 +137,10 @@ module Rigor
85
137
  # declaration wherever it appears: in a class body it is the only way that method exists, and inside
86
138
  # another method it is a definition the enclosing method performs (`mutate.static`) rather than code
87
139
  # the enclosing method contains. A non-literal name has no key to file the block under, so it stays
88
- # contained in the enclosing method and this returns nil.
140
+ # contained in the enclosing method and this returns nil. Which side the method lands on is the
141
+ # {DefinitionContext}'s to say.
89
142
  #
90
- # @return `[name, singleton, body, parameters]`
143
+ # @return `[name, body, parameters]`
91
144
  def self.define_method_unit(node)
92
145
  return nil unless node.name == :define_method && node.receiver.nil?
93
146
 
@@ -97,11 +150,13 @@ module Rigor
97
150
  block = node.block
98
151
  return nil unless block.is_a?(Prism::BlockNode)
99
152
 
100
- [first.unescaped, false, block.body, block.parameters]
153
+ [first.unescaped, block.body, block.parameters]
101
154
  end
102
155
 
103
- # @param singleton — whether the unit's `self` is the class object (`def self.x`,
104
- # `class << self`) — the axis that separates `mutate.self` from `mutate.static` on an ivar write
156
+ # @param context — the {DefinitionContext} the body runs under. Its singleton bit — whether the
157
+ # unit's `self` is a class object (`def self.x`, a `def` in `class << self`) — is the axis that
158
+ # separates `mutate.self` from `mutate.static` on an ivar write; the rest says where the units
159
+ # nested in the body land
105
160
  # @param block_parameter — the unit's `&blk` parameter name, if any; a call on it is
106
161
  # forwarding, not an opaque callable
107
162
  # @param calls — node-identity table of {Collector::CallRecord}s
@@ -113,10 +168,24 @@ module Rigor
113
168
  # @param method_name — this unit's own selector — what a `super` in its body names as
114
169
  # the target the propagator resolves above `owner_class` (#446). With no name to state, a `super`
115
170
  # taints instead.
116
- def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
171
+ # @param non_public — whether the class body declared this unit `private` or `protected`
172
+ # (#1048). Only a UNIT callee rule reads it, and only to decline: Rails' `action_methods` is a
173
+ # controller's PUBLIC instance methods, so a private helper is never implicitly rendered — while
174
+ # a project that happens to ship a template of the same name would otherwise hand that
175
+ # template's effects to the helper.
176
+ # @param shared_slot — whether the body is a `define_method` block, which runs on the special-variable
177
+ # slot of the body that defined it rather than on one of its own (#1363). A write to a
178
+ # {FRAME_LOCAL_GLOBALS} name there reaches every sibling defined in that body, so it stays
179
+ # `global.write`.
180
+ def initialize(context:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
117
181
  attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
118
- plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
182
+ plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil,
183
+ non_public: false, shared_slot: false)
184
+ singleton = context.singleton?
119
185
  @singleton = singleton
186
+ # The context at the walk's current position. It starts as the body's own and moves only inside a
187
+ # block or `class << self` body that rebinds `self`; the unit's singleton bit does not move with it.
188
+ @context = context
120
189
  @block_parameter = block_parameter
121
190
  @calls = calls
122
191
  @attribution = attribution
@@ -124,6 +193,8 @@ module Rigor
124
193
  @plugin_facts = plugin_facts
125
194
  @owner_class = owner_class
126
195
  @method_name = method_name
196
+ @non_public = non_public ? true : false
197
+ @shared_slot = shared_slot ? true : false
127
198
  @mutation = MutationClassifier.new(
128
199
  singleton: singleton, parameters: parameters, owned_locals: owned_locals
129
200
  )
@@ -133,10 +204,44 @@ module Rigor
133
204
  @edges = []
134
205
  @nested = []
135
206
  @delegates_upward = false
207
+ # #1048 — whether a plugin row marked `responds:` fired **unconditionally** in this unit, i.e.
208
+ # the body supplied the framework's answer on every path through it. It is what stands a UNIT
209
+ # callee rule down: an action that called `render :edit` or `redirect_to` outright did not take
210
+ # Rails' implicit render.
211
+ #
212
+ # Conditional does not count, and that is the whole of the bit's care. `redirect_to root_path if
213
+ # @user.nil?` leaves the other path taking the implicit render, so a unit that recorded the
214
+ # response there would drop the template edge AND read exhaustive — the one combination an
215
+ # effect summary may never produce. {@conditional} counts the branching ancestors the walk is
216
+ # inside; only a depth of zero answers.
217
+ @responded = false
218
+ @conditional = 0
219
+ @transparent_blocks = Set.new.compare_by_identity
220
+ # #1071 — a `respond_to` dispatcher's format arms, read for the per-arm conventional edge that
221
+ # replaced the single `html` unit edge. Each arm is one `format.<fmt>` call made on the
222
+ # dispatcher's block parameter, and an arm's own block is a branch the scan walks with the same
223
+ # {@conditional} bit scoped to it: a `responds:` row fired at the arm's top level stands THAT
224
+ # arm's conventional template down, exactly as a unit-level response stands the implicit render
225
+ # down. `@arms_by_block` maps an arm block node to its arm so the walk can open the scope;
226
+ # `@arm_stack` holds the open ones with the depth their body's top level sits at;
227
+ # `@block_stack` is every open block node, so an arm call is recognised by its enclosing block
228
+ # being one of {@transparent_blocks}; `@dispatch_top_level` remembers whether a dispatcher was
229
+ # spelled at the unit's top level, which is what lets the unit rule stand down for the standard
230
+ # responder while an action whose `respond_to` is inside a branch still keeps its plain implicit
231
+ # render.
232
+ @format_arms = []
233
+ @arms_by_block = {}.compare_by_identity
234
+ @arm_stack = []
235
+ @block_stack = []
236
+ @dispatch_top_level = false
237
+ # #391 — set only where a site could not carry the bit on an edge; see {#record_edge}.
238
+ @unclaimed = false
136
239
  end
137
240
 
138
241
  # Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
139
- # block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
242
+ # block becomes that method's body. Each is `[name, body context, body_node, parameters_node,
243
+ # shared_slot]`, the context being the {DefinitionContext}'s answer at the definition and `shared_slot`
244
+ # true for the `define_method` block (see {#initialize}). One it cannot place is omitted.
140
245
  attr_reader :nested
141
246
 
142
247
  # Whether this body reaches `super` — an override that delegates upward still runs whatever the
@@ -153,13 +258,22 @@ module Rigor
153
258
  # Walks `body` and returns `[Summary, edges]`.
154
259
  def run(body)
155
260
  walk(body)
261
+ apply_unit_callees
156
262
  summary = Summary.new(
157
263
  bundles: @bundles, declared_bundles: @declared_bundles,
158
- exhaustive: @causes.empty?, causes: @causes
264
+ exhaustive: @causes.empty?, causes: @causes, unclaimed: @unclaimed
159
265
  )
160
266
  [summary, @edges]
161
267
  end
162
268
 
269
+ # One `format.<fmt>` arm of a `respond_to` dispatcher (#1071): the format it spells, and whether a
270
+ # plugin `responds:` row fired unconditionally in the arm's own block. `responded` is mutated by the
271
+ # walk (a Struct, unlike the Data rows the rules produce) and `conventional?` is the any/all
272
+ # exclusion — those two arms serve every format and can name no single template.
273
+ FormatArm = Struct.new(:format, :responded, keyword_init: true) do
274
+ def conventional? = format != "any" && format != "all"
275
+ end
276
+
163
277
  private
164
278
 
165
279
  def add(origin, labels)
@@ -183,21 +297,66 @@ module Rigor
183
297
  return if unit_boundary?(node)
184
298
 
185
299
  visit(node)
186
- node.rigor_each_child { |child| walk(child) }
300
+ return walk_children(node) unless branching?(node)
301
+
302
+ @conditional += 1
303
+ arm = enter_arm_block(node)
304
+ walk_children(node)
305
+ @arm_stack.pop if arm
306
+ @conditional -= 1
307
+ end
308
+
309
+ # The children, with the block stack maintained: an arm call is recognised by its innermost
310
+ # enclosing block being one of the dispatcher's transparent ones, so every `BlockNode` the walk
311
+ # descends into has to be on the stack while its body runs (#1071).
312
+ def walk_children(node)
313
+ if node.is_a?(Prism::BlockNode)
314
+ @block_stack.push(node)
315
+ node.rigor_each_child { |child| walk(child) }
316
+ @block_stack.pop
317
+ elsif DefinitionContext.rebinds?(node)
318
+ walk_rebinding(node)
319
+ else
320
+ node.rigor_each_child { |child| walk(child) }
321
+ end
322
+ end
323
+
324
+ # The children of a node one of which may run under another {DefinitionContext} — a `class_eval`
325
+ # block, a `class << self` body — so a unit nested there is keyed where Ruby defines it.
326
+ def walk_rebinding(node)
327
+ outer = @context
328
+ node.rigor_each_child do |child|
329
+ @context = outer.for_child(node, child)
330
+ walk(child)
331
+ end
332
+ @context = outer
333
+ end
334
+
335
+ # Whether the walk is entering a construct whose body may not run. A `BlockNode` answers from the
336
+ # call that owns it, which {#visit_call} has already marked — `walk` visits a node before its
337
+ # children, so the mark is always in place by the time the block is reached.
338
+ def branching?(node)
339
+ return !@transparent_blocks.include?(node) if node.is_a?(Prism::BlockNode)
340
+
341
+ BRANCHING.include?(node.class)
187
342
  end
188
343
 
189
344
  # A nested unit is recorded and NOT descended into: its body belongs to its own summary, and the
190
- # enclosing method gets only the `mutate.static` of having defined it.
345
+ # enclosing method gets only the `mutate.static` of having defined it. One the {DefinitionContext}
346
+ # cannot place is not descended into either, and belongs to no summary.
191
347
  def unit_boundary?(node)
192
348
  case node
193
349
  when Prism::DefNode
194
- @nested << [node.name.to_s, !node.receiver.nil?, node.body, node.parameters]
350
+ context = @context.def_body(node)
351
+ @nested << [node.name.to_s, context, node.body, node.parameters, false] if context
195
352
  true
196
353
  when Prism::CallNode
197
354
  declared = self.class.define_method_unit(node)
198
355
  return false unless declared
199
356
 
200
- @nested << declared
357
+ name, body, parameters = declared
358
+ context = @context.module_call_body
359
+ @nested << [name, context, body, parameters, true] if context
201
360
  add(DEFINE_METHOD, MUTATE_STATIC)
202
361
  true
203
362
  else
@@ -211,10 +370,12 @@ module Rigor
211
370
  when Prism::XStringNode, Prism::InterpolatedXStringNode
212
371
  add(XSTRING, IO_PROCESS)
213
372
  when Prism::GlobalVariableReadNode
214
- add(GVAR_READ, GLOBAL_READ) unless FRAME_LOCAL_GLOBALS.include?(node.name.to_s)
373
+ add(GVAR_READ, GLOBAL_READ) unless UNCOLOURED_READS.include?(node.name)
215
374
  when Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode,
216
- Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode
217
- add(GVAR_WRITE, GLOBAL_WRITE)
375
+ Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode,
376
+ Prism::GlobalVariableTargetNode
377
+ # A target is a multiple assignment's, a `for` loop's or a `rescue =>` clause's.
378
+ add(GVAR_WRITE, GLOBAL_WRITE) unless !@shared_slot && FRAME_LOCAL_GLOBALS.include?(node.name)
218
379
  when Prism::ClassVariableReadNode
219
380
  add(CVAR_READ, GLOBAL_READ)
220
381
  when Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode,
@@ -256,6 +417,8 @@ module Rigor
256
417
  end
257
418
 
258
419
  def visit_call(node)
420
+ mark_transparent_block(node)
421
+ record_format_arm(node)
259
422
  record = @calls[node]
260
423
  attribute(node, record)
261
424
  plugin = attribute_plugin(node, record)
@@ -270,6 +433,64 @@ module Rigor
270
433
  visit_block_argument(node)
271
434
  end
272
435
 
436
+ # `respond_to do |format| … end` — the block that is a dispatcher rather than a branch. Marked by
437
+ # identity, because a `BlockNode` cannot name the call it belongs to and two structurally equal
438
+ # blocks in one body are two blocks (#1048). A dispatcher spelled at the unit's top level is also
439
+ # remembered (#1071): its arms answer for the whole body, so the conventional `<action>.html` unit
440
+ # edge stands down; a dispatcher inside a branch does not cover the fall-through path.
441
+ def mark_transparent_block(node)
442
+ return unless node.receiver.nil? && DISPATCH_SELECTORS.include?(node.name)
443
+
444
+ block = node.block
445
+ @transparent_blocks << block if block.is_a?(Prism::BlockNode)
446
+ @dispatch_top_level ||= true if @conditional.zero?
447
+ end
448
+
449
+ # #1071 — one `format.<fmt>` arm of the enclosing `respond_to`, when the formatter is the block's
450
+ # own parameter: the edge its arm answers for is the conventional `<action>.<fmt>` template, and the
451
+ # walk needs the arm registered and its block (if any) mapped before it descends into that block's
452
+ # body. A call made on anything else in the block is not an arm; a formatter nobody assigned a
453
+ # parameter is a dispatcher whose arms cannot be read, and the unit rule simply keeps its old
454
+ # single answer.
455
+ def record_format_arm(node)
456
+ enclosing = @block_stack.last
457
+ return unless enclosing.is_a?(Prism::BlockNode) && @transparent_blocks.include?(enclosing)
458
+
459
+ parameter = block_parameter(enclosing)
460
+ return if parameter.nil?
461
+ return unless node.receiver.is_a?(Prism::LocalVariableReadNode) && node.receiver.name.to_s == parameter
462
+
463
+ arm = FormatArm.new(format: node.name.to_s)
464
+ @format_arms << arm
465
+ block = node.block
466
+ @arms_by_block[block] = arm if block.is_a?(Prism::BlockNode)
467
+ end
468
+
469
+ # The name of a dispatcher block's first required parameter — `|format|` in the ordinary shape. A
470
+ # `respond_to` whose block spells no parameter (or a splat, or a destructure) has no formatter the
471
+ # walk can name arms on.
472
+ def block_parameter(block)
473
+ params = block.parameters
474
+ return nil unless params.is_a?(Prism::BlockParametersNode)
475
+
476
+ required = params.parameters&.requireds
477
+ return nil if required.nil? || required.empty?
478
+
479
+ first = required.first
480
+ first.name.to_s if first.respond_to?(:name)
481
+ end
482
+
483
+ # Opens an arm scope for the walk: the arm's own block is a branch like any other, and its body's
484
+ # top level sits at the current {@conditional} depth — the value a `responds:` row must fire at for
485
+ # the ARM to count as answered outright.
486
+ def enter_arm_block(node)
487
+ arm = @arms_by_block[node]
488
+ return nil unless arm
489
+
490
+ @arm_stack << [arm, @conditional]
491
+ arm
492
+ end
493
+
273
494
  # The **plugin stratum** (#387; ADR-103 WD6 / WD10): what the plugin that models a framework says
274
495
  # this call does.
275
496
  #
@@ -290,15 +511,122 @@ module Rigor
290
511
  row = plugin_row(node, record)
291
512
  return nil if row.nil?
292
513
 
514
+ @responded = true if row.responds && @conditional.zero?
515
+ mark_arm_responded(row)
516
+ edged = callee_edge_taken?(node, row)
293
517
  labels = row.narrow ? Narrowing.apply(row.narrow, node) : row.labels
294
- return row if labels.nil? || labels.empty?
518
+ # A `narrow:` that narrowed to nothing says the call does nothing, and a call that does nothing
519
+ # taints nothing. A row that declares no labels at all is a different statement — since #1048 a
520
+ # row may contribute an EDGE and no label — so it falls through to the taints rather than
521
+ # returning here.
522
+ return row if row.narrow && (labels.nil? || labels.empty?)
523
+
524
+ add_declared(Origin.plugin(row.key), labels) unless labels.nil? || labels.empty?
525
+ record_plugin_taints(row, edged)
526
+ row
527
+ end
528
+
529
+ # The `responds:` bit scoped to an OPEN format arm (#1071): a row fired at the arm's body top
530
+ # level, so the arm never falls through to its conventional `<action>.<fmt>` template. A deeper
531
+ # response does not stand the arm's own fall-through down, exactly as a conditional response does
532
+ # not stand a unit's implicit render down.
533
+ def mark_arm_responded(row)
534
+ return unless row.responds
295
535
 
296
- add_declared(Origin.plugin(row.key), labels)
297
- # A row may discharge AND still taint: `render` states exactly what the CONTROLLER does and says
298
- # nothing about the template, which is not an effect unit yet (ADR-103 WD11).
299
- taint(row.taint, row.key) if row.taint
536
+ arm, depth = @arm_stack.last
537
+ return if arm.nil?
538
+
539
+ arm.responded = true if @conditional == depth
540
+ end
541
+
542
+ # A row may discharge AND still taint: `render` states exactly what the CONTROLLER does and says
543
+ # nothing about the template. Where a {CalleeRule} named the template the taint rides the EDGE
544
+ # instead (#1048), so a render that reaches a real unit clears it and one that reaches nothing
545
+ # keeps it — decided by the propagator, which is the only thing that knows.
546
+ def record_plugin_taints(row, edged)
547
+ taint(row.taint, row.key) if row.taint && !edged
300
548
  taint("plugin-attribution", row.key) unless row.discharge?
301
- row
549
+ end
550
+
551
+ # #1048 — the call-graph edge a framework method IS, when the plugin's row named a {CalleeRule}.
552
+ # `render :show` inside `UsersController` runs `view:users/show.html`, which is a unit in the same
553
+ # table; the rule reads the call's argument literals and nothing else, and answers nil for anything
554
+ # it cannot settle — a computed target, an unmodelled option, a receiver whose class names no view
555
+ # directory. Nil leaves the site exactly as it was, taint included.
556
+ #
557
+ # @return whether an edge took the row's taint with it
558
+ def callee_edge_taken?(node, row)
559
+ return false if row.callee.nil? || !CalleeRule.site_rule?(row.callee)
560
+
561
+ callee = CalleeRule.site(row.callee, node, owner_class: @owner_class, unit_key: @method_name,
562
+ fallbacks: row.callee_fallbacks)
563
+ return false if callee.nil?
564
+
565
+ @edges << FileCollection::Edge.new(
566
+ receiver_class: callee.receiver, kind: :singleton, selector: callee.selector, self_call: false,
567
+ taint_if_unresolved: row.taint ? [row.taint, row.key].freeze : nil,
568
+ fallback_selectors: callee.fallbacks
569
+ )
570
+ !row.taint.nil?
571
+ end
572
+
573
+ # The UNIT callee rules (#1048): an edge a plugin declares for a body that made no call at all.
574
+ # Rails' implicit render is the whole of the case — an action that falls off its end renders
575
+ # `<controller>/<action>` — so the producing fact is the ABSENCE of a `responds:` row, which only a
576
+ # finished unit scan can observe.
577
+ #
578
+ # Contributes an edge and nothing else — no label, no taint — which is what keeps it from being
579
+ # wrong about a method that is not an action at all.
580
+ #
581
+ # Two units it never applies to, and both are the same false positive twice: a `private` /
582
+ # `protected` member (Rails' `action_methods` is public only) and a `def` nested inside another
583
+ # method. A project with `app/views/users/card.html.erb` and a `private def card` would otherwise
584
+ # hand that template's `io.db.write` to the helper.
585
+ #
586
+ # #1071 widens the answer per `respond_to` arm: a unit that spelled a dispatcher renders one
587
+ # conventional `<action>.<fmt>` template per arm rather than one `<action>.html`, so the row is
588
+ # applied once per arm the walk read, each answered outright (a `responds:` row at the arm's own
589
+ # top level) or degenerate (`any` / `all`) standing its arm's edge down. The `<action>.html`
590
+ # answer survives only for a dispatcher the walk could not name arms on, or one nested under a
591
+ # branch — the plain fall-through still exists on the path that skips it.
592
+ def apply_unit_callees
593
+ return if @responded || @singleton || @non_public
594
+ return if @owner_class.nil? || @method_name.nil?
595
+
596
+ rows = @plugin_facts.unit_callee_rows
597
+ return if rows.empty?
598
+
599
+ rows.each do |row|
600
+ next unless @plugin_facts.descends_from?(@owner_class, row.receiver)
601
+
602
+ apply_unit_callee_row(row)
603
+ end
604
+ end
605
+
606
+ # One unit callee row across the unit's format arms: nil targets the plain `<action>.html` implicit
607
+ # render, and one application per arm reaches that arm's own `<action>.<fmt>` convention (#1071) —
608
+ # an arm that answered outright, or one serving every format (`any` / `all`), names nothing.
609
+ def apply_unit_callee_row(row)
610
+ return apply_unit_callee(row, nil) if @format_arms.empty?
611
+
612
+ apply_unit_callee(row, nil) unless @dispatch_top_level
613
+ @format_arms.each { |arm| apply_unit_callee(row, arm) }
614
+ end
615
+
616
+ # Applies one unit callee row's rule for one target format.
617
+ # @return the {Callee} the edge was recorded for, or nil when the arm (or the format) names nothing
618
+ def apply_unit_callee(row, arm)
619
+ return nil if arm && !arm.conventional?
620
+ return nil if arm&.responded
621
+
622
+ format = arm&.format
623
+ callee = CalleeRule.unit(row.callee, owner_class: @owner_class, unit_key: @method_name, format: format)
624
+ return nil if callee.nil?
625
+
626
+ @edges << FileCollection::Edge.new(
627
+ receiver_class: callee.receiver, kind: :singleton, selector: callee.selector, self_call: false
628
+ )
629
+ callee
302
630
  end
303
631
 
304
632
  def plugin_row(node, record)
@@ -545,7 +873,11 @@ module Rigor
545
873
  selector = literal_selector(node.arguments&.arguments&.first)
546
874
  return taint("dynamic-send") unless selector
547
875
 
548
- push_edge(record, selector, node.receiver.nil?)
876
+ # A literal `send` is an ordinary call and nothing bounded it, so it is unclaimed on the same
877
+ # terms as {#record_edge}'s.
878
+ push_edge(record, selector, node.receiver.nil?,
879
+ unclaimed: true, constant_receiver: constant_receiver?(node.receiver))
880
+ @unclaimed = true if !node.receiver.nil? && !edge_recordable?(record)
549
881
  end
550
882
 
551
883
  def literal_selector(node)
@@ -571,7 +903,14 @@ module Rigor
571
903
  # such calls are ordinary inherited ones the catalogue simply has no row for.
572
904
  def record_edge(node, record, bound = nil)
573
905
  self_call = node.receiver.nil?
574
- push_edge(record, node.name.to_s, self_call)
906
+ # #391 — an uncatalogued, unbounded site is a site whose callee nobody described. Whether that
907
+ # matters is the propagator's to decide: if the edge lands on a project definition the closure
908
+ # reads that definition's own summary and the site is fully accounted for. So the bit travels ON
909
+ # the edge, and the unit is marked directly only where there is no edge to carry it.
910
+ unclaimed = bound.nil?
911
+ push_edge(record, node.name.to_s, self_call,
912
+ unclaimed: unclaimed, constant_receiver: constant_receiver?(node.receiver))
913
+ @unclaimed = true if unclaimed && !self_call && !edge_recordable?(record)
575
914
  return unless self_call && (record.nil? || !record.resolved)
576
915
  # An envelope on this unit's own class for the very selector the dispatcher declined is the
577
916
  # project declaring the method and stating its bound; a discharging plugin row on the framework
@@ -583,14 +922,30 @@ module Rigor
583
922
  taint("unresolved-self-call", node.name.to_s)
584
923
  end
585
924
 
586
- def push_edge(record, selector, self_call)
587
- return if record.nil? || record.receiver_class.nil?
925
+ def push_edge(record, selector, self_call, unclaimed: false, constant_receiver: false)
926
+ return unless edge_recordable?(record)
588
927
 
589
928
  @edges << FileCollection::Edge.new(
590
- receiver_class: record.receiver_class, kind: record.kind, selector: selector, self_call: self_call
929
+ receiver_class: record.receiver_class, kind: record.kind, selector: selector,
930
+ self_call: self_call, unclaimed: unclaimed, constant_receiver: constant_receiver
591
931
  )
592
932
  end
593
933
 
934
+ # Whether the AUTHOR wrote this receiver as a constant path (#1039). The edge is otherwise keyed on
935
+ # the receiver's type, which cannot tell `Base.new` from `self.class.new` — the same `Singleton[Base]`
936
+ # in both — and the two reach different constructors. Everything else, a bare `self` included, is a
937
+ # receiver whose run-time class may be a subclass.
938
+ def constant_receiver?(receiver)
939
+ receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
940
+ end
941
+
942
+ # Whether {#push_edge} has a receiver to key an edge on. A call whose receiver the typer never
943
+ # named carries nothing the propagator can resolve, so an unclaimed site of that shape marks the
944
+ # unit directly instead of handing the bit to an edge that will not exist (#391).
945
+ def edge_recordable?(record)
946
+ !record.nil? && !record.receiver_class.nil?
947
+ end
948
+
594
949
  def visit_block_argument(node)
595
950
  block = node.block
596
951
  return unless block.is_a?(Prism::BlockArgumentNode)
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # The `private` / `protected` members a class body declared, read straight off its statement list in
8
+ # source order (#1048).
9
+ #
10
+ # One reader, and it only ever declines: a UNIT {CalleeRule} rule. Rails' `action_methods` is a
11
+ # controller's **public** instance methods, so a private helper is never implicitly rendered as
12
+ # `<controller>/<helper>` — while a project that happens to ship a template of that name would
13
+ # otherwise hand the template's effects to the helper. `private def card` beside
14
+ # `app/views/users/card.html.erb` is the measured shape.
15
+ #
16
+ # Deliberately shallow and deliberately syntactic. It reads the class body's own top level, which is
17
+ # where all three spellings appear in practice, and answers "public" for anything it cannot see —
18
+ # `send(:private, :card)`, a visibility change in a `class_eval`, a concern that privatises on
19
+ # include. Declining to decline leaves today's behaviour intact, which is the right direction for a
20
+ # bit whose only power is to remove an edge.
21
+ module Visibility
22
+ # The markers whose ARGUMENT form names members. `public` is one of them and **subtracts**:
23
+ # `private; def reopened; end; public :reopened` is a public action, and an answer that only ever
24
+ # grew would mark it private and silently drop its implicit-render edge.
25
+ HIDING = %i[private protected].freeze
26
+ SHOWING = :public
27
+
28
+ NONE = [].freeze
29
+ private_constant :NONE
30
+
31
+ module_function
32
+
33
+ # The method names `body` — a class or module body — declared non-public.
34
+ def non_public_names(body)
35
+ statements = body.respond_to?(:body) ? Array(body.body) : NONE
36
+ names = Set.new
37
+ region = false
38
+ statements.each do |statement|
39
+ case statement
40
+ # A `def self.x` is not the instance method a later `def x` defines, and the two share a name.
41
+ # Recording the singleton would mark the instance method private, which is the same false
42
+ # negative from the other side.
43
+ when Prism::DefNode then names << statement.name.to_s if region && statement.receiver.nil?
44
+ when Prism::CallNode
45
+ region = region_after(statement, region)
46
+ apply_targets(names, statement)
47
+ end
48
+ end
49
+ names
50
+ end
51
+
52
+ # Whether a **bare** `private` / `protected` / `public` opened or closed the region. A marker with
53
+ # arguments names specific members instead and leaves the region exactly as it was, which is what
54
+ # lets `private def a` sit above a `public` region without closing it.
55
+ def region_after(node, region)
56
+ return region unless bare?(node)
57
+
58
+ case node.name
59
+ when :private, :protected then true
60
+ when :public then false
61
+ else region
62
+ end
63
+ end
64
+
65
+ # `private def foo` / `private :foo, :bar`, and their inverse `public def foo` / `public :foo`.
66
+ def apply_targets(names, node)
67
+ return unless node.receiver.nil?
68
+ return names.merge(argument_names(node)) if HIDING.include?(node.name)
69
+ return unless node.name == SHOWING
70
+
71
+ argument_names(node).each { |name| names.delete(name) }
72
+ end
73
+
74
+ def argument_names(node)
75
+ arguments = node.arguments&.arguments
76
+ return NONE if arguments.nil? || arguments.empty?
77
+
78
+ arguments.flat_map { |argument| names_in(argument) }
79
+ end
80
+
81
+ # One argument's names. The array form (`private %i[a b]`, `private [:a, :b]`) is read because it
82
+ # is ordinary and costs a line; a **splat** (`private(*names)`) is not, because the names are a
83
+ # value rather than syntax, and such a member reads as public — the direction that leaves
84
+ # today's behaviour intact rather than guessing.
85
+ def names_in(argument)
86
+ case argument
87
+ when Prism::DefNode then [argument.name.to_s]
88
+ when Prism::SymbolNode, Prism::StringNode then [argument.unescaped]
89
+ when Prism::ArrayNode then argument.elements.flat_map { |element| names_in(element) }
90
+ else NONE
91
+ end
92
+ end
93
+
94
+ def bare?(node)
95
+ node.receiver.nil? && node.arguments.nil? && node.block.nil?
96
+ end
97
+
98
+ private_class_method :region_after, :apply_targets, :argument_names, :names_in, :bare?
99
+ end
100
+ end
101
+ end