rigortype 0.3.8 → 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 (634) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/capability_roles/capability_roles.rbs +36 -0
  4. data/data/core_overlay/enumerable.rbs +51 -0
  5. data/data/core_overlay/enumerator.rbs +84 -0
  6. data/data/core_overlay/hash_rbs3.rbs +41 -0
  7. data/data/core_overlay/process.rbs +40 -0
  8. data/data/core_overlay/string_io.rbs +33 -0
  9. data/data/effects/core.yml +3 -3
  10. data/data/gem_overlay/activesupport/core_ext.rbs +236 -17
  11. data/docs/handbook/02-everyday-types.md +17 -14
  12. data/docs/handbook/03-narrowing.md +132 -14
  13. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  14. data/docs/handbook/05-methods-and-blocks.md +1 -1
  15. data/docs/handbook/06-classes.md +2 -2
  16. data/docs/handbook/07-rbs-and-extended.md +19 -1
  17. data/docs/handbook/10-sorbet.md +9 -10
  18. data/docs/handbook/11-sig-gen.md +525 -13
  19. data/docs/handbook/README.md +1 -1
  20. data/docs/handbook/appendix-elixir.md +2 -2
  21. data/docs/handbook/appendix-go.md +2 -2
  22. data/docs/handbook/appendix-java-csharp.md +2 -2
  23. data/docs/handbook/appendix-mypy.md +2 -2
  24. data/docs/handbook/appendix-phpstan.md +1 -1
  25. data/docs/handbook/appendix-rust.md +1 -1
  26. data/docs/handbook/appendix-type-theory.md +4 -4
  27. data/docs/handbook/appendix-typescript.md +1 -1
  28. data/docs/llms.txt +2 -0
  29. data/docs/manual/02-cli-reference.md +95 -6
  30. data/docs/manual/03-configuration.md +18 -0
  31. data/docs/manual/04-diagnostics.md +15 -0
  32. data/docs/manual/07-plugins.md +15 -5
  33. data/docs/manual/08-skills.md +23 -2
  34. data/docs/manual/10-mcp-server.md +3 -2
  35. data/docs/manual/11-ci.md +9 -0
  36. data/docs/manual/15-type-protection-coverage.md +8 -0
  37. data/docs/manual/16-rbs-extended-annotations.md +46 -8
  38. data/docs/manual/18-removing-dead-code.md +10 -8
  39. data/docs/manual/19-effect-labels.md +10 -0
  40. data/docs/manual/plugins/README.md +7 -0
  41. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  42. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  43. data/docs/manual/plugins/rigor-actionpack.md +261 -0
  44. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  45. data/docs/manual/plugins/rigor-activejob.md +7 -0
  46. data/docs/manual/plugins/rigor-activerecord.md +138 -4
  47. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  48. data/docs/manual/plugins/rigor-activesupport-core-ext.md +43 -7
  49. data/docs/manual/plugins/rigor-grape.md +106 -0
  50. data/docs/manual/plugins/rigor-graphql.md +23 -2
  51. data/docs/manual/plugins/rigor-pundit.md +8 -1
  52. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  53. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  54. data/docs/manual/plugins/rigor-rbs-inline.md +229 -1
  55. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  56. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  57. data/exe/rigor +19 -4
  58. data/lib/rigor/analysis/baseline.rb +3 -3
  59. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +1 -1
  60. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +1 -5
  61. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  62. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +10 -2
  63. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  64. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +6 -4
  65. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +15 -6
  66. data/lib/rigor/analysis/check_rules/rule_ids.rb +21 -6
  67. data/lib/rigor/analysis/check_rules/rule_walk.rb +41 -5
  68. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +1 -1
  69. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +1 -1
  70. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  71. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  72. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +38 -4
  73. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +0 -1
  74. data/lib/rigor/analysis/check_rules.rb +718 -79
  75. data/lib/rigor/analysis/crash_signature.rb +8 -24
  76. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  77. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +1 -1
  78. data/lib/rigor/analysis/dependency_source_inference/builder.rb +0 -2
  79. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +0 -2
  80. data/lib/rigor/analysis/dependency_source_inference/index.rb +5 -5
  81. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +1 -2
  82. data/lib/rigor/analysis/dependency_source_inference/walker.rb +4 -4
  83. data/lib/rigor/analysis/effects_cache_probe.rb +3 -4
  84. data/lib/rigor/analysis/erb_template_detector.rb +1 -2
  85. data/lib/rigor/analysis/fact_store.rb +15 -1
  86. data/lib/rigor/analysis/incremental.rb +42 -0
  87. data/lib/rigor/analysis/incremental_session.rb +117 -26
  88. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +0 -1
  89. data/lib/rigor/analysis/project_scan.rb +11 -1
  90. data/lib/rigor/analysis/reachability/graph.rb +3 -5
  91. data/lib/rigor/analysis/reachability/plugin_roots.rb +5 -5
  92. data/lib/rigor/analysis/reachability/project_files.rb +2 -2
  93. data/lib/rigor/analysis/reachability/scan.rb +5 -5
  94. data/lib/rigor/analysis/reachability/scan_cache.rb +3 -2
  95. data/lib/rigor/analysis/reachability/signature_scan.rb +2 -2
  96. data/lib/rigor/analysis/result.rb +1 -3
  97. data/lib/rigor/analysis/rule_catalog.rb +131 -1
  98. data/lib/rigor/analysis/run_cache_key.rb +21 -12
  99. data/lib/rigor/analysis/run_cache_probe.rb +3 -5
  100. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -8
  101. data/lib/rigor/analysis/runner/declaration_position.rb +1 -2
  102. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +340 -33
  103. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +2 -3
  104. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +18 -14
  105. data/lib/rigor/analysis/runner/pool_coordinator.rb +357 -97
  106. data/lib/rigor/analysis/runner/project_pre_passes.rb +38 -13
  107. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  108. data/lib/rigor/analysis/runner.rb +407 -51
  109. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  110. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  111. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  112. data/lib/rigor/analysis/template_units.rb +399 -0
  113. data/lib/rigor/analysis/worker_session.rb +62 -20
  114. data/lib/rigor/bleeding_edge.rb +10 -24
  115. data/lib/rigor/builtins/hkt_builtins.rb +3 -2
  116. data/lib/rigor/builtins/imported_refinements.rb +159 -21
  117. data/lib/rigor/builtins/predefined_constant_refinements.rb +5 -7
  118. data/lib/rigor/builtins/regex_refinement.rb +21 -13
  119. data/lib/rigor/builtins/static_return_refinements.rb +7 -6
  120. data/lib/rigor/cache/annotation_location.rb +2 -4
  121. data/lib/rigor/cache/descriptor.rb +63 -20
  122. data/lib/rigor/cache/engine_source.rb +33 -5
  123. data/lib/rigor/cache/file_digest.rb +9 -1
  124. data/lib/rigor/cache/incremental_snapshot.rb +72 -6
  125. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  126. data/lib/rigor/cache/rbs_class_ancestor_table.rb +0 -3
  127. data/lib/rigor/cache/rbs_class_type_param_names.rb +0 -3
  128. data/lib/rigor/cache/rbs_constant_table.rb +0 -3
  129. data/lib/rigor/cache/rbs_descriptor.rb +112 -14
  130. data/lib/rigor/cache/rbs_environment.rb +8 -4
  131. data/lib/rigor/cache/rbs_known_class_names.rb +0 -3
  132. data/lib/rigor/cache/store.rb +76 -32
  133. data/lib/rigor/ci_detector.rb +1 -0
  134. data/lib/rigor/cli/annotate_command.rb +5 -6
  135. data/lib/rigor/cli/check_command.rb +57 -10
  136. data/lib/rigor/cli/check_invocation.rb +6 -11
  137. data/lib/rigor/cli/check_runner_factory.rb +1 -5
  138. data/lib/rigor/cli/coverage_command.rb +1 -1
  139. data/lib/rigor/cli/coverage_mutation.rb +12 -3
  140. data/lib/rigor/cli/coverage_scan.rb +1 -5
  141. data/lib/rigor/cli/diff_command.rb +1 -1
  142. data/lib/rigor/cli/doc_links.rb +4 -4
  143. data/lib/rigor/cli/docs_command.rb +6 -6
  144. data/lib/rigor/cli/doctor_command.rb +85 -38
  145. data/lib/rigor/cli/effects_command.rb +2 -2
  146. data/lib/rigor/cli/effects_diff_renderer.rb +3 -3
  147. data/lib/rigor/cli/effects_snapshot_command.rb +1 -1
  148. data/lib/rigor/cli/explain_command.rb +34 -1
  149. data/lib/rigor/cli/fused_protection_report.rb +7 -1
  150. data/lib/rigor/cli/lsp_command.rb +1 -1
  151. data/lib/rigor/cli/mcp_command.rb +1 -1
  152. data/lib/rigor/cli/measurement_integrity_warning.rb +4 -5
  153. data/lib/rigor/cli/mutation_fork_scan.rb +6 -6
  154. data/lib/rigor/cli/mutation_protection_report.rb +7 -1
  155. data/lib/rigor/cli/plugin_command.rb +4 -4
  156. data/lib/rigor/cli/plugins_command.rb +2 -1
  157. data/lib/rigor/cli/prism_colorizer.rb +1 -2
  158. data/lib/rigor/cli/protection_fork_scan.rb +6 -6
  159. data/lib/rigor/cli/show_bleedingedge_command.rb +1 -1
  160. data/lib/rigor/cli/sig_gen_command.rb +212 -31
  161. data/lib/rigor/cli/skill_command.rb +2 -2
  162. data/lib/rigor/cli/skill_deep_probe.rb +4 -4
  163. data/lib/rigor/cli/skill_describe.rb +35 -35
  164. data/lib/rigor/cli/trace_command.rb +4 -4
  165. data/lib/rigor/cli/trace_renderer.rb +5 -6
  166. data/lib/rigor/cli/triage_command.rb +1 -1
  167. data/lib/rigor/cli/type_of_command.rb +29 -11
  168. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  169. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  170. data/lib/rigor/cli/type_scan_command.rb +4 -4
  171. data/lib/rigor/cli/unused_command.rb +11 -3
  172. data/lib/rigor/cli/upgrade_command.rb +1 -1
  173. data/lib/rigor/cli.rb +89 -9
  174. data/lib/rigor/config_audit.rb +30 -5
  175. data/lib/rigor/configuration/severity_profile.rb +25 -9
  176. data/lib/rigor/configuration.rb +109 -7
  177. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  178. data/lib/rigor/effects/attribution.rb +12 -5
  179. data/lib/rigor/effects/callee_rule.rb +368 -0
  180. data/lib/rigor/effects/catalog.rb +7 -4
  181. data/lib/rigor/effects/collector.rb +11 -5
  182. data/lib/rigor/effects/config_envelopes.rb +15 -10
  183. data/lib/rigor/effects/definition_context.rb +179 -0
  184. data/lib/rigor/effects/definition_lines.rb +25 -6
  185. data/lib/rigor/effects/effect_table.rb +11 -4
  186. data/lib/rigor/effects/entry_points.rb +0 -2
  187. data/lib/rigor/effects/envelope_check.rb +9 -9
  188. data/lib/rigor/effects/envelope_index.rb +20 -8
  189. data/lib/rigor/effects/file_collection.rb +59 -5
  190. data/lib/rigor/effects/framework_units.rb +5 -7
  191. data/lib/rigor/effects/identity.rb +20 -6
  192. data/lib/rigor/effects/inline_anchor.rb +7 -7
  193. data/lib/rigor/effects/label_intent.rb +3 -4
  194. data/lib/rigor/effects/liskov_check.rb +8 -8
  195. data/lib/rigor/effects/local_ownership.rb +38 -12
  196. data/lib/rigor/effects/method_key.rb +22 -1
  197. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  198. data/lib/rigor/effects/plugin_facts.rb +47 -37
  199. data/lib/rigor/effects/propagator.rb +297 -19
  200. data/lib/rigor/effects/registry.rb +1 -1
  201. data/lib/rigor/effects/scanner.rb +122 -73
  202. data/lib/rigor/effects/signature_sources.rb +4 -6
  203. data/lib/rigor/effects/snapshot.rb +12 -11
  204. data/lib/rigor/effects/snapshot_diff.rb +1 -1
  205. data/lib/rigor/effects/summary.rb +27 -4
  206. data/lib/rigor/effects/unit_scan.rb +393 -38
  207. data/lib/rigor/effects/unknown_label_check.rb +3 -8
  208. data/lib/rigor/effects/unknown_label_report.rb +3 -4
  209. data/lib/rigor/effects/visibility.rb +101 -0
  210. data/lib/rigor/environment/bundle_sig_discovery.rb +6 -6
  211. data/lib/rigor/environment/class_registry.rb +3 -1
  212. data/lib/rigor/environment/failure_slot.rb +2 -2
  213. data/lib/rigor/environment/installed_gem_set.rb +85 -0
  214. data/lib/rigor/environment/lockfile_resolver.rb +68 -4
  215. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  216. data/lib/rigor/environment/member_consistency.rb +298 -0
  217. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -4
  218. data/lib/rigor/environment/rbs_collection_discovery.rb +5 -5
  219. data/lib/rigor/environment/rbs_coverage_report.rb +5 -5
  220. data/lib/rigor/environment/rbs_hierarchy.rb +3 -1
  221. data/lib/rigor/environment/rbs_loader.rb +674 -79
  222. data/lib/rigor/environment.rb +174 -24
  223. data/lib/rigor/flow_contribution/merger.rb +0 -2
  224. data/lib/rigor/flow_contribution.rb +11 -13
  225. data/lib/rigor/hashing/xxh3.rb +264 -0
  226. data/lib/rigor/inference/acceptance.rb +292 -17
  227. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  228. data/lib/rigor/inference/block_call_timing.rb +338 -0
  229. data/lib/rigor/inference/block_parameter_binder.rb +75 -30
  230. data/lib/rigor/inference/block_repetition.rb +71 -0
  231. data/lib/rigor/inference/body_fixpoint.rb +7 -6
  232. data/lib/rigor/inference/budget_trace.rb +3 -6
  233. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  234. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  235. data/lib/rigor/inference/captured_locals.rb +389 -18
  236. data/lib/rigor/inference/closure_escape_analyzer.rb +159 -17
  237. data/lib/rigor/inference/content_join.rb +200 -27
  238. data/lib/rigor/inference/coverage_scanner.rb +4 -6
  239. data/lib/rigor/inference/def_return_typer.rb +11 -7
  240. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  241. data/lib/rigor/inference/dynamic_origin.rb +1 -1
  242. data/lib/rigor/inference/element_read_widening.rb +180 -0
  243. data/lib/rigor/inference/error_info.rb +196 -0
  244. data/lib/rigor/inference/expression_typer.rb +2124 -468
  245. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  246. data/lib/rigor/inference/fork_map.rb +5 -7
  247. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  248. data/lib/rigor/inference/global_write_census.rb +239 -0
  249. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  250. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  251. data/lib/rigor/inference/hkt_reducer.rb +2 -3
  252. data/lib/rigor/inference/hkt_registry.rb +4 -7
  253. data/lib/rigor/inference/index_write_widening.rb +16 -7
  254. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  255. data/lib/rigor/inference/jump_targets.rb +82 -0
  256. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  257. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  258. data/lib/rigor/inference/last_line.rb +340 -0
  259. data/lib/rigor/inference/last_status.rb +144 -0
  260. data/lib/rigor/inference/macro_block_self_type.rb +168 -21
  261. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  262. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  263. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  264. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  265. data/lib/rigor/inference/match_rebinding.rb +392 -0
  266. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  267. data/lib/rigor/inference/method_dispatcher/block_folding.rb +87 -33
  268. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +1 -1
  269. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +305 -19
  270. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  271. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  272. data/lib/rigor/inference/method_dispatcher/file_folding.rb +1 -1
  273. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  274. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  275. data/lib/rigor/inference/method_dispatcher/json_folding.rb +1 -1
  276. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +7 -1
  277. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  278. data/lib/rigor/inference/method_dispatcher/math_folding.rb +41 -3
  279. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -5
  280. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +165 -111
  281. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  282. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  283. data/lib/rigor/inference/method_dispatcher/random_folding.rb +82 -0
  284. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +1046 -69
  285. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +25 -4
  286. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +2 -8
  287. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +1 -1
  288. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  289. data/lib/rigor/inference/method_dispatcher/set_folding.rb +1 -1
  290. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +129 -54
  291. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +1 -1
  292. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +1 -2
  293. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +5 -6
  294. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +85 -4
  295. data/lib/rigor/inference/method_dispatcher/time_folding.rb +1 -1
  296. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +1 -2
  297. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +1 -1
  298. data/lib/rigor/inference/method_dispatcher.rb +225 -25
  299. data/lib/rigor/inference/method_parameter_binder.rb +12 -9
  300. data/lib/rigor/inference/multi_target_binder.rb +341 -55
  301. data/lib/rigor/inference/mutation_rejoin.rb +171 -0
  302. data/lib/rigor/inference/mutation_widening.rb +156 -85
  303. data/lib/rigor/inference/narrowing.rb +835 -162
  304. data/lib/rigor/inference/operand_effects.rb +167 -0
  305. data/lib/rigor/inference/operand_walk.rb +88 -0
  306. data/lib/rigor/inference/optimistic_origin.rb +153 -16
  307. data/lib/rigor/inference/origin_lookup.rb +2 -3
  308. data/lib/rigor/inference/parameter_inference_collector.rb +7 -7
  309. data/lib/rigor/inference/pre_eval_constants.rb +6 -5
  310. data/lib/rigor/inference/precision_scanner.rb +3 -4
  311. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  312. data/lib/rigor/inference/project_patched_methods.rb +9 -4
  313. data/lib/rigor/inference/project_patched_scanner.rb +10 -6
  314. data/lib/rigor/inference/protection_scanner.rb +1 -2
  315. data/lib/rigor/inference/range_constant.rb +57 -0
  316. data/lib/rigor/inference/rbs_type_translator.rb +49 -11
  317. data/lib/rigor/inference/receiver_alias.rb +93 -4
  318. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  319. data/lib/rigor/inference/refinement_mutation.rb +76 -0
  320. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  321. data/lib/rigor/inference/return_barrier.rb +54 -0
  322. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  323. data/lib/rigor/inference/scope_indexer.rb +5228 -627
  324. data/lib/rigor/inference/statement_evaluator.rb +3452 -595
  325. data/lib/rigor/inference/stored_block_call.rb +54 -0
  326. data/lib/rigor/inference/string_mutation.rb +97 -0
  327. data/lib/rigor/inference/struct_fold_safety.rb +6 -7
  328. data/lib/rigor/inference/synthetic_method_index.rb +1 -2
  329. data/lib/rigor/inference/synthetic_method_scanner.rb +4 -6
  330. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  331. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  332. data/lib/rigor/inference/version_guard.rb +28 -21
  333. data/lib/rigor/inference/void_origin.rb +3 -3
  334. data/lib/rigor/inference/void_tail_summary.rb +2 -4
  335. data/lib/rigor/language_server/buffer_table.rb +4 -4
  336. data/lib/rigor/language_server/completion_provider.rb +1 -1
  337. data/lib/rigor/language_server/debouncer.rb +0 -1
  338. data/lib/rigor/language_server/diagnostic_publisher.rb +7 -6
  339. data/lib/rigor/language_server/document_symbol_provider.rb +1 -1
  340. data/lib/rigor/language_server/folding_range_provider.rb +1 -1
  341. data/lib/rigor/language_server/hover_provider.rb +1 -1
  342. data/lib/rigor/language_server/hover_renderer.rb +4 -4
  343. data/lib/rigor/language_server/incremental_sync.rb +6 -6
  344. data/lib/rigor/language_server/project_context.rb +7 -8
  345. data/lib/rigor/language_server/publish_batcher.rb +2 -2
  346. data/lib/rigor/language_server/selection_range_provider.rb +2 -2
  347. data/lib/rigor/language_server/server.rb +9 -9
  348. data/lib/rigor/language_server/signature_help_provider.rb +1 -1
  349. data/lib/rigor/language_server/uri.rb +1 -1
  350. data/lib/rigor/mcp/server.rb +2 -1
  351. data/lib/rigor/plugin/base.rb +258 -26
  352. data/lib/rigor/plugin/box_probe.rb +91 -0
  353. data/lib/rigor/plugin/bundled_catalog.rb +167 -0
  354. data/lib/rigor/plugin/effect_attribution.rb +65 -11
  355. data/lib/rigor/plugin/effect_entry_points.rb +3 -3
  356. data/lib/rigor/plugin/fact_store.rb +6 -6
  357. data/lib/rigor/plugin/io_boundary.rb +93 -8
  358. data/lib/rigor/plugin/isolation.rb +29 -6
  359. data/lib/rigor/plugin/loader.rb +33 -13
  360. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  361. data/lib/rigor/plugin/macro/heredoc_template.rb +1 -1
  362. data/lib/rigor/plugin/macro/trait_registry.rb +1 -1
  363. data/lib/rigor/plugin/manifest.rb +94 -11
  364. data/lib/rigor/plugin/registry.rb +76 -12
  365. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +21 -4
  366. data/lib/rigor/plugin/template_unit.rb +196 -0
  367. data/lib/rigor/plugin/trust_policy.rb +2 -4
  368. data/lib/rigor/plugin/type_node_resolver.rb +3 -3
  369. data/lib/rigor/plugin.rb +1 -0
  370. data/lib/rigor/plugin_gap_advisory.rb +96 -0
  371. data/lib/rigor/project_environment.rb +138 -0
  372. data/lib/rigor/protection/analysis_guard.rb +93 -14
  373. data/lib/rigor/protection/closure_kill_oracle.rb +67 -23
  374. data/lib/rigor/protection/dependency_closure.rb +5 -8
  375. data/lib/rigor/protection/diagnostic_oracle.rb +2 -2
  376. data/lib/rigor/protection/discovery_seed.rb +14 -12
  377. data/lib/rigor/protection/kill_signature.rb +1 -4
  378. data/lib/rigor/protection/measurement_integrity.rb +1 -3
  379. data/lib/rigor/protection/mutation_cache.rb +7 -11
  380. data/lib/rigor/protection/mutation_scanner.rb +12 -15
  381. data/lib/rigor/protection/mutator.rb +2 -1
  382. data/lib/rigor/protection/test_suite_oracle.rb +5 -5
  383. data/lib/rigor/rbs_extended/conformance_checker.rb +4 -3
  384. data/lib/rigor/rbs_extended/envelope_scanner.rb +4 -6
  385. data/lib/rigor/rbs_extended/reporter.rb +36 -10
  386. data/lib/rigor/rbs_extended.rb +88 -21
  387. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  388. data/lib/rigor/reflection/constant_path.rb +139 -0
  389. data/lib/rigor/reflection.rb +93 -110
  390. data/lib/rigor/runtime/jit.rb +6 -8
  391. data/lib/rigor/scope/discovery_index.rb +165 -2
  392. data/lib/rigor/scope.rb +1041 -32
  393. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  394. data/lib/rigor/sig_gen/classification.rb +22 -5
  395. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  396. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  397. data/lib/rigor/sig_gen/generator.rb +673 -53
  398. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  399. data/lib/rigor/sig_gen/layout_index.rb +3 -4
  400. data/lib/rigor/sig_gen/meta_class_shape.rb +3 -5
  401. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  402. data/lib/rigor/sig_gen/observation_collector.rb +18 -16
  403. data/lib/rigor/sig_gen/path_mapper.rb +4 -6
  404. data/lib/rigor/sig_gen/rbs_validity.rb +4 -4
  405. data/lib/rigor/sig_gen/renderer.rb +128 -14
  406. data/lib/rigor/sig_gen/skip_reason_catalog.rb +154 -0
  407. data/lib/rigor/sig_gen/superclass_spelling.rb +27 -0
  408. data/lib/rigor/sig_gen/type_elaborator.rb +1 -3
  409. data/lib/rigor/sig_gen/write_result.rb +19 -3
  410. data/lib/rigor/sig_gen/writer.rb +189 -41
  411. data/lib/rigor/sig_gen.rb +3 -0
  412. data/lib/rigor/signature_path_audit.rb +153 -6
  413. data/lib/rigor/source/literals.rb +0 -23
  414. data/lib/rigor/source/node_children.rb +0 -2
  415. data/lib/rigor/source/node_locator.rb +5 -11
  416. data/lib/rigor/source/node_walker.rb +0 -6
  417. data/lib/rigor/source/parameter_envelope.rb +72 -0
  418. data/lib/rigor/source.rb +1 -0
  419. data/lib/rigor/triage/catalogue.rb +1 -3
  420. data/lib/rigor/triage.rb +3 -5
  421. data/lib/rigor/type/accepts_result.rb +24 -6
  422. data/lib/rigor/type/combinator.rb +144 -5
  423. data/lib/rigor/type/data_class.rb +2 -2
  424. data/lib/rigor/type/data_instance.rb +4 -4
  425. data/lib/rigor/type/difference.rb +1 -0
  426. data/lib/rigor/type/float_range.rb +128 -0
  427. data/lib/rigor/type/hash_shape.rb +6 -6
  428. data/lib/rigor/type/integer_range.rb +13 -8
  429. data/lib/rigor/type/nominal.rb +8 -2
  430. data/lib/rigor/type/refined.rb +4 -3
  431. data/lib/rigor/type/struct_class.rb +3 -3
  432. data/lib/rigor/type/struct_instance.rb +4 -4
  433. data/lib/rigor/type.rb +1 -0
  434. data/lib/rigor/type_node/generic.rb +1 -1
  435. data/lib/rigor/type_node/range_literal.rb +25 -0
  436. data/lib/rigor/type_node/resolver_chain.rb +1 -1
  437. data/lib/rigor/type_node.rb +1 -0
  438. data/lib/rigor/version.rb +1 -1
  439. data/lib/rigor.rb +1 -0
  440. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +0 -4
  441. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +0 -1
  442. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +0 -2
  443. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  444. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +10 -5
  445. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +0 -4
  446. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +3 -5
  447. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +1 -4
  448. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +14 -4
  449. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +32 -30
  450. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +0 -1
  451. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +1 -3
  452. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  453. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  454. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  455. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  456. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  457. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  458. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +264 -3
  459. data/plugins/rigor-actionpack/sig/action_controller.rbs +71 -0
  460. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  461. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  462. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  463. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  464. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  465. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  466. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +0 -4
  467. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +2 -1
  468. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +0 -1
  469. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +0 -2
  470. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +1 -1
  471. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +10 -5
  472. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  473. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  474. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +383 -7
  475. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  476. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +1 -2
  477. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +1 -2
  478. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +322 -41
  479. data/plugins/rigor-activerecord/sig/active_record/framework.rbs +123 -0
  480. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  481. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +26 -14
  482. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  483. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -2
  484. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +290 -19
  485. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +1 -0
  486. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +3 -0
  487. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +9 -7
  488. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +7 -3
  489. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +1 -0
  490. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +8 -6
  491. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +11 -7
  492. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -11
  493. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +9 -4
  494. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +2 -0
  495. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +0 -5
  496. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +0 -1
  497. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +0 -1
  498. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +8 -1
  499. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  500. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +36 -4
  501. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +1 -0
  502. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  503. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  504. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  505. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +8 -6
  506. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +78 -5
  507. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  508. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +1 -0
  509. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +2 -1
  510. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -3
  511. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +1 -0
  512. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +0 -5
  513. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -2
  514. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +0 -1
  515. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +0 -1
  516. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +12 -7
  517. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +0 -12
  518. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +1 -3
  519. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +0 -1
  520. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +36 -37
  521. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +5 -5
  522. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -10
  523. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +5 -3
  524. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +3 -2
  525. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +2 -2
  526. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +2 -2
  527. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +9 -10
  528. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +2 -3
  529. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +10 -17
  530. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +1 -0
  531. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +13 -1
  532. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  533. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +324 -18
  534. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +0 -3
  535. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +0 -3
  536. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -5
  537. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +2 -3
  538. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +1 -0
  539. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +0 -3
  540. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +1 -0
  541. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +1 -0
  542. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +1 -9
  543. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +1 -0
  544. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +0 -4
  545. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +1 -1
  546. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +2 -1
  547. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +0 -1
  548. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +0 -2
  549. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +10 -5
  550. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -0
  551. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +2 -5
  552. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +1 -4
  553. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +3 -9
  554. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +4 -4
  555. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +1 -2
  556. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +3 -5
  557. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +7 -5
  558. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +51 -6
  559. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  560. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +1 -0
  561. data/sig/rigor/analysis/baseline.rbs +69 -7
  562. data/sig/rigor/analysis/fact_store.rbs +1 -1
  563. data/sig/rigor/analysis/project_scan.rbs +74 -0
  564. data/sig/rigor/analysis/reachability/scan_cache.rbs +12 -0
  565. data/sig/rigor/cache.rbs +2 -3
  566. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  567. data/sig/rigor/effects/effect_table.rbs +60 -0
  568. data/sig/rigor/effects/envelope.rbs +97 -0
  569. data/sig/rigor/effects/envelope_index.rbs +33 -0
  570. data/sig/rigor/effects/file_collection.rbs +81 -0
  571. data/sig/rigor/effects/label.rbs +26 -0
  572. data/sig/rigor/effects/label_set.rbs +46 -0
  573. data/sig/rigor/effects/method_key.rbs +24 -0
  574. data/sig/rigor/effects/origin.rbs +50 -0
  575. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  576. data/sig/rigor/effects/registry.rbs +68 -0
  577. data/sig/rigor/effects/summary.rbs +50 -0
  578. data/sig/rigor/effects/taint_cause.rbs +12 -0
  579. data/sig/rigor/environment.rbs +17 -10
  580. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  581. data/sig/rigor/inference.rbs +9 -14
  582. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  583. data/sig/rigor/plugin/base.rbs +30 -7
  584. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  585. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  586. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  587. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  588. data/sig/rigor/plugin/io_boundary.rbs +4 -0
  589. data/sig/rigor/plugin/loader.rbs +3 -3
  590. data/sig/rigor/plugin/manifest.rbs +51 -11
  591. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  592. data/sig/rigor/plugin/registry.rbs +62 -1
  593. data/sig/rigor/plugin.rbs +1 -1
  594. data/sig/rigor/rbs_extended.rbs +1 -1
  595. data/sig/rigor/reflection.rbs +9 -6
  596. data/sig/rigor/scope.rbs +148 -12
  597. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +21 -0
  598. data/sig/rigor/source.rbs +4 -4
  599. data/sig/rigor/testing.rbs +10 -4
  600. data/sig/rigor/type.rbs +33 -1
  601. data/sig/rigor.rbs +39 -20
  602. data/skills/rigor-ask/SKILL.md +16 -11
  603. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  604. data/skills/rigor-ci-setup/SKILL.md +17 -21
  605. data/skills/rigor-doctor/SKILL.md +24 -22
  606. data/skills/rigor-doctor/references/01-checks.md +97 -33
  607. data/skills/rigor-editor-setup/SKILL.md +6 -4
  608. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  609. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  610. data/skills/rigor-next-steps/SKILL.md +4 -2
  611. data/skills/rigor-plugin-author/SKILL.md +19 -23
  612. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  613. data/skills/rigor-plugin-author/references/02-walker-and-types.md +25 -27
  614. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  615. data/skills/rigor-plugin-review/SKILL.md +6 -4
  616. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  617. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  618. data/skills/rigor-project-init/SKILL.md +15 -10
  619. data/skills/rigor-project-init/references/01-detect.md +13 -9
  620. data/skills/rigor-project-init/references/02-configure.md +43 -14
  621. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  622. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  623. data/skills/rigor-project-init/references/06-agent-contract.md +67 -0
  624. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  625. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  626. data/skills/rigor-type-oracle/SKILL.md +226 -0
  627. data/skills/rigor-type-oracle/references/01-oracle-commands.md +265 -0
  628. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +52 -0
  629. data/skills/rigor-type-oracle/references/03-gap-protocol.md +125 -0
  630. data/skills/rigor-unused-adjudicate/SKILL.md +12 -7
  631. data/skills/rigor-upgrade/SKILL.md +13 -8
  632. metadata +133 -3
  633. data/lib/rigor/cli/probe_environment.rb +0 -85
  634. data/sig/rigor/inference/builtins/numeric_catalog.rbs +0 -3
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ require "rigor/plugin"
6
+ require "rigor/plugin/isolation"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ class Actionpack < Rigor::Plugin::Base
11
+ # #393 — ERB → Ruby, with the line map the template-unit seam (#392) reports through.
12
+ #
13
+ # ## Which compiler
14
+ #
15
+ # [ADR-90](../../../../../../docs/adr/90-target-library-resolution-from-project-bundle.md): Erubi is
16
+ # **never bundled** — it is not a Rigor dependency and nothing here adds it to a Gemfile — but it is
17
+ # what Rails itself compiles with, so it is used when it resolves in the ANALYSED project's bundle,
18
+ # through the same `Isolation.require_with_target_bundle` path `Inflector` takes. Stdlib `ERB` is the
19
+ # fallback, and it is always there. Whichever ran is reported as the unit's `transform_id`
20
+ # (`erubi-1.13.1` / `erb-6.0.1.1`), which rides the unit digest — so a project that installs Erubi
21
+ # between two runs re-analyses rather than replaying the stdlib answer.
22
+ #
23
+ # ## The line map is measured, never assumed
24
+ #
25
+ # Both compilers are line-preserving: a tag's text is rewritten in place and the newlines between
26
+ # tags are emitted verbatim, which is why a Rails backtrace can name `show.html.erb:12`. What differs
27
+ # is the **prologue** — stdlib ERB emits a `#coding:` magic comment above the body, Erubi emits its
28
+ # buffer initialiser — and neither documents its height as API.
29
+ #
30
+ # So the height is measured rather than hardcoded: {.line_offset} compiles a probe template of marked
31
+ # lines once per compiler and reads back which compiled line the first marker landed on, checking at
32
+ # the same time that the following markers step by exactly one. A compiler that fails that check is
33
+ # refused (the caller declines the file) rather than trusted, because the failure mode of a wrong
34
+ # offset is every finding in every template reported at the wrong line — silent, and worse than no
35
+ # unit at all.
36
+ module ErbCompiler
37
+ # A compiler whose probe did not step line-for-line. Caught by {Actionpack#template_units_for_file},
38
+ # which declines the file.
39
+ class Unmappable < StandardError; end
40
+
41
+ # Three marked lines, each a STATEMENT tag. A tag's Ruby is emitted verbatim, so the marker is
42
+ # findable in the compiled source and each one is forced onto its own compiled line.
43
+ #
44
+ # Plain text would not do: consecutive text lines are one chunk and are emitted as ONE append
45
+ # carrying the newlines escaped inside the literal — the compiler then pads the missing lines out
46
+ # BELOW it, so a text probe reads as a collapse when the line numbering is in fact intact. That
47
+ # padding is exactly the property being measured, and a tag probe measures it without tripping
48
+ # over how the padding is spelled.
49
+ PROBE = (1..3).map { |n| "<% RIGOR_ERB_PROBE_#{n} %>\n" }.join.freeze
50
+ private_constant :PROBE
51
+
52
+ # An output tag whose Ruby OPENS A BLOCK: `<%= form_with(model: @user) do |f| %>`, the single most
53
+ # common shape in a real Rails view. Neither compiler handles it — both emit
54
+ # `_buf << (form_with(…) do |f|).to_s`, which is a syntax error, so every template using a form
55
+ # helper produced two parse diagnostics and no unit at all (431 of them on redmine, measured).
56
+ #
57
+ # Rails does not hit this because its own ERB handler carries exactly this rule
58
+ # (`ActionView::Template::Handlers::ERB::BLOCK_EXPR`) and emits `@output_buffer.append= expr`
59
+ # unwrapped for a matching tag. The same rule is applied here one step earlier, as a rewrite of
60
+ # the TEMPLATE rather than of a compiler's output: `<%=` becomes `<%` for such a tag, which is a
61
+ # same-width, same-line edit both compilers then handle, and the discarded value is the buffer
62
+ # append the design note says is not an origin anyway.
63
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\z/
64
+ private_constant :BLOCK_EXPR
65
+
66
+ # The Erubi/Rails trim markers, which stdlib ERB only understands under a `trim_mode:` that also
67
+ # changes how it emits newlines. They are blanked instead — `<%-` → `<% `, `-%>` → ` %>` — which
68
+ # is the same-width, same-line rewrite {BLOCK_EXPR} uses and keeps the line map out of the
69
+ # compiler's trim rules entirely. Left in place under `trim_mode: nil` they parse as Ruby's unary
70
+ # minus and cost the template its unit (87 of redmine's 506, measured after the block-expression
71
+ # fix and before this one).
72
+ TRIM_OPEN = /<%-/
73
+ private_constant :TRIM_OPEN
74
+
75
+ TRIM_CLOSE = /-%>/
76
+ private_constant :TRIM_CLOSE
77
+
78
+ # #1047 — a LAYOUT's `yield`. `<%= yield %>`, `<%= yield :sidebar %>` and `<% if content_for?(:x) %>`
79
+ # are what a layout is made of, and the first two are legal ERB and illegal Ruby anywhere outside a
80
+ # method body — so every layout's compiled source failed to parse and the file was declined (four of
81
+ # redmine's 506 templates, two of mastodon's 46; `docs/notes/20260917-erb-template-units.md` § 3).
82
+ #
83
+ # The seam deliberately does NOT wrap a unit's body in a synthesised method
84
+ # (`docs/internal-spec/macro-substrate.md` § Positions: wrapping would shift every line and compose
85
+ # a second line map onto the plugin's), so the fix is a rewrite of the TEMPLATE, in the same pre-pass
86
+ # {BLOCK_EXPR} and {TRIM_OPEN} live in: the `yield` KEYWORD inside an ERB tag becomes a call to
87
+ # {YIELD_METHOD}, an ordinary implicit-self method on the synthesised view context, declared in
88
+ # `sig/action_view.rbs` as returning `String`.
89
+ #
90
+ # Unlike the other two rewrites this one is NOT width-preserving, and it does not have to be: a unit
91
+ # whose `line_map` is non-empty reports at column 1 by construction
92
+ # ({Analysis::TemplateUnits#remap} — "the template's lines and the compiled Ruby's columns do not
93
+ # correspond"), so only the LINE count is load-bearing, and the replacement contains no newline.
94
+ #
95
+ # What the rewrite deliberately does not do is give the call a TYPE beyond `String`. Rails' `yield`
96
+ # returns whatever the inner template's buffer holds, and `yield :sidebar` returns the `content_for`
97
+ # buffer — an empty `SafeBuffer` when nothing was provided, never nil. A lenient `String` is the
98
+ # widest honest reading, and inventing anything narrower would be the `Parameters#[]` trap one
99
+ # layer up.
100
+ YIELD_METHOD = "__rigor_yield"
101
+
102
+ # The `yield` keyword, and only the keyword: not `foo.yield`, not `:yield`, not `@yield`, not
103
+ # `yielding`. A `yield` inside a STRING literal in a tag (`<%= t("yield") %>`) is rewritten too —
104
+ # the pre-pass is a Regexp over the tag, not a Ruby lexer — which changes a literal's bytes in a
105
+ # body that is never executed and that no rule reads the contents of. Recorded rather than guarded,
106
+ # because a lexer here would be a second Ruby parser to keep honest.
107
+ YIELD_KEYWORD = /(?<![A-Za-z0-9_.:@$])yield(?![A-Za-z0-9_?!])/
108
+ private_constant :YIELD_KEYWORD
109
+
110
+ # Any ERB tag, non-greedy and multi-line — the region {YIELD_KEYWORD} is applied inside, so a
111
+ # `yield` in the template's HTML text is left exactly as it was written. A `<%%` opener is ERB's
112
+ # escape for a LITERAL `<%` in the output, so what follows it is text too and is skipped.
113
+ ANY_TAG = /<%.*?%>/m
114
+ private_constant :ANY_TAG
115
+
116
+ # `<%= … %>` and `<%== … %>`, non-greedy and multi-line. No capture group: the block below reads
117
+ # the tag it was handed rather than `Regexp.last_match`, whose group is `String?` however sure
118
+ # the pattern makes it — and a rewrite that depends on global match state is the harder one to
119
+ # read anyway.
120
+ OUTPUT_TAG = /<%={1,2}(?!=).*?%>/m
121
+ private_constant :OUTPUT_TAG
122
+
123
+ # The `<%=` / `<%==` opener of a matched tag, which is all that gets rewritten.
124
+ OUTPUT_OPENER = /\A<%=+/
125
+ private_constant :OUTPUT_OPENER
126
+
127
+ module_function
128
+
129
+ # `[ruby_source, line_map, transform_id]` for one template's bytes. `text` must already be valid
130
+ # UTF-8 — {.scrub} is the caller's, because more than one thing reads the template and every one
131
+ # of them has to read the SAME bytes.
132
+ #
133
+ # `line_map` is `{ compiled line => template line }` for every template line, explicitly — NOT the
134
+ # empty "identity" map, even when the offset is zero. An empty map tells the engine the transform
135
+ # was byte-preserving and lets the compiled Ruby's COLUMNS through, and an ERB column names
136
+ # nothing in the template (`macro-substrate.md` § Positions).
137
+ def compile(text)
138
+ offset = line_offset
139
+ raise Unmappable, "#{transform_id} does not emit template lines one per compiled line" if offset.nil?
140
+
141
+ compiled = compile_source(normalize(text))
142
+ map = (1..template_line_count(text)).to_h { |line| [line + offset, line] }
143
+ [compiled, map, transform_id]
144
+ end
145
+
146
+ # A template's bytes as valid UTF-8. A `.erb` file is whatever the project committed, and an
147
+ # invalid byte is not a reason to refuse it: `ERB`, `Regexp#match?` and Prism all raise on one,
148
+ # so every reader has to be handed the same scrubbed String or they disagree about the file.
149
+ def scrub(source)
150
+ text = source.to_s.dup.force_encoding(Encoding::UTF_8)
151
+ return text if text.valid_encoding?
152
+
153
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
154
+ end
155
+
156
+ # The compiled Ruby for one template, through whichever compiler resolved.
157
+ def compile_source(text)
158
+ if erubi?
159
+ ::Erubi::Engine.new(text).src
160
+ else
161
+ ::ERB.new(text, trim_mode: nil).src
162
+ end
163
+ end
164
+
165
+ # Rewrites every block-opening output tag into a statement tag; see {BLOCK_EXPR}. Line- and
166
+ # width-preserving by construction: only the tag's `=` characters are replaced, by spaces.
167
+ def normalize(text)
168
+ normalize_block_expressions(normalize_yields(text.gsub(TRIM_OPEN, "<% ").gsub(TRIM_CLOSE, " %>")))
169
+ end
170
+
171
+ # Rewrites the `yield` keyword inside every ERB tag into a call on the view context; see
172
+ # {YIELD_METHOD}. Runs BEFORE {#normalize_block_expressions} so that pass reads the tag bodies it
173
+ # will actually hand to the compiler.
174
+ def normalize_yields(text)
175
+ return text unless text.match?(YIELD_KEYWORD)
176
+
177
+ text.gsub(ANY_TAG) { |tag| tag.start_with?("<%%") ? tag : tag.gsub(YIELD_KEYWORD, YIELD_METHOD) }
178
+ end
179
+
180
+ def normalize_block_expressions(text)
181
+ text.gsub(OUTPUT_TAG) do |tag|
182
+ opener = tag[OUTPUT_OPENER]
183
+ next tag if opener.nil?
184
+
185
+ body = tag[opener.length...-2].to_s
186
+ next tag unless body.match?(BLOCK_EXPR)
187
+
188
+ "<%#{' ' * (opener.length - 2)}#{body}%>"
189
+ end
190
+ end
191
+
192
+ # A template's last line may lack its newline; `String#lines` already counts it, and an EMPTY
193
+ # template has no lines at all and needs no rows.
194
+ def template_line_count(text)
195
+ text.lines.length
196
+ end
197
+
198
+ # How many compiled lines sit above the template's first line. Measured once per process per
199
+ # compiler — the answer cannot change under a loaded compiler, and the probe compile is cheap
200
+ # enough that memoising it is about tidiness rather than cost.
201
+ def line_offset
202
+ return @line_offset if defined?(@line_offset) && @line_offset
203
+
204
+ @line_offset = probe_offset
205
+ end
206
+
207
+ # `nil` when the loaded compiler does not step line-for-line.
208
+ def probe_offset
209
+ lines = compile_source(PROBE).lines
210
+ first = lines.index { |line| line.include?("RIGOR_ERB_PROBE_1") }
211
+ return nil if first.nil?
212
+
213
+ (2..3).each do |marker|
214
+ return nil unless lines[first + marker - 1]&.include?("RIGOR_ERB_PROBE_#{marker}")
215
+ end
216
+ first
217
+ end
218
+
219
+ # `erubi-1.13.1` / `erb-6.0.1.1` — the compiler's identity, which rides the unit digest.
220
+ def transform_id
221
+ @transform_id ||= if erubi?
222
+ "erubi-#{::Erubi::VERSION}"
223
+ else
224
+ "erb-#{::ERB.const_defined?(:VERSION) ? ::ERB::VERSION : 'stdlib'}"
225
+ end
226
+ end
227
+
228
+ # True once Erubi has been loaded out of the analysed project's bundle AND its line numbering has
229
+ # been measured. Resolved ONCE per process: a failed resolution is as memoised as a successful
230
+ # one, so a project without Erubi does not pay a `$LOAD_PATH` walk per template.
231
+ #
232
+ # `Isolation.require_with_target_bundle` is [ADR-90](../../../../../../docs/adr/90-target-library-resolution-from-project-bundle.md)'s
233
+ # path — Rigor's own gem env first, then the analysed project's bundle, `$LOAD_PATH` appended and
234
+ # only on a failed require. Erubi is not a Rigor dependency and is never added to one. Any load
235
+ # failure is equally "no Erubi here"; stdlib `ERB` answers the same question, so nothing is
236
+ # reported and nothing degrades but the compiler's name.
237
+ #
238
+ # A resolved Erubi that does NOT step line-for-line is **demoted** rather than trusted. Erubi pads
239
+ # its output to the template's line count deliberately (it is why a Rails backtrace can name
240
+ # `show.html.erb:12`), so the demotion is not expected to fire — but a compiler whose numbering
241
+ # the probe cannot confirm would silently report every finding in every template at the wrong
242
+ # line, and stdlib ERB is right here and always present.
243
+ def erubi?
244
+ return @erubi if defined?(@erubi)
245
+
246
+ @erubi = load_erubi
247
+ return @erubi unless @erubi
248
+ return true unless probe_offset.nil?
249
+
250
+ @erubi = false
251
+ end
252
+
253
+ def load_erubi
254
+ Isolation.require_with_target_bundle("erubi", Isolation.target_bundle_root)
255
+ defined?(::Erubi::Engine) ? true : false
256
+ rescue ::StandardError, ::LoadError
257
+ false
258
+ end
259
+
260
+ # Test seam: forget the probed offset, the resolved compiler and its id, so one process can measure
261
+ # both compilers. Never called by the plugin itself.
262
+ def reset!
263
+ remove_instance_variable(:@line_offset) if defined?(@line_offset)
264
+ remove_instance_variable(:@transform_id) if defined?(@transform_id)
265
+ remove_instance_variable(:@erubi) if defined?(@erubi)
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "controller_scan"
6
+ require_relative "erb_compiler"
7
+ require_relative "view_assigns"
8
+ require_relative "view_units"
9
+
10
+ module Rigor
11
+ module Plugin
12
+ class Actionpack < Rigor::Plugin::Base
13
+ # #1047 — the locals a render site passes, indexed by the template they are passed TO.
14
+ #
15
+ # #393 seeded a template's `locals:` from the Rails 7.1 strict-locals comment only, so a partial
16
+ # without one read its own parameters as method calls on the view context. That absence has a
17
+ # measured cost rather than a theoretical one: redmine's `app/views/common/_other.html.erb` opens
18
+ # with the standard optional-local preamble
19
+ #
20
+ # <% path = nil unless defined? path %>
21
+ #
22
+ # and the compiled Ruby really does assign nil there, because nothing told the unit that `path` is
23
+ # bound — so the flow rules folded three live branches and `flow.` had to join `call.` in the
24
+ # plugin's default suppressed set for that reason alone
25
+ # (`docs/notes/20260917-erb-template-units.md` § 4). Tracing the render site is what lets the flow
26
+ # rules back into a view.
27
+ #
28
+ # ## Why over-binding is the safe direction and under-binding is not
29
+ #
30
+ # A name this index seeds that the render site did not really pass costs nothing: the local binds
31
+ # `Dynamic`, `defined?` reads it as bound, and no rule folds anything. A name it MISSES is the
32
+ # false positive above. So the union across render sites is deliberately generous — a name bound at
33
+ # only some of a partial's render sites is seeded anyway, typed `Dynamic`, never left absent — and
34
+ # every shape below that cannot be settled from literals contributes a NAME with no type rather
35
+ # than nothing.
36
+ #
37
+ # The one thing that is not generous is the TYPE. A type is a claim the engine acts on, so a name
38
+ # keeps a concrete type only when every site that passes it passes the same one; anything else is
39
+ # {ViewUnits::UNKNOWN_LOCAL}, which the engine binds `Dynamic[top]` (ADR-5).
40
+ #
41
+ # ## What a render site is read for
42
+ #
43
+ # Both sides of the render, because both bind the same thing:
44
+ #
45
+ # - a **controller** site, through {Analyzer.render_target_for} — `render partial: "card",
46
+ # locals: { user: @user }`;
47
+ # - a **template** site, in the compiled Ruby of every view — the same keyword form, plus the
48
+ # positional-hash form `render "card", user: @user`, which is locals in a view and OPTIONS in a
49
+ # controller (`ActionView::Helpers::RenderingHelper#render(options, locals)` against
50
+ # `ActionController::Base#render(options)`). Reading the controller form as locals would seed a
51
+ # `status` local for every `render :show, status: :ok` in the corpus.
52
+ #
53
+ # `collection:` and `object:` bind a local named after the partial, or after `as:` when given, and a
54
+ # collection additionally binds the `_counter` and `_iteration` companions Rails provides.
55
+ #
56
+ # ## The compiled-source cache
57
+ #
58
+ # Building the index compiles every template in the project, and `#template_units_for_file` is about
59
+ # to compile each of them again. So the builder keeps what it compiled, keyed by path and guarded by
60
+ # the template's own scrubbed bytes, and the hook reuses it — a full run does exactly the compile
61
+ # work it did before this feature, one pass instead of two. An editor buffer's bytes differ from the
62
+ # file's, so its template falls through to its own compile; the index itself is built once per
63
+ # plugin instance and revalidated once per collection pass against {Builder.fingerprint}, so an
64
+ # unsaved buffer's render sites are NOT read — its partials keep the locals the saved file passes
65
+ # until the save.
66
+ class RenderLocals
67
+ # What a name is seeded with when no site settled a type for it, or when two disagreed.
68
+ UNKNOWN = ViewUnits::UNKNOWN_LOCAL
69
+
70
+ # Finders and constructors whose Rails implementation raises rather than returning nil — the same
71
+ # closed set {ViewAssigns::NON_NIL_PRODUCERS} names, and for the same reason: a nil-free nominal
72
+ # standing in for a runtime nil licenses folds that report correct code.
73
+ NON_NIL_PRODUCERS = ViewAssigns::NON_NIL_PRODUCERS
74
+
75
+ # `{ "common/_other" => { "path" => UNKNOWN, … } }`, keyed by logical name with the format
76
+ # stripped — a partial's locals do not depend on which format rendered it.
77
+ attr_reader :by_template
78
+
79
+ # The {Builder.fingerprint} of the sources this index was built from, taken BEFORE they were read —
80
+ # so an edit that lands during the build reads as a change on the next pass rather than being
81
+ # recorded as already seen. nil for {.empty}, which therefore never matches a real fingerprint.
82
+ attr_reader :fingerprint
83
+
84
+ # Every method name a project helper `def`s — see {ViewUnits.self_declared_locals}.
85
+ attr_reader :helper_methods
86
+
87
+ def initialize(by_template, compiled = {}, fingerprint: nil, helper_methods: {})
88
+ @by_template = by_template.freeze
89
+ @compiled = compiled.freeze
90
+ @fingerprint = fingerprint
91
+ @helper_methods = helper_methods.freeze
92
+ freeze
93
+ end
94
+
95
+ def self.empty
96
+ new({})
97
+ end
98
+
99
+ # `common/_other.html` → the names every site that renders `common/_other` passed.
100
+ def seeds_for(logical_name)
101
+ @by_template.fetch(logical_name.to_s.sub(%r{\.[^./]+\z}, ""), {})
102
+ end
103
+
104
+ # The `[compiled, line_map, transform_id, parse_ok]` this index already computed for `path`, or nil
105
+ # when it read different bytes (an editor buffer) or never saw the file. The bytes are compared
106
+ # rather than trusted: a unit compiled from the file on disk must never stand in for one the editor
107
+ # is holding.
108
+ def compiled_for(path, text)
109
+ entry = @compiled[path.to_s]
110
+ return nil if entry.nil? || entry.first != text
111
+
112
+ entry.drop(1)
113
+ end
114
+
115
+ # Builds the index from the controller sources and the templates the plugin claims.
116
+ class Builder
117
+ def initialize(io_boundary:, controller_search_paths:, view_search_paths:, view_assigns:,
118
+ helper_search_paths: [])
119
+ @io_boundary = io_boundary
120
+ @controller_search_paths = controller_search_paths
121
+ @view_search_paths = view_search_paths
122
+ @helper_search_paths = helper_search_paths
123
+ @view_assigns = view_assigns
124
+ # `{ target => [ { name => type_name_or_nil }, … ] }` — one hash per render site, merged only
125
+ # once every site has been seen, because "bound at some sites" is a property of the whole set.
126
+ @sites = Hash.new { |hash, key| hash[key] = [] }
127
+ end
128
+
129
+ def build
130
+ fingerprint = self.class.fingerprint(
131
+ io_boundary: @io_boundary, controller_search_paths: @controller_search_paths,
132
+ view_search_paths: @view_search_paths, helper_search_paths: @helper_search_paths
133
+ )
134
+ harvest_controllers
135
+ compiled = harvest_templates
136
+ RenderLocals.new(@sites.transform_values { |sites| merge(sites) }, compiled,
137
+ fingerprint: fingerprint, helper_methods: harvest_helper_methods)
138
+ end
139
+
140
+ # What the index was built from, as one String: every controller, helper and template path with
141
+ # its size and mtime. A glob catches a file appearing or vanishing and a stat catches an edit;
142
+ # neither reads a byte, so revalidating costs one `stat` per file, once per collection pass.
143
+ #
144
+ # There is deliberately no content-digest fallback, unlike the collector's ADR-87 pack: a
145
+ # byte-identical `touch` moves the fingerprint and rebuilds this index while the collector still
146
+ # carries the units. That costs one rebuild and never a wrong answer — the rebuilt index is the
147
+ # index the carried units were seeded from — so a digest per file per pass is not worth buying.
148
+ def self.fingerprint(io_boundary:, controller_search_paths:, view_search_paths:, helper_search_paths: [])
149
+ files = source_files(io_boundary, controller_search_paths, "*.rb") +
150
+ source_files(io_boundary, helper_search_paths, "*.rb") +
151
+ source_files(io_boundary, view_search_paths, "*.erb")
152
+ files.map do |path|
153
+ stat = File.stat(path)
154
+ "#{path}\0#{stat.size}\0#{stat.mtime.to_r}"
155
+ rescue SystemCallError
156
+ "#{path}\0gone"
157
+ end.join("\n")
158
+ end
159
+
160
+ # Project-relative paths, the spelling `#template_units_for_file` is handed and the spelling the
161
+ # compiled cache is keyed by.
162
+ def self.source_files(io_boundary, roots, pattern)
163
+ root_prefix = "#{Dir.pwd}#{File::SEPARATOR}"
164
+ roots.flat_map do |root|
165
+ absolute = File.expand_path(root)
166
+ next [] unless io_boundary.directory?(absolute)
167
+
168
+ Dir.glob(File.join(absolute, "**", pattern)).map { |path| path.delete_prefix(root_prefix) }
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ # Two sites that agree on a name and its type keep the type; anything else — a disagreement, a
175
+ # site that settled no type, or a site that did not pass the name at all — is `Dynamic`.
176
+ def merge(sites)
177
+ names = sites.flat_map(&:keys).uniq
178
+ names.to_h do |name|
179
+ types = sites.map { |site| site.key?(name) ? site[name] : :absent }.uniq
180
+ [name, types.length == 1 && types.first.is_a?(String) ? types.first : UNKNOWN]
181
+ end
182
+ end
183
+
184
+ HELPER_DEF = /^[ \t]*def[ \t]+(?:self\.)?([a-z_][A-Za-z0-9_]*[?!]?)/
185
+ private_constant :HELPER_DEF
186
+
187
+ # `{ "current_user" => true }` for every `def` in a project helper. A line scan rather than a
188
+ # parse: the answer only has to be a superset of the names a helper can answer, and a name read
189
+ # out of a comment or a heredoc merely leaves one template's `defined?` test unseeded — which is
190
+ # what #393 shipped for every name.
191
+ def harvest_helper_methods
192
+ source_files(@helper_search_paths, "*.rb").each_with_object({}) do |path, names|
193
+ read(path)&.scan(HELPER_DEF)&.each { |(name)| names[name] = true }
194
+ end
195
+ end
196
+
197
+ def harvest_controllers
198
+ source_files(@controller_search_paths, "*.rb").each do |path|
199
+ tree = parse_file(path)
200
+ next if tree.nil?
201
+
202
+ ControllerScan.each_controller(tree, []) do |node, namespace|
203
+ harvest_controller(node, namespace)
204
+ end
205
+ end
206
+ end
207
+
208
+ def harvest_controller(node, namespace)
209
+ segments = namespace + ControllerScan.constant_segments(node.constant_path)
210
+ prefix = ControllerScan.controller_path(segments)
211
+ return if prefix.nil?
212
+
213
+ ControllerScan.method_bodies(node).each do |action, body|
214
+ ivars = @view_assigns.seeds_for("#{prefix}/#{action}")
215
+ ControllerScan.each_render(body) do |call|
216
+ record(controller_target(call, prefix), call, ivars, view_side: false)
217
+ end
218
+ end
219
+ end
220
+
221
+ # {Analyzer.render_target_for} already answers this for a controller: `render :edit`,
222
+ # `render "admin/shared/form"`, `render partial: "card"`. Its `[:template, name]` /
223
+ # `[:partial, name]` pair reduces to the name, because the locals of a template and of a partial
224
+ # are seeded the same way.
225
+ def controller_target(call, prefix)
226
+ Analyzer.render_target_for(call, prefix)&.last
227
+ end
228
+
229
+ # Every claimed template, compiled once. Returns the `{ path => [text, compiled, map, id, ok] }`
230
+ # cache `#template_units_for_file` reuses.
231
+ def harvest_templates
232
+ source_files(@view_search_paths, "*.erb").each_with_object({}) do |path, compiled|
233
+ text = read(path)
234
+ next if text.nil?
235
+
236
+ text = ErbCompiler.scrub(text)
237
+ source, line_map, transform = ErbCompiler.compile(text)
238
+ result = Prism.parse(source)
239
+ compiled[path] = [text, source, line_map, transform, result.errors.empty?]
240
+ next unless result.errors.empty?
241
+
242
+ harvest_template(path, result.value)
243
+ end
244
+ rescue ErbCompiler::Unmappable
245
+ {}
246
+ end
247
+
248
+ def harvest_template(path, tree)
249
+ name = ViewUnits.logical_name(path, @view_search_paths).sub(%r{\.[^./]+\z}, "")
250
+ directory = name.include?("/") ? File.dirname(name) : ""
251
+ ivars = @view_assigns.seeds_for(name)
252
+ ControllerScan.each_render(tree) do |call|
253
+ record(template_target(call, directory), call, ivars, view_side: true)
254
+ end
255
+ end
256
+
257
+ # The view-side reading of a render target, which differs from the controller's in exactly one
258
+ # place: a bare positional name is a PARTIAL here. `template:` is the escape hatch an author uses
259
+ # when they mean otherwise, and `layout:` in a view names a partial too — the same reading
260
+ # {Effects::CalleeRule.rails_render_partial} takes, for the same reason.
261
+ def template_target(call, directory)
262
+ explicit = string_option(call, "template")
263
+ return qualify(explicit, directory) if explicit
264
+
265
+ partial = string_option(call, "partial") || string_option(call, "layout")
266
+ return partialize(qualify(partial, directory)) if partial
267
+
268
+ positional = (call.arguments&.arguments || []).grep_v(Prism::KeywordHashNode).first
269
+ return nil unless positional.is_a?(Prism::StringNode) || positional.is_a?(Prism::SymbolNode)
270
+
271
+ partialize(qualify(positional.unescaped, directory))
272
+ end
273
+
274
+ def record(target, call, ivars, view_side:)
275
+ return if target.nil? || target.empty?
276
+
277
+ locals = locals_at(call, target, ivars, view_side: view_side)
278
+ @sites[target] << locals
279
+ end
280
+
281
+ # The names one render site binds, and the type of each where the site settled one.
282
+ def locals_at(call, target, ivars, view_side:)
283
+ arguments = call.arguments&.arguments || []
284
+ options = arguments.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
285
+ locals = {}
286
+ # `render "card", user: @user` — the view's second positional argument IS the locals hash, and
287
+ # the controller's is options. Read alongside an explicit `locals:` rather than instead of it,
288
+ # because over-binding is the safe direction.
289
+ collect_pairs(options, locals, ivars) if view_side && !arguments.first.is_a?(Prism::KeywordHashNode)
290
+ collect_pairs(option(call, "locals"), locals, ivars)
291
+ collect_object_locals(call, target, locals, ivars)
292
+ locals
293
+ end
294
+
295
+ # `locals: { user: @user }` and its hashrocket spelling. A key that is not a literal symbol or
296
+ # string — a computed key, a `**splat` — names nothing this can seed, and is skipped rather than
297
+ # guessed at.
298
+ def collect_pairs(hash, locals, ivars)
299
+ return unless hash.is_a?(Prism::HashNode) || hash.is_a?(Prism::KeywordHashNode)
300
+
301
+ hash.elements.each do |element|
302
+ next unless element.is_a?(Prism::AssocNode)
303
+
304
+ name = literal_name(element.key)
305
+ next if name.nil? || !name.match?(/\A[a-z_][A-Za-z0-9_]*\z/)
306
+
307
+ locals[name] = value_type(element.value, ivars)
308
+ end
309
+ end
310
+
311
+ # `object:` binds one local named after the partial; `collection:` binds the same name plus the
312
+ # `_counter` and `_iteration` companions Rails provides, and never a type — the local is the
313
+ # ELEMENT, and nothing at the site names the element's class.
314
+ def collect_object_locals(call, target, locals, ivars)
315
+ base = literal_name(option(call, "as")) || File.basename(target).delete_prefix("_")
316
+ return unless base.match?(/\A[a-z_][A-Za-z0-9_]*\z/)
317
+
318
+ object = option(call, "object")
319
+ collection = option(call, "collection")
320
+ locals[base] = value_type(object, ivars) if object
321
+ return if collection.nil?
322
+
323
+ locals[base] = nil
324
+ locals["#{base}_counter"] = nil
325
+ locals["#{base}_iteration"] = nil
326
+ end
327
+
328
+ # The two shapes a render site settles a type from, and nothing else: a non-nil-able finder or
329
+ # constructor on a constant (`User.find(params[:id])`), and an ivar the rendering unit's own
330
+ # seeds already typed. Anything else is a name with no type.
331
+ def value_type(node, ivars)
332
+ case node
333
+ when Prism::InstanceVariableReadNode then ivars[node.name.to_s]
334
+ when Prism::CallNode then produced_type(node)
335
+ end
336
+ end
337
+
338
+ def produced_type(call)
339
+ return nil unless NON_NIL_PRODUCERS.include?(call.name)
340
+
341
+ receiver = call.receiver
342
+ return nil unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
343
+
344
+ name = ControllerScan.constant_segments(receiver).join("::")
345
+ name.empty? ? nil : name
346
+ end
347
+
348
+ def option(call, key)
349
+ hash = call.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
350
+ pair = hash&.elements&.find do |element|
351
+ element.is_a?(Prism::AssocNode) && literal_name(element.key) == key
352
+ end
353
+ pair&.value
354
+ end
355
+
356
+ def string_option(call, key)
357
+ value = option(call, key)
358
+ value.is_a?(Prism::StringNode) || value.is_a?(Prism::SymbolNode) ? value.unescaped : nil
359
+ end
360
+
361
+ def literal_name(node)
362
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
363
+ end
364
+
365
+ def qualify(name, directory)
366
+ return nil if name.nil? || name.empty?
367
+ return name if name.include?("/") || directory.empty?
368
+
369
+ "#{directory}/#{name}"
370
+ end
371
+
372
+ def partialize(name)
373
+ return nil if name.nil?
374
+
375
+ directory, separator, base = name.rpartition("/")
376
+ return base.start_with?("_") ? name : "_#{name}" if separator.empty?
377
+
378
+ base.start_with?("_") ? name : "#{directory}/_#{base}"
379
+ end
380
+
381
+ def source_files(roots, pattern)
382
+ self.class.source_files(@io_boundary, roots, pattern)
383
+ end
384
+
385
+ def read(path)
386
+ @io_boundary.read_file(File.expand_path(path))
387
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
388
+ nil
389
+ end
390
+
391
+ def parse_file(path)
392
+ contents = read(path)
393
+ return nil if contents.nil?
394
+
395
+ result = Prism.parse(contents)
396
+ result.errors.empty? ? result.value : nil
397
+ end
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end