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
@@ -123,12 +123,12 @@ module Rigor
123
123
  @protocol_contracts || manifest.protocol_contracts
124
124
  end
125
125
 
126
- # File-level only: the load-error emission. Per-call broadcast validation runs over the
126
+ # File-level only: the load-error disclosure. Per-call broadcast validation runs over the
127
127
  # engine-owned walk via the node_rule below (ADR-37). The channel index is lazily loaded +
128
128
  # memoised by `producer_value`, shared by both surfaces.
129
129
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
130
130
  index = producer_value(:channel_index)
131
- return [load_error_diagnostic(path)] if index.nil? && producer_error(:channel_index)
131
+ disclose_load_error if index.nil? && producer_error(:channel_index)
132
132
 
133
133
  []
134
134
  end
@@ -151,10 +151,14 @@ module Rigor
151
151
  "#{base}/**/*.rb"
152
152
  end
153
153
 
154
- def load_error_diagnostic(path)
154
+ # Issue #1056 — "the channel index did not load" is a fact about the run's INPUTS, not about the file being
155
+ # analysed, so it is registered rather than returned: see {Plugin::Base#disclose_once} for the
156
+ # channel, the `(plugin id, key)` de-duplication and why the engine positions it at `.rigor.yml:1:1`.
157
+ # Returned from the per-file hook it carried no once-guard at all and repeated on every file.
158
+ def disclose_load_error
155
159
  error = producer_error(:channel_index)
156
- Rigor::Analysis::Diagnostic.new(
157
- path: path, line: 1, column: 1,
160
+ disclose_once(
161
+ :channel_index_load_failed,
158
162
  message: "rigor-actioncable: failed to discover channels: #{error.class}: #{error.message}",
159
163
  severity: :warning,
160
164
  rule: "load-error"
@@ -93,7 +93,10 @@ module Rigor
93
93
  # loaded + memoised, shared.
94
94
  def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
95
95
  index = producer_value(:mailer_index)
96
- return [load_error_diagnostic(path)] if index.nil? && producer_error(:mailer_index)
96
+ if index.nil? && producer_error(:mailer_index)
97
+ disclose_load_error
98
+ return []
99
+ end
97
100
  return [] if index.nil? || index.empty?
98
101
 
99
102
  missing_view_diagnostics(path, index)
@@ -136,10 +139,16 @@ module Rigor
136
139
  File.expand_path(path)
137
140
  end
138
141
 
139
- def load_error_diagnostic(path)
142
+ # Issue #1056 — "the mailer index did not load" is a fact about the run's INPUTS, not about the file being
143
+ # analysed, so it is registered rather than returned: see {Plugin::Base#disclose_once} for the
144
+ # channel, the `(plugin id, key)` de-duplication and why the engine positions it at `.rigor.yml:1:1`.
145
+ # Returned from the per-file hook it carried no once-guard at all and repeated on every file.
146
+ # The missing-view rows this hook still returns are NOT disclosures: they name the mailer's own
147
+ # source file.
148
+ def disclose_load_error
140
149
  error = producer_error(:mailer_index)
141
- Rigor::Analysis::Diagnostic.new(
142
- path: path, line: 1, column: 1,
150
+ disclose_once(
151
+ :mailer_index_load_failed,
143
152
  message: "rigor-actionmailer: failed to discover mailers: #{error.class}: #{error.message}",
144
153
  severity: :warning,
145
154
  rule: "load-error"
@@ -171,7 +171,6 @@ module Rigor
171
171
  # since the implicit path would false-positive on `redirect_to` / `head` / early returns.
172
172
  #
173
173
  # @param ancestors — the lexical ancestor chain
174
- # @param path — file being analysed
175
174
  # @param io_boundary — the plugin's {Rigor::Plugin::IoBoundary}; #629 — every filesystem
176
175
  # question this phase asks goes through it, so "no such template" is a recorded dependency and
177
176
  # the run-result cache invalidates once the template appears
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/source/node_children"
4
+
5
+ require "prism"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Actionpack < Rigor::Plugin::Base
10
+ # The syntactic controller walk two parent-side indexes share: {ViewAssigns}, which reads what a
11
+ # controller action ASSIGNED, and {RenderLocals} (#1047), which reads what a render site PASSED.
12
+ #
13
+ # Both run on the parent before any analysis — `#template_units_for_file` is called before the
14
+ # engine has indexed anything — so neither may ask the typer a question, and both need the same four
15
+ # answers off a Prism tree: which class declarations in this file are controllers, what their
16
+ # qualified names are, which view directory each one owns, and which `def`s sit directly in the body.
17
+ # Keeping one copy is what stops the two indexes from disagreeing about, say, whether
18
+ # `module Admin; class UsersController` is `admin/users` — a disagreement that would be silent, since
19
+ # each side would simply find nothing.
20
+ #
21
+ # It deliberately stops at the shapes {ControllerDiscoverer} already recognises. Anything a
22
+ # controller does at runtime — `define_method`, a concern that adds actions on include — is not read
23
+ # here and is not read there either.
24
+ module ControllerScan
25
+ module_function
26
+
27
+ # Yields `[class_node, enclosing_namespace_segments]` for every `ClassNode` in the tree, with the
28
+ # enclosing `module` chain accumulated. Mirrors {ControllerDiscoverer#walk_declarations}'s
29
+ # qualification reduced to the two shapes that can carry an action: `class Admin::UsersController`
30
+ # and `module Admin; class UsersController`.
31
+ def each_controller(node, namespace, &)
32
+ return unless node.is_a?(Prism::Node)
33
+
34
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
35
+ yield node, namespace if node.is_a?(Prism::ClassNode)
36
+ inner = namespace + constant_segments(node.constant_path)
37
+ each_controller(node.body, inner, &) if node.body
38
+ return
39
+ end
40
+
41
+ node.rigor_each_child { |child| each_controller(child, namespace, &) }
42
+ end
43
+
44
+ def constant_segments(path)
45
+ case path
46
+ when Prism::ConstantReadNode then [path.name.to_s]
47
+ when Prism::ConstantPathNode then constant_segments(path.parent) + [path.name.to_s]
48
+ else []
49
+ end
50
+ end
51
+
52
+ # `["Admin", "UsersController"]` → `"admin/users"`. `ApplicationController` and the other abstract
53
+ # bases are not excluded: an action they define really is inherited, and a template under their own
54
+ # path simply never exists. nil for a class whose name is not a controller's.
55
+ def controller_path(segments)
56
+ return nil unless segments.last&.end_with?("Controller")
57
+
58
+ parts = segments.map { |segment| underscore(segment) }
59
+ parts[-1] = parts[-1].sub(/_controller\z/, "")
60
+ return nil if parts[-1].empty?
61
+
62
+ parts.join("/")
63
+ end
64
+
65
+ # The ASCII-only inflection this needs: a controller constant is a CamelCase identifier, and
66
+ # nothing here has to invert an irregular plural (`ActiveSupport::Inflector` is the analysed
67
+ # project's, not Rigor's).
68
+ def underscore(segment)
69
+ segment.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
70
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
71
+ .downcase
72
+ end
73
+
74
+ # `{ :show => body_node }` for every `def` directly in the class body.
75
+ def method_bodies(node)
76
+ body = node.body
77
+ return {} if body.nil?
78
+
79
+ body.child_nodes.compact.filter_map do |child|
80
+ next nil unless child.is_a?(Prism::DefNode) && child.receiver.nil?
81
+
82
+ [child.name, child.body]
83
+ end.to_h
84
+ end
85
+
86
+ # Every implicit-self `render` call node under `node`, at any depth.
87
+ def each_render(node, &)
88
+ return unless node.is_a?(Prism::Node)
89
+
90
+ yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
91
+ node.rigor_each_child { |child| each_render(child, &) }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -31,6 +31,11 @@ module Rigor
31
31
  module Effects
32
32
  CONTROLLER = "ActionController::Base"
33
33
 
34
+ # The view context a template unit's body runs under — the `self_type:` the plugin declares on
35
+ # every {Rigor::Plugin::TemplateUnit}. A `render` inside a template is an implicit-self call on
36
+ # it, which is what lets one row colour the template → partial edge (#1048).
37
+ VIEW = "ActionView::Base"
38
+
34
39
  RESPONSE = ["mutate.self", "rails.response.write"].freeze
35
40
  SESSION_WRITE = ["mutate", "rails.session.write"].freeze
36
41
  SESSION_READ = ["io", "rails.session.read"].freeze
@@ -46,6 +51,26 @@ module Rigor
46
51
  # the one genuinely misleading row in the whole Rails layer.
47
52
  RENDERERS = %w[render render_to_string render_to_body].freeze
48
53
 
54
+ # Rails' own name for the implicit render — what `ActionController::Base` calls when an action
55
+ # returns without having answered. The project never writes it, which is exactly the point: the
56
+ # row exists so the engine's unit-level {Rigor::Effects::CalleeRule} has a declaration to read,
57
+ # and it carries no labels because an action that implicitly renders has already been coloured by
58
+ # nothing at all and must not be coloured by a guess (see `why:`).
59
+ IMPLICIT_RENDER = "default_render"
60
+
61
+ # Action View's own format lookup order for a template-side render, as DATA the engine's
62
+ # `rails_render_partial` rule copies onto the edge (#1065). While a `.js.erb` template renders, the
63
+ # lookup context holds `[:js, :html]` — `LookupContext#formats=` appends `:html` to a lone `:js` —
64
+ # so `render "watchers"` runs `_watchers.js.erb` where it exists and `_watchers.html.erb`
65
+ # otherwise. The propagator takes the first key a unit answers.
66
+ #
67
+ # `js` is the only entry, because it is the only fallback Action View itself hard-codes. What a
68
+ # `.json`, `.xml` or `.turbo_stream` template falls back to is whatever the REQUEST's formats
69
+ # happened to be (`prepend_formats` puts the template's format in front of them) — an `Accept`
70
+ # header the source never states, Turbo's included — so those keep their taint rather than guess.
71
+ FORMAT_FALLBACKS = { "js" => ["html"] }.freeze
72
+ Ractor.make_shareable(FORMAT_FALLBACKS)
73
+
49
74
  # The cookie jars a Rails app writes through.
50
75
  COOKIE_JARS = ["self.cookies", "self.cookies.signed", "self.cookies.encrypted",
51
76
  "self.cookies.permanent"].freeze
@@ -59,35 +84,67 @@ module Rigor
59
84
  def response_rows
60
85
  RESPONSE_WRITERS.map do |selector|
61
86
  EffectAttribution.new(
62
- receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
87
+ receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true, responds: true,
63
88
  why: "sets the response on the controller instance — Rack writes the socket later, outside " \
64
89
  "any project method, so this is `mutate.self` and deliberately not `io`"
65
90
  )
66
- end + render_rows
91
+ end + render_rows + [implicit_render_row]
67
92
  end
68
93
 
94
+ # `render` and its two `*_to_string` twins. Each carries a `callee:` so the render site edges to
95
+ # the template's own effect unit (#1048), and each keeps the `template-not-analysed` taint — which
96
+ # now rides the edge: a render whose template produced a unit discharges it, and one whose
97
+ # template the plugin declined (a layout, #1047) or whose target is computed keeps it.
98
+ #
99
+ # Only `render` sets `responds:`. `render_to_string` builds a string and leaves the response
100
+ # unanswered, so an action that calls it and returns still takes Rails' implicit render.
69
101
  def render_rows
70
102
  RENDERERS.map do |selector|
71
103
  EffectAttribution.new(
72
104
  receiver: CONTROLLER, method: selector, labels: RESPONSE, discharge: true,
73
- taint: "template-not-analysed",
105
+ taint: "template-not-analysed", callee: "rails_render", responds: selector == "render",
74
106
  why: "sets the response body from a template. The controller half is fully stated; the " \
75
- "template's own effects are unknown until views become effect units, and the taint " \
76
- "is how the summary says so rather than reading exhaustive"
107
+ "template's own effects reach it through the `rails_render` callee edge, and the " \
108
+ "taint survives on the edge for a target the rule cannot resolve"
77
109
  )
78
- end
110
+ end + [
111
+ EffectAttribution.new(
112
+ receiver: VIEW, method: :render, labels: [], discharge: true,
113
+ taint: "template-not-analysed", callee: "rails_render_partial",
114
+ callee_fallbacks: FORMAT_FALLBACKS,
115
+ why: "a `render` inside a template runs another template. The edge is the whole " \
116
+ "contribution — what a partial render DOES is what the partial does, and the row " \
117
+ "reaches it now — while a target the rule cannot resolve keeps the taint. In a view " \
118
+ "a bare argument names a PARTIAL and `layout:` names one too, which is why this is a " \
119
+ "separate rule from the controller's"
120
+ )
121
+ ]
122
+ end
123
+
124
+ # The implicit render, as an EDGE and nothing else. An action that never answered still renders
125
+ # `<controller>/<action>`, so its summary must include that template's effects — but the fact is
126
+ # the absence of a call, so there is no site to colour, and inventing a `rails.response.write`
127
+ # here would put it on every private helper a controller defines as well.
128
+ def implicit_render_row
129
+ EffectAttribution.new(
130
+ receiver: CONTROLLER, method: IMPLICIT_RENDER, labels: [], discharge: true,
131
+ callee: "rails_implicit_render",
132
+ why: "Rails renders `<controller>/<action>` for an action that answered nothing. The edge " \
133
+ "is the whole contribution: no label, because an implicit render is observed from a " \
134
+ "body that made no call, and a label read off an absence would colour every helper"
135
+ )
79
136
  end
80
137
 
81
138
  # `send_file` streams from disk; `send_data` does not.
82
139
  def file_rows
83
140
  [
84
141
  EffectAttribution.new(
85
- receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true,
142
+ receiver: CONTROLLER, method: :send_data, labels: RESPONSE, discharge: true, responds: true,
86
143
  why: "sets the response body from an in-memory string"
87
144
  ),
88
145
  EffectAttribution.new(
89
146
  receiver: CONTROLLER, method: :send_file, labels: RESPONSE + ["io.fs.read"], discharge: true,
90
- why: "sets the response AND reads the named file off disk"
147
+ responds: true, why: "sets the response AND reads the named file off disk"
91
148
  )
92
149
  ]
93
150
  end
@@ -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