rigortype 0.2.7 → 0.2.9

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 (433) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/08-understanding-errors.md +3 -3
  8. data/docs/manual/01-installation.md +1 -0
  9. data/docs/manual/02-cli-reference.md +16 -7
  10. data/docs/manual/04-diagnostics.md +7 -4
  11. data/docs/manual/06-baseline.md +5 -2
  12. data/docs/manual/07-plugins.md +1 -1
  13. data/docs/manual/14-rails-quickstart.md +4 -2
  14. data/docs/manual/15-type-protection-coverage.md +27 -4
  15. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  16. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  17. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  18. data/lib/rigor/analysis/baseline.rb +56 -96
  19. data/lib/rigor/analysis/buffer_binding.rb +13 -20
  20. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +29 -48
  21. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +35 -54
  22. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +23 -37
  23. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +16 -23
  24. data/lib/rigor/analysis/check_rules/rule_walk.rb +48 -71
  25. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +19 -28
  26. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +48 -67
  27. data/lib/rigor/analysis/check_rules.rb +174 -274
  28. data/lib/rigor/analysis/dependency_recorder.rb +26 -38
  29. data/lib/rigor/analysis/dependency_source_inference/boundary_cross_reporter.rb +13 -25
  30. data/lib/rigor/analysis/dependency_source_inference/builder.rb +17 -30
  31. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +15 -26
  32. data/lib/rigor/analysis/dependency_source_inference/index.rb +45 -76
  33. data/lib/rigor/analysis/dependency_source_inference/return_type_heuristic.rb +23 -40
  34. data/lib/rigor/analysis/dependency_source_inference/walker.rb +50 -83
  35. data/lib/rigor/analysis/dependency_source_inference.rb +6 -10
  36. data/lib/rigor/analysis/diagnostic.rb +48 -75
  37. data/lib/rigor/analysis/erb_template_detector.rb +13 -18
  38. data/lib/rigor/analysis/fact_store.rb +12 -22
  39. data/lib/rigor/analysis/incremental.rb +47 -63
  40. data/lib/rigor/analysis/incremental_session.rb +74 -100
  41. data/lib/rigor/analysis/project_scan.rb +12 -23
  42. data/lib/rigor/analysis/result.rb +3 -5
  43. data/lib/rigor/analysis/rule_catalog.rb +67 -95
  44. data/lib/rigor/analysis/run_stats.rb +38 -61
  45. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +101 -160
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +121 -188
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +62 -96
  48. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -18
  49. data/lib/rigor/analysis/runner.rb +220 -333
  50. data/lib/rigor/analysis/self_call_resolution_recorder.rb +30 -43
  51. data/lib/rigor/analysis/worker_session.rb +64 -102
  52. data/lib/rigor/ast/type_node.rb +6 -8
  53. data/lib/rigor/ast.rb +6 -10
  54. data/lib/rigor/bleeding_edge.rb +27 -35
  55. data/lib/rigor/builtins/hkt_builtins.rb +7 -14
  56. data/lib/rigor/builtins/imported_refinements.rb +106 -149
  57. data/lib/rigor/builtins/predefined_constant_refinements.rb +51 -65
  58. data/lib/rigor/builtins/regex_refinement.rb +32 -43
  59. data/lib/rigor/builtins/static_return_refinements.rb +57 -81
  60. data/lib/rigor/cache/descriptor.rb +58 -94
  61. data/lib/rigor/cache/incremental_snapshot.rb +32 -48
  62. data/lib/rigor/cache/rbs_cache_producer.rb +10 -15
  63. data/lib/rigor/cache/rbs_class_ancestor_table.rb +10 -16
  64. data/lib/rigor/cache/rbs_class_type_param_names.rb +10 -16
  65. data/lib/rigor/cache/rbs_constant_table.rb +8 -13
  66. data/lib/rigor/cache/rbs_descriptor.rb +8 -13
  67. data/lib/rigor/cache/rbs_environment.rb +11 -19
  68. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +11 -19
  69. data/lib/rigor/cache/rbs_known_class_names.rb +8 -12
  70. data/lib/rigor/cache/store.rb +248 -186
  71. data/lib/rigor/cli/annotate_command.rb +51 -88
  72. data/lib/rigor/cli/baseline_command.rb +10 -17
  73. data/lib/rigor/cli/check_command.rb +121 -199
  74. data/lib/rigor/cli/check_runner_factory.rb +8 -14
  75. data/lib/rigor/cli/ci_detector.rb +9 -14
  76. data/lib/rigor/cli/command.rb +8 -14
  77. data/lib/rigor/cli/coverage_command.rb +62 -48
  78. data/lib/rigor/cli/coverage_mutation.rb +14 -19
  79. data/lib/rigor/cli/coverage_report.rb +0 -2
  80. data/lib/rigor/cli/coverage_scan.rb +7 -11
  81. data/lib/rigor/cli/diagnostic_formats.rb +46 -62
  82. data/lib/rigor/cli/diff_command.rb +10 -20
  83. data/lib/rigor/cli/docs_command.rb +26 -40
  84. data/lib/rigor/cli/doctor_command.rb +8 -9
  85. data/lib/rigor/cli/explain_command.rb +4 -7
  86. data/lib/rigor/cli/fused_protection_renderer.rb +4 -5
  87. data/lib/rigor/cli/fused_protection_report.rb +6 -8
  88. data/lib/rigor/cli/lsp_command.rb +11 -19
  89. data/lib/rigor/cli/mcp_command.rb +4 -6
  90. data/lib/rigor/cli/mutation_protection_renderer.rb +3 -4
  91. data/lib/rigor/cli/mutation_protection_report.rb +6 -9
  92. data/lib/rigor/cli/options.rb +11 -19
  93. data/lib/rigor/cli/plugin_command.rb +18 -30
  94. data/lib/rigor/cli/plugins_command.rb +29 -51
  95. data/lib/rigor/cli/plugins_renderer.rb +12 -20
  96. data/lib/rigor/cli/prism_colorizer.rb +13 -19
  97. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  98. data/lib/rigor/cli/protection_renderer.rb +5 -7
  99. data/lib/rigor/cli/protection_report.rb +34 -18
  100. data/lib/rigor/cli/renderable.rb +4 -6
  101. data/lib/rigor/cli/show_bleedingedge_command.rb +5 -8
  102. data/lib/rigor/cli/sig_gen_command.rb +14 -26
  103. data/lib/rigor/cli/skill_command.rb +30 -47
  104. data/lib/rigor/cli/skill_describe.rb +40 -64
  105. data/lib/rigor/cli/trace_command.rb +9 -16
  106. data/lib/rigor/cli/trace_renderer.rb +35 -51
  107. data/lib/rigor/cli/triage_command.rb +6 -10
  108. data/lib/rigor/cli/triage_renderer.rb +1 -2
  109. data/lib/rigor/cli/type_of_command.rb +14 -23
  110. data/lib/rigor/cli/type_of_renderer.rb +3 -5
  111. data/lib/rigor/cli/type_scan_command.rb +6 -10
  112. data/lib/rigor/cli/type_scan_renderer.rb +3 -5
  113. data/lib/rigor/cli/type_scan_report.rb +2 -3
  114. data/lib/rigor/cli/upgrade_command.rb +2 -3
  115. data/lib/rigor/cli.rb +16 -28
  116. data/lib/rigor/config_audit.rb +35 -44
  117. data/lib/rigor/configuration/dependencies.rb +41 -70
  118. data/lib/rigor/configuration/severity_profile.rb +28 -42
  119. data/lib/rigor/configuration.rb +150 -241
  120. data/lib/rigor/environment/bundle_sig_discovery.rb +74 -118
  121. data/lib/rigor/environment/class_registry.rb +19 -26
  122. data/lib/rigor/environment/constant_type_cache_holder.rb +14 -21
  123. data/lib/rigor/environment/hkt_registry_holder.rb +6 -12
  124. data/lib/rigor/environment/lockfile_resolver.rb +31 -47
  125. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  126. data/lib/rigor/environment/rbs_collection_discovery.rb +39 -63
  127. data/lib/rigor/environment/rbs_coverage_report.rb +24 -42
  128. data/lib/rigor/environment/rbs_loader.rb +420 -518
  129. data/lib/rigor/environment/reflection.rb +28 -53
  130. data/lib/rigor/environment/reporters.rb +13 -24
  131. data/lib/rigor/environment.rb +225 -304
  132. data/lib/rigor/flow_contribution/conflict.rb +16 -27
  133. data/lib/rigor/flow_contribution/element.rb +7 -12
  134. data/lib/rigor/flow_contribution/fact.rb +33 -57
  135. data/lib/rigor/flow_contribution/merge_result.rb +6 -9
  136. data/lib/rigor/flow_contribution/merger.rb +32 -47
  137. data/lib/rigor/flow_contribution.rb +37 -55
  138. data/lib/rigor/inference/acceptance.rb +133 -219
  139. data/lib/rigor/inference/block_parameter_binder.rb +52 -82
  140. data/lib/rigor/inference/body_fixpoint.rb +22 -30
  141. data/lib/rigor/inference/budget_trace.rb +43 -63
  142. data/lib/rigor/inference/builtins/array_catalog.rb +4 -6
  143. data/lib/rigor/inference/builtins/comparable_catalog.rb +4 -6
  144. data/lib/rigor/inference/builtins/complex_catalog.rb +13 -19
  145. data/lib/rigor/inference/builtins/date_catalog.rb +38 -67
  146. data/lib/rigor/inference/builtins/encoding_catalog.rb +22 -36
  147. data/lib/rigor/inference/builtins/enumerable_catalog.rb +4 -6
  148. data/lib/rigor/inference/builtins/exception_catalog.rb +38 -57
  149. data/lib/rigor/inference/builtins/hash_catalog.rb +9 -14
  150. data/lib/rigor/inference/builtins/method_catalog.rb +29 -48
  151. data/lib/rigor/inference/builtins/numeric_catalog.rb +13 -18
  152. data/lib/rigor/inference/builtins/pathname_catalog.rb +8 -13
  153. data/lib/rigor/inference/builtins/proc_catalog.rb +38 -62
  154. data/lib/rigor/inference/builtins/random_catalog.rb +21 -32
  155. data/lib/rigor/inference/builtins/range_catalog.rb +12 -21
  156. data/lib/rigor/inference/builtins/rational_catalog.rb +9 -15
  157. data/lib/rigor/inference/builtins/re_catalog.rb +29 -48
  158. data/lib/rigor/inference/builtins/set_catalog.rb +18 -27
  159. data/lib/rigor/inference/builtins/string_catalog.rb +12 -19
  160. data/lib/rigor/inference/builtins/struct_catalog.rb +18 -30
  161. data/lib/rigor/inference/builtins/time_catalog.rb +28 -44
  162. data/lib/rigor/inference/closure_escape_analyzer.rb +44 -67
  163. data/lib/rigor/inference/coverage_scanner.rb +13 -18
  164. data/lib/rigor/inference/def_return_typer.rb +8 -14
  165. data/lib/rigor/inference/dynamic_origin.rb +23 -20
  166. data/lib/rigor/inference/expression_typer.rb +794 -1154
  167. data/lib/rigor/inference/fallback.rb +8 -12
  168. data/lib/rigor/inference/fallback_tracer.rb +4 -10
  169. data/lib/rigor/inference/flow_tracer.rb +25 -36
  170. data/lib/rigor/inference/fork_map.rb +87 -0
  171. data/lib/rigor/inference/hkt_body.rb +45 -68
  172. data/lib/rigor/inference/hkt_body_parser.rb +33 -52
  173. data/lib/rigor/inference/hkt_reducer.rb +38 -59
  174. data/lib/rigor/inference/hkt_registry.rb +50 -76
  175. data/lib/rigor/inference/indexed_narrowing.rb +55 -81
  176. data/lib/rigor/inference/macro_block_self_type.rb +21 -34
  177. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +11 -18
  178. data/lib/rigor/inference/method_dispatcher/block_folding.rb +49 -83
  179. data/lib/rigor/inference/method_dispatcher/call_context.rb +16 -23
  180. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +13 -22
  181. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +307 -454
  182. data/lib/rigor/inference/method_dispatcher/data_folding.rb +24 -34
  183. data/lib/rigor/inference/method_dispatcher/file_folding.rb +22 -37
  184. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +52 -81
  185. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +51 -78
  186. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +60 -97
  187. data/lib/rigor/inference/method_dispatcher/math_folding.rb +23 -34
  188. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +18 -26
  189. data/lib/rigor/inference/method_dispatcher/method_folding.rb +37 -71
  190. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +114 -182
  191. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +122 -193
  192. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +15 -24
  193. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +64 -93
  194. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +31 -45
  195. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -9
  196. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +356 -511
  197. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +22 -32
  198. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +15 -21
  199. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +66 -82
  200. data/lib/rigor/inference/method_dispatcher/time_folding.rb +10 -15
  201. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -13
  202. data/lib/rigor/inference/method_dispatcher.rb +378 -524
  203. data/lib/rigor/inference/method_parameter_binder.rb +81 -114
  204. data/lib/rigor/inference/multi_target_binder.rb +51 -68
  205. data/lib/rigor/inference/mutation_widening.rb +115 -158
  206. data/lib/rigor/inference/narrowing.rb +627 -727
  207. data/lib/rigor/inference/origin_lookup.rb +38 -0
  208. data/lib/rigor/inference/parameter_inference_collector.rb +154 -109
  209. data/lib/rigor/inference/precision_scanner.rb +26 -35
  210. data/lib/rigor/inference/project_patched_methods.rb +20 -32
  211. data/lib/rigor/inference/project_patched_scanner.rb +23 -37
  212. data/lib/rigor/inference/protection_scanner.rb +16 -18
  213. data/lib/rigor/inference/rbs_type_translator.rb +49 -66
  214. data/lib/rigor/inference/scope_indexer.rb +473 -821
  215. data/lib/rigor/inference/statement_evaluator.rb +754 -1114
  216. data/lib/rigor/inference/struct_fold_safety.rb +34 -48
  217. data/lib/rigor/inference/synthetic_method.rb +9 -16
  218. data/lib/rigor/inference/synthetic_method_index.rb +20 -35
  219. data/lib/rigor/inference/synthetic_method_scanner.rb +63 -103
  220. data/lib/rigor/language_server/buffer_resolution.rb +6 -9
  221. data/lib/rigor/language_server/buffer_table.rb +11 -18
  222. data/lib/rigor/language_server/completion_provider.rb +69 -116
  223. data/lib/rigor/language_server/debouncer.rb +16 -25
  224. data/lib/rigor/language_server/diagnostic_publisher.rb +26 -40
  225. data/lib/rigor/language_server/document_symbol_provider.rb +14 -19
  226. data/lib/rigor/language_server/folding_range_provider.rb +11 -19
  227. data/lib/rigor/language_server/hover_provider.rb +8 -11
  228. data/lib/rigor/language_server/hover_renderer.rb +56 -93
  229. data/lib/rigor/language_server/loop.rb +14 -19
  230. data/lib/rigor/language_server/project_context.rb +46 -74
  231. data/lib/rigor/language_server/selection_range_provider.rb +9 -12
  232. data/lib/rigor/language_server/server.rb +55 -83
  233. data/lib/rigor/language_server/signature_help_provider.rb +29 -46
  234. data/lib/rigor/language_server/synchronized_writer.rb +4 -7
  235. data/lib/rigor/language_server/uri.rb +8 -13
  236. data/lib/rigor/language_server.rb +4 -6
  237. data/lib/rigor/mcp/loop.rb +2 -3
  238. data/lib/rigor/mcp/server.rb +4 -7
  239. data/lib/rigor/mcp.rb +3 -6
  240. data/lib/rigor/plugin/access_denied_error.rb +5 -8
  241. data/lib/rigor/plugin/additional_initializer.rb +21 -31
  242. data/lib/rigor/plugin/base.rb +335 -518
  243. data/lib/rigor/plugin/blueprint.rb +14 -23
  244. data/lib/rigor/plugin/box.rb +18 -29
  245. data/lib/rigor/plugin/fact_store.rb +16 -26
  246. data/lib/rigor/plugin/inflector.rb +37 -53
  247. data/lib/rigor/plugin/io_boundary.rb +33 -56
  248. data/lib/rigor/plugin/isolation.rb +42 -55
  249. data/lib/rigor/plugin/load_error.rb +10 -15
  250. data/lib/rigor/plugin/loader.rb +30 -49
  251. data/lib/rigor/plugin/macro/block_as_method.rb +20 -32
  252. data/lib/rigor/plugin/macro/heredoc_template.rb +35 -58
  253. data/lib/rigor/plugin/macro/nested_class_template.rb +22 -36
  254. data/lib/rigor/plugin/macro/trait_registry.rb +34 -51
  255. data/lib/rigor/plugin/macro.rb +10 -15
  256. data/lib/rigor/plugin/manifest.rb +85 -144
  257. data/lib/rigor/plugin/node_context.rb +14 -22
  258. data/lib/rigor/plugin/node_rule_walk.rb +49 -74
  259. data/lib/rigor/plugin/protocol_contract.rb +25 -39
  260. data/lib/rigor/plugin/registry.rb +132 -205
  261. data/lib/rigor/plugin/services.rb +21 -33
  262. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +11 -19
  263. data/lib/rigor/plugin/trust_policy.rb +24 -38
  264. data/lib/rigor/plugin/type_node_resolver.rb +15 -24
  265. data/lib/rigor/protection/diagnostic_oracle.rb +11 -13
  266. data/lib/rigor/protection/mutation_scanner.rb +27 -35
  267. data/lib/rigor/protection/mutator.rb +50 -70
  268. data/lib/rigor/protection/test_suite_oracle.rb +20 -27
  269. data/lib/rigor/rbs_extended/conformance_checker.rb +48 -68
  270. data/lib/rigor/rbs_extended/hkt_directives.rb +28 -54
  271. data/lib/rigor/rbs_extended/reporter.rb +24 -40
  272. data/lib/rigor/rbs_extended.rb +107 -197
  273. data/lib/rigor/reflection.rb +68 -86
  274. data/lib/rigor/scope/discovery_index.rb +14 -19
  275. data/lib/rigor/scope.rb +255 -310
  276. data/lib/rigor/sig_gen/classification.rb +6 -10
  277. data/lib/rigor/sig_gen/generator.rb +197 -323
  278. data/lib/rigor/sig_gen/layout_index.rb +12 -20
  279. data/lib/rigor/sig_gen/method_candidate.rb +12 -17
  280. data/lib/rigor/sig_gen/observation_collector.rb +38 -70
  281. data/lib/rigor/sig_gen/observed_call.rb +13 -23
  282. data/lib/rigor/sig_gen/path_mapper.rb +17 -29
  283. data/lib/rigor/sig_gen/renderer.rb +7 -13
  284. data/lib/rigor/sig_gen/type_elaborator.rb +15 -28
  285. data/lib/rigor/sig_gen/write_result.rb +8 -16
  286. data/lib/rigor/sig_gen/writer.rb +95 -174
  287. data/lib/rigor/sig_gen.rb +3 -6
  288. data/lib/rigor/signature_path_audit.rb +24 -30
  289. data/lib/rigor/source/constant_path.rb +10 -14
  290. data/lib/rigor/source/literals.rb +31 -45
  291. data/lib/rigor/source/node_locator.rb +9 -11
  292. data/lib/rigor/source/node_walker.rb +9 -13
  293. data/lib/rigor/source.rb +3 -4
  294. data/lib/rigor/testing.rb +16 -20
  295. data/lib/rigor/triage/catalogue.rb +38 -62
  296. data/lib/rigor/triage.rb +31 -52
  297. data/lib/rigor/trinary.rb +9 -13
  298. data/lib/rigor/type/acceptance_router.rb +4 -6
  299. data/lib/rigor/type/accepts_result.rb +10 -14
  300. data/lib/rigor/type/app.rb +19 -27
  301. data/lib/rigor/type/bot.rb +4 -6
  302. data/lib/rigor/type/bound_method.rb +10 -15
  303. data/lib/rigor/type/combinator.rb +165 -257
  304. data/lib/rigor/type/constant.rb +23 -34
  305. data/lib/rigor/type/data_class.rb +10 -15
  306. data/lib/rigor/type/data_instance.rb +14 -20
  307. data/lib/rigor/type/difference.rb +21 -32
  308. data/lib/rigor/type/dynamic.rb +3 -5
  309. data/lib/rigor/type/hash_shape.rb +32 -18
  310. data/lib/rigor/type/integer_range.rb +11 -16
  311. data/lib/rigor/type/intersection.rb +27 -42
  312. data/lib/rigor/type/nominal.rb +10 -15
  313. data/lib/rigor/type/plain_lattice.rb +9 -13
  314. data/lib/rigor/type/refined.rb +67 -114
  315. data/lib/rigor/type/singleton.rb +4 -6
  316. data/lib/rigor/type/struct_class.rb +11 -16
  317. data/lib/rigor/type/struct_instance.rb +15 -21
  318. data/lib/rigor/type/tuple.rb +14 -19
  319. data/lib/rigor/type/union.rb +30 -42
  320. data/lib/rigor/type_node/generic.rb +14 -26
  321. data/lib/rigor/type_node/identifier.rb +12 -19
  322. data/lib/rigor/type_node.rb +3 -12
  323. data/lib/rigor/value_semantics.rb +16 -21
  324. data/lib/rigor/version.rb +1 -1
  325. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +18 -30
  326. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +16 -29
  327. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +12 -23
  328. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +22 -39
  329. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +26 -46
  330. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +51 -87
  331. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +10 -17
  332. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +26 -39
  333. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +141 -184
  334. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +30 -51
  335. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +57 -92
  336. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +92 -108
  337. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +13 -22
  338. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +15 -25
  339. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +8 -13
  340. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +16 -24
  341. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +40 -48
  342. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +140 -129
  343. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +56 -59
  344. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +27 -41
  345. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +16 -24
  346. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  347. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +168 -241
  348. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +3 -5
  349. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +8 -14
  350. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +9 -15
  351. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +4 -7
  352. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +23 -41
  353. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +3 -5
  354. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +6 -10
  355. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +8 -11
  356. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  357. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +30 -45
  358. data/plugins/rigor-devise/lib/rigor-devise.rb +3 -5
  359. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +43 -69
  360. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +26 -35
  361. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +3 -5
  362. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +30 -47
  363. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +3 -5
  364. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +67 -113
  365. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +29 -46
  366. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +3 -5
  367. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +13 -20
  368. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +14 -23
  369. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +2 -3
  370. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +22 -37
  371. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +28 -46
  372. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +13 -17
  373. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -47
  374. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +52 -80
  375. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +21 -31
  376. data/plugins/rigor-graphql/lib/rigor-graphql.rb +3 -5
  377. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +11 -17
  378. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +20 -34
  379. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +45 -75
  380. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +22 -36
  381. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +22 -41
  382. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +17 -32
  383. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +6 -12
  384. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +4 -8
  385. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +17 -32
  386. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -18
  387. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +58 -92
  388. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +20 -34
  389. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +20 -31
  390. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +49 -82
  391. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +55 -92
  392. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +51 -90
  393. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +17 -30
  394. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +189 -0
  395. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +38 -70
  396. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +61 -75
  397. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +319 -505
  398. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +91 -96
  399. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +32 -54
  400. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +8 -13
  401. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +9 -16
  402. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +24 -39
  403. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +13 -22
  404. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +34 -56
  405. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +22 -42
  406. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +43 -75
  407. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +27 -39
  408. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +14 -19
  409. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +20 -34
  410. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +28 -47
  411. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +21 -37
  412. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +13 -22
  413. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +18 -31
  414. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +6 -10
  415. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +14 -29
  416. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +23 -41
  417. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +3 -5
  418. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +23 -38
  419. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +57 -96
  420. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +22 -35
  421. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +41 -69
  422. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +6 -10
  423. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +24 -41
  424. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +29 -50
  425. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +55 -88
  426. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +159 -262
  427. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +21 -29
  428. data/plugins/rigor-statesman/lib/rigor-statesman.rb +3 -5
  429. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +23 -37
  430. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +4 -6
  431. data/sig/rigor/environment.rbs +1 -0
  432. data/sig/rigor/scope.rbs +6 -0
  433. metadata +9 -1
@@ -8,27 +8,21 @@ require_relative "helper_table"
8
8
  module Rigor
9
9
  module Plugin
10
10
  class RailsRoutes < Rigor::Plugin::Base
11
- # Statically interprets `config/routes.rb`'s DSL via
12
- # Prism never executes the file. The interpreter is
13
- # deliberately narrow; it covers the subset documented
14
- # in the plugin's README and degrades silently on
15
- # constructs it doesn't recognise.
11
+ # Statically interprets `config/routes.rb`'s DSL via Prism — never executes the file. The interpreter
12
+ # is deliberately narrow; it covers the subset documented in the plugin's README and degrades
13
+ # silently on constructs it doesn't recognise.
16
14
  #
17
- # Recognised DSL surface (per the Rails-plugins
18
- # roadmap):
15
+ # Recognised DSL surface (per the Rails-plugins roadmap):
19
16
  #
20
- # - `Rails.application.routes.draw do ... end` (entry
21
- # block; the body is interpreted)
17
+ # - `Rails.application.routes.draw do ... end` (entry block; the body is interpreted)
22
18
  # - `resources :name [, only: [...] | except: [...]]`
23
19
  # - `resource :name`
24
20
  # - `get/post/patch/put/delete "path", to:, as:`
25
21
  # - `root to: "..."` / `root "..."`
26
22
  # - `scope "path", as: :name do ... end` (with `as:` key)
27
23
  # - One level of `namespace :foo do ... end`
28
- # - One level of nested `resources` (`resources :users
29
- # do; resources :posts; end`)
30
- # - `member do ... end` / `collection do ... end`
31
- # inside `resources`
24
+ # - One level of nested `resources` (`resources :users do; resources :posts; end`)
25
+ # - `member do ... end` / `collection do ... end` inside `resources`
32
26
  #
33
27
  # Out of scope for v0.1.0 (silent skips):
34
28
  #
@@ -40,12 +34,10 @@ module Rigor
40
34
  module RoutesParser
41
35
  # Standard resource actions Rails generates by default.
42
36
  DEFAULT_RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze
43
- # Default actions for `resource` (singular) — no index,
44
- # no `:id` segment.
37
+ # Default actions for `resource` (singular) — no index, no `:id` segment.
45
38
  DEFAULT_SINGULAR_ACTIONS = %i[show new create edit update destroy].freeze
46
39
 
47
- # Helper-name conventions per action. `:show` and
48
- # `:update` / `:destroy` share the singular-form
40
+ # Helper-name conventions per action. `:show` and `:update` / `:destroy` share the singular-form
49
41
  # helper (Rails dedupes).
50
42
  ACTION_HTTP_METHODS = {
51
43
  index: :get,
@@ -64,17 +56,17 @@ module Rigor
64
56
  # draw partial from `config/routes/name.rb`. Returns file contents
65
57
  # or nil when the file is absent.
66
58
  # @return [HelperTable]
67
- def parse(contents, file_reader: nil, custom_helpers: [])
59
+ def parse(contents, file_reader: nil, custom_helpers: [], grape_prefixes: [])
68
60
  parse_result = Prism.parse(contents)
69
- return HelperTable.new([], custom_helpers: custom_helpers) unless parse_result.errors.empty?
61
+ unless parse_result.errors.empty?
62
+ return HelperTable.new([], custom_helpers: custom_helpers, grape_prefixes: grape_prefixes)
63
+ end
70
64
 
71
65
  context = Context.new(file_reader: file_reader)
72
66
  interpret(parse_result.value, context)
73
67
 
74
- # Apply name-transform alias rules discovered during
75
- # the walk (the `direct(name.sub(X, Y)) do |...| send(
76
- # "#{name}_url", ...) end` GitLab pattern — see
77
- # `collect_alias_rules` below).
68
+ # Apply name-transform alias rules discovered during the walk (the `direct(name.sub(X, Y)) do
69
+ # |...| send("#{name}_url", ...) end` GitLab pattern — see `collect_alias_rules` below).
78
70
  apply_alias_rules(context)
79
71
 
80
72
  # Each helper has both `_path` and `_url` forms.
@@ -90,24 +82,19 @@ module Rigor
90
82
  )
91
83
  ]
92
84
  end
93
- HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources)
85
+ HelperTable.new(paired, custom_helpers: custom_helpers, devise_resources: context.devise_resources,
86
+ grape_prefixes: grape_prefixes)
94
87
  end
95
88
 
96
- # For every registered alias rule `(from_str, to_str,
97
- # arity_delta)`, find every existing entry whose name
98
- # contains `from_str` and register an aliased entry with
99
- # `from_str to_str` substitution. The pattern matches
100
- # GitLab's shorthand-helper idiom: a loop over registered
101
- # route names that invokes `direct(new_name) do |project,
102
- # *args| send("#{name}_url", project&.namespace,
103
- # project, *args) end` where `new_name = name.sub(FROM,
104
- # TO)`. The direct block extracts the namespace from
105
- # `project` and forwards the rest, so the alias's arity
106
- # is one LESS than the original helper's
107
- # (`namespace_project_blob_path(namespace_id, project_id,
108
- # blob_id)` → `project_blob_path(project, blob_id)`).
109
- # We register each alias under the original entry's
110
- # arity AND `original - 1` to span both interpretations.
89
+ # For every registered alias rule `(from_str, to_str, arity_delta)`, find every existing entry whose
90
+ # name contains `from_str` and register an aliased entry with `from_str → to_str` substitution. The
91
+ # pattern matches GitLab's shorthand-helper idiom: a loop over registered route names that invokes
92
+ # `direct(new_name) do |project, *args| send("#{name}_url", project&.namespace, project, *args) end`
93
+ # where `new_name = name.sub(FROM, TO)`. The direct block extracts the namespace from `project` and
94
+ # forwards the rest, so the alias's arity is one LESS than the original helper's
95
+ # (`namespace_project_blob_path(namespace_id, project_id, blob_id)` →
96
+ # `project_blob_path(project, blob_id)`). We register each alias under the original entry's arity
97
+ # AND `original - 1` to span both interpretations.
111
98
  def apply_alias_rules(context)
112
99
  return if context.alias_rules.empty?
113
100
 
@@ -119,10 +106,8 @@ module Rigor
119
106
  new_name = entry.name.sub(from, to)
120
107
  next if new_name == entry.name
121
108
 
122
- # Register at original arity AND at arity-1 so the
123
- # common GitLab `|project, *args|` pattern (which
124
- # collapses namespace_id + project_id into project)
125
- # passes the arity check.
109
+ # Register at original arity AND at arity-1 so the common GitLab `|project, *args|` pattern
110
+ # (which collapses namespace_id + project_id into project) passes the arity check.
126
111
  [entry.arity, [entry.arity - 1, 0].max].uniq.each do |arity|
127
112
  aliases << HelperTable::Entry.new(
128
113
  name: new_name, arity: arity,
@@ -135,9 +120,8 @@ module Rigor
135
120
  context.entries.concat(aliases)
136
121
  end
137
122
 
138
- # Per-parse mutable accumulator. Tracks the current
139
- # nesting prefix (namespaces + parent resource) and the
140
- # entries collected so far.
123
+ # Per-parse mutable accumulator. Tracks the current nesting prefix (namespaces + parent resource)
124
+ # and the entries collected so far.
141
125
  class Context
142
126
  attr_reader :entries, :file_reader, :devise_resources, :alias_rules
143
127
 
@@ -149,22 +133,15 @@ module Rigor
149
133
  # - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
150
134
  # - `{ kind: :as_scope, name: "event", path: "/:event_slug", arity: 1 }`
151
135
  @stack = []
152
- # Devise resource segments (singularised) declared
153
- # via `devise_for :resource`. Drives the
136
+ # Devise resource segments (singularised) declared via `devise_for :resource`. Drives the
154
137
  # OmniAuth-helper recognition in `HelperTable#omniauth_match?`.
155
138
  @devise_resources = []
156
- # Registered `concern :name do ... end` blocks.
157
- # Keyed by Symbol name; value is the block's body
158
- # node. `resources :foo, concerns: :name do ... end`
159
- # replays the body at the resource's site.
139
+ # Registered `concern :name do ... end` blocks. Keyed by Symbol name; value is the block's body
140
+ # node. `resources :foo, concerns: :name do ... end` replays the body at the resource's site.
160
141
  @concerns = {}
161
- # `(from_str, to_str)` pairs collected from
162
- # iterative `direct(name.sub(FROM, TO)) do ... end`
163
- # patterns. Applied after parsing via
164
- # `apply_alias_rules` to generate substituted-name
165
- # aliases for every matching entry — closes
166
- # GitLab's `namespace_project_*` → `project_*`
167
- # shorthand idiom.
142
+ # `(from_str, to_str)` pairs collected from iterative `direct(name.sub(FROM, TO)) do ... end`
143
+ # patterns. Applied after parsing via `apply_alias_rules` to generate substituted-name aliases
144
+ # for every matching entry — closes GitLab's `namespace_project_*` → `project_*` shorthand idiom.
168
145
  @alias_rules = []
169
146
  end
170
147
 
@@ -200,17 +177,12 @@ module Rigor
200
177
  @stack.pop
201
178
  end
202
179
 
203
- # `resource :foo do ... end` (SINGULAR resource) —
204
- # adds the resource name to the helper prefix and
205
- # path for nested declarations, but DOESN'T
206
- # contribute a dynamic `:id` segment (singular
207
- # resources have no `:id`). So `resource :instance
208
- # do; resources :domain_blocks; end` generates
209
- # `instance_domain_blocks_path` (arity 0), not
210
- # `instance_domain_blocks_path(:id)`.
180
+ # `resource :foo do ... end` (SINGULAR resource) — adds the resource name to the helper prefix
181
+ # and path for nested declarations, but DOESN'T contribute a dynamic `:id` segment (singular
182
+ # resources have no `:id`). So `resource :instance do; resources :domain_blocks; end` generates
183
+ # `instance_domain_blocks_path` (arity 0), not `instance_domain_blocks_path(:id)`.
211
184
  #
212
- # Helper-prefix segment uses the AS-GIVEN name (no
213
- # singularising — `resource :foo` keeps `foo`).
185
+ # Helper-prefix segment uses the AS-GIVEN name (no singularising — `resource :foo` keeps `foo`).
214
186
  def push_singular_resource(parent_name)
215
187
  name = parent_name.to_s
216
188
  @stack.push(kind: :singular_scope, parent: name, path_segment: "/#{name}")
@@ -219,14 +191,10 @@ module Rigor
219
191
  @stack.pop
220
192
  end
221
193
 
222
- # `member do ... end` / `collection do ... end` —
223
- # records the mode so subsequent shorthand HTTP-verb
224
- # calls (`post :memorialize`) inside the block can
225
- # derive their helper name from the enclosing
226
- # resource. The frame also carries the immediate
227
- # parent's singular / plural names so member /
228
- # collection actions can pick the correct form
229
- # (`memorialize_account_path(id)` vs
194
+ # `member do ... end` / `collection do ... end` — records the mode so subsequent shorthand
195
+ # HTTP-verb calls (`post :memorialize`) inside the block can derive their helper name from the
196
+ # enclosing resource. The frame also carries the immediate parent's singular / plural names so
197
+ # member / collection actions can pick the correct form (`memorialize_account_path(id)` vs
230
198
  # `memorialize_accounts_path`).
231
199
  def push_action_block(mode, parent_singular, parent_plural)
232
200
  @stack.push(kind: :"#{mode}_block", parent_singular: parent_singular, parent_plural: parent_plural)
@@ -235,11 +203,9 @@ module Rigor
235
203
  @stack.pop
236
204
  end
237
205
 
238
- # `with_options only: [:index], concerns: :batch do
239
- # ... end` every call inside the block inherits
240
- # these default options (each call's own options
241
- # override the defaults). `effective_options_for`
242
- # below merges them in caller-precedence order.
206
+ # `with_options only: [:index], concerns: :batch do ... end` — every call inside the block
207
+ # inherits these default options (each call's own options override the defaults).
208
+ # `effective_options_for` below merges them in caller-precedence order.
243
209
  def push_with_options(defaults)
244
210
  @stack.push(kind: :with_options, defaults: defaults)
245
211
  yield
@@ -247,15 +213,10 @@ module Rigor
247
213
  @stack.pop
248
214
  end
249
215
 
250
- # The merged default-options chain from every
251
- # enclosing `with_options` frame on the stack
252
- # (outer-most first; inner frames take precedence).
253
- # `handle_resources` / `handle_resource` merge this
254
- # with the node's own option hash so a bare
255
- # `resources :links` inside `with_options only:
256
- # [:index], concerns: :batch do ... end` is
257
- # treated as if it had those options written
258
- # inline.
216
+ # The merged default-options chain from every enclosing `with_options` frame on the stack
217
+ # (outer-most first; inner frames take precedence). `handle_resources` / `handle_resource` merge
218
+ # this with the node's own option hash so a bare `resources :links` inside `with_options only:
219
+ # [:index], concerns: :batch do ... end` is treated as if it had those options written inline.
259
220
  def with_options_defaults
260
221
  defaults = {}
261
222
  @stack.each do |frame|
@@ -266,10 +227,8 @@ module Rigor
266
227
  defaults
267
228
  end
268
229
 
269
- # Returns the top-most `:scope` frame's singular /
270
- # plural names, or `nil` when not inside a resources
271
- # / resource block. Used by `handle_member_or_collection`
272
- # to push the action-block frame.
230
+ # Returns the top-most `:scope` frame's singular / plural names, or `nil` when not inside a
231
+ # resources / resource block. Used by `handle_member_or_collection` to push the action-block frame.
273
232
  def innermost_resource
274
233
  scope_frame = @stack.rfind { |f| f[:kind] == :scope }
275
234
  return nil if scope_frame.nil?
@@ -277,17 +236,14 @@ module Rigor
277
236
  { singular: scope_frame[:parent], plural: scope_frame[:parent_plural] || scope_frame[:parent] }
278
237
  end
279
238
 
280
- # Top-most `:member_block` / `:collection_block`
281
- # frame, or nil when not in a shorthand action
282
- # context. `handle_explicit_route` uses this to
283
- # detect a `post :memorialize` symbol-only call shape.
239
+ # Top-most `:member_block` / `:collection_block` frame, or nil when not in a shorthand action
240
+ # context. `handle_explicit_route` uses this to detect a `post :memorialize` symbol-only call shape.
284
241
  def innermost_action_block
285
242
  @stack.rfind { |f| %i[member_block collection_block].include?(f[:kind]) }
286
243
  end
287
244
 
288
- # `scope "/:slug", as: "foo" do ... end` — adds a
289
- # helper-name prefix without the "parent resource" arity
290
- # arithmetic that push_resource uses.
245
+ # `scope "/:slug", as: "foo" do ... end` — adds a helper-name prefix without the "parent
246
+ # resource" arity arithmetic that push_resource uses.
291
247
  def push_as_scope(name, path, arity_count)
292
248
  @stack.push(kind: :as_scope, name: name.to_s, path: path, arity: arity_count)
293
249
  yield
@@ -295,20 +251,16 @@ module Rigor
295
251
  @stack.pop
296
252
  end
297
253
 
298
- # Helper-name prefix from namespaces (`admin_`,
299
- # `admin_users_`, …).
254
+ # Helper-name prefix from namespaces (`admin_`, `admin_users_`, …).
300
255
  def helper_prefix
301
256
  segments = @stack.filter_map { |frame| frame_helper_segment(frame) }
302
257
  segments.map { |segment| "#{segment}_" }.join
303
258
  end
304
259
 
305
- # The chain of resource-singular segments above the
306
- # current scope, in order used to form member-route
307
- # helper names of the form `<as>_<singular_chain>_path`.
308
- # Includes nested resources (`resources :projects do;
309
- # resources :issues do; get 'foo', as: 'bar'; end; end`
310
- # → `bar_project_issue_path`). Returns `""` outside any
311
- # `:scope` / `:singular_scope` frame.
260
+ # The chain of resource-singular segments above the current scope, in order — used to form
261
+ # member-route helper names of the form `<as>_<singular_chain>_path`. Includes nested resources
262
+ # (`resources :projects do; resources :issues do; get 'foo', as: 'bar'; end; end` →
263
+ # `bar_project_issue_path`). Returns `""` outside any `:scope` / `:singular_scope` frame.
312
264
  def resource_singular_chain
313
265
  segments = @stack.filter_map do |frame|
314
266
  case frame[:kind]
@@ -319,12 +271,10 @@ module Rigor
319
271
  segments.empty? ? "" : "#{segments.join('_')}_"
320
272
  end
321
273
 
322
- # Namespace-only prefix (drops the resource-singular
323
- # segments that `helper_prefix` includes). Used so that
324
- # an `:as => 'foo'` inside `namespace :admin do
325
- # resources :projects do; ...; end; end` generates
326
- # `admin_foo_project_path` — namespace first, then
327
- # `:as` action prefix, then singular chain.
274
+ # Namespace-only prefix (drops the resource-singular segments that `helper_prefix` includes).
275
+ # Used so that an `:as => 'foo'` inside `namespace :admin do resources :projects do; ...; end;
276
+ # end` generates `admin_foo_project_path` namespace first, then `:as` action prefix, then
277
+ # singular chain.
328
278
  def namespace_only_prefix
329
279
  segments = @stack.filter_map do |frame|
330
280
  case frame[:kind]
@@ -335,34 +285,28 @@ module Rigor
335
285
  segments.empty? ? "" : "#{segments.join('_')}_"
336
286
  end
337
287
 
338
- # True when we're inside a `resources` or `resource`
339
- # scope (the AST has pushed a `:scope` or
340
- # `:singular_scope` frame). Drives member-route
341
- # helper-name generation in `handle_explicit_route`.
288
+ # True when we're inside a `resources` or `resource` scope (the AST has pushed a `:scope` or
289
+ # `:singular_scope` frame). Drives member-route helper-name generation in `handle_explicit_route`.
342
290
  def inside_resource_scope?
343
291
  @stack.any? { |frame| %i[scope singular_scope].include?(frame[:kind]) }
344
292
  end
345
293
 
346
- # The innermost `:scope` frame (plural resource),
347
- # carrying `parent` (singular) and `parent_plural`.
348
- # Used by the inline `:on => :collection` / `:on =>
349
- # :member` form to derive the helper name.
294
+ # The innermost `:scope` frame (plural resource), carrying `parent` (singular) and
295
+ # `parent_plural`. Used by the inline `:on => :collection` / `:on => :member` form to derive the
296
+ # helper name.
350
297
  def innermost_scope_frame
351
298
  @stack.rfind { |f| f[:kind] == :scope }
352
299
  end
353
300
 
354
- # Path prefix — including the parent's `:user_id`
355
- # segments for nested resources and the namespace
356
- # path prefix.
301
+ # Path prefix — including the parent's `:user_id` segments for nested resources and the
302
+ # namespace path prefix.
357
303
  def path_prefix
358
304
  parts = @stack.flat_map { |frame| frame_path_segments(frame) }
359
305
  parts.join
360
306
  end
361
307
 
362
- # Number of dynamic segments (`:user_id`-style)
363
- # captured by the parent scope chain. Used to
364
- # compute helper arity for nested resources.
365
- # Each nested-resource `:scope` frame contributes 1;
308
+ # Number of dynamic segments (`:user_id`-style) captured by the parent scope chain. Used to
309
+ # compute helper arity for nested resources. Each nested-resource `:scope` frame contributes 1;
366
310
  # each `:as_scope` frame contributes its own arity.
367
311
  def parent_segment_count
368
312
  @stack.sum do |frame|
@@ -381,10 +325,8 @@ module Rigor
381
325
  when :namespace then frame[:name]
382
326
  when :scope then frame[:parent]
383
327
  when :as_scope
384
- # An `:as_scope` frame with an empty `:name` is a
385
- # path-only frame pushed by `scope(path: 'X')`
386
- # without `:as` — contributes path/arity but no
387
- # helper-prefix segment.
328
+ # An `:as_scope` frame with an empty `:name` is a path-only frame pushed by `scope(path: 'X')`
329
+ # without `:as` contributes path/arity but no helper-prefix segment.
388
330
  frame[:name].to_s.empty? ? nil : frame[:name]
389
331
  when :singular_scope then frame[:parent]
390
332
  end
@@ -394,12 +336,9 @@ module Rigor
394
336
  case frame[:kind]
395
337
  when :namespace then ["/#{frame[:name]}"]
396
338
  when :scope
397
- # Use the as-given plural when available
398
- # (`parent_plural` was captured at
399
- # push_resource time) the singularize /
400
- # pluralize round-trip is lossy for irregular
401
- # forms (`media → medium → medias`) but the
402
- # original name is always correct.
339
+ # Use the as-given plural when available (`parent_plural` was captured at push_resource
340
+ # time) — the singularize / pluralize round-trip is lossy for irregular forms (`media
341
+ # medium → medias`) but the original name is always correct.
403
342
  plural = frame[:parent_plural] || pluralize(frame[:parent])
404
343
  ["/#{plural}/:#{frame[:parent]}_id"]
405
344
  when :as_scope then frame[:path] ? [frame[:path]] : []
@@ -408,18 +347,14 @@ module Rigor
408
347
  end
409
348
  end
410
349
 
411
- # ADR-39: inflection delegates to the shared
412
- # `Rigor::Plugin::Inflector`, which calls the real
413
- # `ActiveSupport::Inflector` (the authority Rails itself uses to
414
- # generate helper names) when available, falling back to a
415
- # built-in approximation otherwise. This replaces the hand-tuned
416
- # AS-replica these methods used to carry — every special case
417
- # they accreted (`news` uncountable, `media`→`medium`,
418
- # `databases`→`database`, `custom_css` ss-preservation,
419
- # `async_refresh`→`async_refreshes`) was reverse-engineered
420
- # `ActiveSupport::Inflector` behaviour, so the real inflector
421
- # produces the same result for those and the correct one for the
422
- # words the replica never covered.
350
+ # ADR-39: inflection delegates to the shared `Rigor::Plugin::Inflector`, which calls the real
351
+ # `ActiveSupport::Inflector` (the authority Rails itself uses to generate helper names) when
352
+ # available, falling back to a built-in approximation otherwise. This replaces the hand-tuned
353
+ # AS-replica these methods used to carry every special case they accreted (`news` uncountable,
354
+ # `media`→`medium`, `databases`→`database`, `custom_css` ss-preservation,
355
+ # `async_refresh`→`async_refreshes`) was reverse-engineered `ActiveSupport::Inflector` behaviour,
356
+ # so the real inflector produces the same result for those and the correct one for the words the
357
+ # replica never covered.
423
358
  def singularize(word)
424
359
  Rigor::Plugin::Inflector.singularize(word)
425
360
  end
@@ -447,21 +382,15 @@ module Rigor
447
382
  # `Rails.application.routes.draw do ... end`
448
383
  interpret_block_body(node, context)
449
384
  else
450
- # `draw(:admin)` — routing partial at
451
- # config/routes/{name}.rb.
385
+ # `draw(:admin)` — routing partial at config/routes/{name}.rb.
452
386
  load_drawn_routes(node, context)
453
387
  end
454
388
  when :draw_all
455
- # `draw_all :user` — from the
456
- # `action_dispatch-draw_all` gem (used by GitLab
457
- # FOSS). Same single-file load semantics as `draw`
458
- # the gem just allows multiple route-dir search
459
- # paths (we look in the one we know about).
460
- # GitLab's `draw_all :user` loads
461
- # `config/routes/user.rb` containing `devise_for
462
- # :users, controllers: ...` plus the broader user
463
- # route catalogue; without this every Devise
464
- # session helper reads as `unknown-helper`.
389
+ # `draw_all :user` — from the `action_dispatch-draw_all` gem (used by GitLab FOSS). Same
390
+ # single-file load semantics as `draw` — the gem just allows multiple route-dir search paths
391
+ # (we look in the one we know about). GitLab's `draw_all :user` loads
392
+ # `config/routes/user.rb` containing `devise_for :users, controllers: ...` plus the broader
393
+ # user route catalogue; without this every Devise session helper reads as `unknown-helper`.
465
394
  load_drawn_routes(node, context)
466
395
  when :namespace
467
396
  handle_namespace(node, context)
@@ -474,21 +403,15 @@ module Rigor
474
403
  when :scope
475
404
  handle_scope(node, context)
476
405
  when :get, :post, :patch, :put, :delete, :match
477
- # `match 'login', :to => 'a#b', :as => :signin, :via => [:get, :post]`
478
- # generates the same helper-name shape as `get` /
479
- # `post` (first arg = path string, `:as` overrides the
480
- # derived helper name). The only difference is the
481
- # HTTP-method set (driven by `:via`); the helper-name
482
- # generation logic is identical, so reuse the same
483
- # handler. Redmine relies heavily on `match` for
484
- # multi-method endpoints (`account/lost_password`,
485
- # `news/preview`, etc.).
406
+ # `match 'login', :to => 'a#b', :as => :signin, :via => [:get, :post]` generates the same
407
+ # helper-name shape as `get` / `post` (first arg = path string, `:as` overrides the derived
408
+ # helper name). The only difference is the HTTP-method set (driven by `:via`); the helper-name
409
+ # generation logic is identical, so reuse the same handler. Redmine relies heavily on `match`
410
+ # for multi-method endpoints (`account/lost_password`, `news/preview`, etc.).
486
411
  handle_explicit_route(node, context)
487
412
  when :member, :collection
488
- # Inside a `resources` block, `member do ... end`
489
- # / `collection do ... end` introduces extra
490
- # routes. Interpreted only when we have a parent
491
- # scope (otherwise the call is meaningless).
413
+ # Inside a `resources` block, `member do ... end` / `collection do ... end` introduces extra
414
+ # routes. Interpreted only when we have a parent scope (otherwise the call is meaningless).
492
415
  handle_member_or_collection(node, context)
493
416
  when :devise_for
494
417
  handle_devise_for(node, context)
@@ -501,19 +424,14 @@ module Rigor
501
424
  when :concern
502
425
  handle_concern_definition(node, context)
503
426
  when :direct
504
- # `direct(:name) do |args...| ... end` — Rails DSL
505
- # for a custom URL helper. We register `name_path`
506
- # and (auto-paired) `name_url`. The arity comes
507
- # from the block's required-parameter count. Only
508
- # literal Symbol/String names are handled here;
509
- # non-literal forms are caught by the
427
+ # `direct(:name) do |args...| ... end` — Rails DSL for a custom URL helper. We register
428
+ # `name_path` and (auto-paired) `name_url`. The arity comes from the block's required-parameter
429
+ # count. Only literal Symbol/String names are handled here; non-literal forms are caught by the
510
430
  # `detect_alias_rule_in_each` walker upstream.
511
431
  handle_direct(node, context)
512
432
  when :each
513
- # `.each do |name| ... end` — scan the block for
514
- # the GitLab `direct(name.sub(X, Y)) do ... end`
515
- # alias-generation idiom. If found, record the
516
- # (X, Y) pair so `apply_alias_rules` can expand
433
+ # `.each do |name| ... end` — scan the block for the GitLab `direct(name.sub(X, Y)) do ... end`
434
+ # alias-generation idiom. If found, record the (X, Y) pair so `apply_alias_rules` can expand
517
435
  # every matching registered entry.
518
436
  detect_alias_rule_in_each(node, context)
519
437
  interpret_block_body(node, context)
@@ -522,11 +440,9 @@ module Rigor
522
440
  end
523
441
  end
524
442
 
525
- # `direct(:name) do |arg1, arg2, ...| ... end` — Rails
526
- # adds a custom URL helper. We register `name_path`
527
- # with the block's required-arg count as the arity (the
528
- # auto-pairing in `parse` adds `name_url`). Only handled
529
- # for literal Symbol or String first-arg.
443
+ # `direct(:name) do |arg1, arg2, ...| ... end` — Rails adds a custom URL helper. We register
444
+ # `name_path` with the block's required-arg count as the arity (the auto-pairing in `parse` adds
445
+ # `name_url`). Only handled for literal Symbol or String first-arg.
530
446
  def handle_direct(node, context)
531
447
  name = Rigor::Source::Literals.symbol_or_string_name(node.arguments&.arguments&.first)
532
448
  return if name.nil? || name.empty?
@@ -556,13 +472,10 @@ module Rigor
556
472
  # Walks a `.each do |loop_var| ... end` body looking for:
557
473
  # - `<new_name_var> = <loop_var>.sub(FROM_STR, TO_STR)`
558
474
  # - `direct(<new_name_var>) do |...| ... end`
559
- # When both shapes appear with the same `<new_name_var>`,
560
- # registers `(FROM_STR, TO_STR)` as an alias rule. The
561
- # iterated collection (`Rails.application.routes.set`)
562
- # is assumed to yield the names of already-registered
563
- # helpers — GitLab's idiom. Other iterations that happen
564
- # to match the shape are rare; the FP risk is bounded
565
- # because alias generation only adds entries.
475
+ # When both shapes appear with the same `<new_name_var>`, registers `(FROM_STR, TO_STR)` as an
476
+ # alias rule. The iterated collection (`Rails.application.routes.set`) is assumed to yield the
477
+ # names of already-registered helpers — GitLab's idiom. Other iterations that happen to match the
478
+ # shape are rare; the FP risk is bounded because alias generation only adds entries.
566
479
  def detect_alias_rule_in_each(each_node, context)
567
480
  block = each_node.block
568
481
  return unless block.is_a?(Prism::BlockNode)
@@ -573,8 +486,7 @@ module Rigor
573
486
  body = block.body
574
487
  return if body.nil?
575
488
 
576
- # First pass: gather every `<var> = <loop_var>.sub(FROM, TO)`
577
- # assignment.
489
+ # First pass: gather every `<var> = <loop_var>.sub(FROM, TO)` assignment.
578
490
  sub_assignments = {}
579
491
  walk_for_alias_pattern(body) do |node|
580
492
  next unless node.is_a?(Prism::LocalVariableWriteNode)
@@ -596,9 +508,8 @@ module Rigor
596
508
 
597
509
  return if sub_assignments.empty?
598
510
 
599
- # Second pass: find `direct(<var>) do ... end` calls
600
- # whose first arg is one of the captured assignment
601
- # variables.
511
+ # Second pass: find `direct(<var>) do ... end` calls whose first arg is one of the captured
512
+ # assignment variables.
602
513
  walk_for_alias_pattern(body) do |node|
603
514
  next unless node.is_a?(Prism::CallNode) && node.name == :direct
604
515
  next if node.arguments.nil?
@@ -644,14 +555,11 @@ module Rigor
644
555
  context.push_namespace(name) { interpret_block_body(node, context) }
645
556
  end
646
557
 
647
- # `devise_for :users [, skip: [...], path: "..."]` —
648
- # generates the standard Devise route-helper catalogue
649
- # for the named resource. Symbol-literal first arg
650
- # only; non-literal forms (e.g. dynamic resource names
651
- # built from constants) are silently skipped because the
652
- # helper SET depends on the literal name and we cannot
653
- # statically resolve a variable here. `skip:` is read
654
- # so the project's omitted controllers do not register.
558
+ # `devise_for :users [, skip: [...], path: "..."]` — generates the standard Devise route-helper
559
+ # catalogue for the named resource. Symbol-literal first arg only; non-literal forms (e.g. dynamic
560
+ # resource names built from constants) are silently skipped because the helper SET depends on the
561
+ # literal name and we cannot statically resolve a variable here. `skip:` is read so the project's
562
+ # omitted controllers do not register.
655
563
  def handle_devise_for(node, context)
656
564
  resource = symbol_argument(node, 0)
657
565
  return if resource.nil?
@@ -664,18 +572,13 @@ module Rigor
664
572
  end
665
573
  end
666
574
 
667
- # `with_options X do ... end` — applies the `X`
668
- # options hash as defaults for every call inside the
669
- # block. Mastodon's `with_options only: [:index],
670
- # concerns: :batch do resources :links; resources
671
- # :tags; end` lets us register the inner resources
672
- # with the implicit defaults — closing `batch_*_path`
673
- # cluster generated via the `:batch` concern.
575
+ # `with_options X do ... end` — applies the `X` options hash as defaults for every call inside the
576
+ # block. Mastodon's `with_options only: [:index], concerns: :batch do resources :links; resources
577
+ # :tags; end` lets us register the inner resources with the implicit defaults — closing
578
+ # `batch_*_path` cluster generated via the `:batch` concern.
674
579
  #
675
- # We push a `:with_options` frame carrying the
676
- # defaults; `effective_options_for(node)` (in
677
- # `handle_resources` / `handle_resource`) merges them
678
- # in before reading specific option keys.
580
+ # We push a `:with_options` frame carrying the defaults; `effective_options_for(node)` (in
581
+ # `handle_resources` / `handle_resource`) merges them in before reading specific option keys.
679
582
  def handle_with_options(node, context)
680
583
  defaults = options_hash(node)
681
584
  context.push_with_options(defaults) do
@@ -683,18 +586,13 @@ module Rigor
683
586
  end
684
587
  end
685
588
 
686
- # `mount Foo::Engine, at: '/path', as: :name` —
687
- # mounted Rails engines. The mount adds a single
688
- # helper for the mount point itself: `<as>_path` /
689
- # `<as>_url` (arity 0). The engine's own internal
690
- # helpers are out of scope (they'd need the engine's
691
- # routes.rb available; almost no static parser
692
- # follows that). Mastodon uses `mount Sidekiq::Web,
693
- # at: 'sidekiq', as: :sidekiq` and similar.
589
+ # `mount Foo::Engine, at: '/path', as: :name` — mounted Rails engines. The mount adds a single
590
+ # helper for the mount point itself: `<as>_path` / `<as>_url` (arity 0). The engine's own internal
591
+ # helpers are out of scope (they'd need the engine's routes.rb available; almost no static parser
592
+ # follows that). Mastodon uses `mount Sidekiq::Web, at: 'sidekiq', as: :sidekiq` and similar.
694
593
  #
695
- # When `as:` is omitted Rails derives a helper name
696
- # from the engine class that's harder to compute
697
- # statically, so we silently skip.
594
+ # When `as:` is omitted Rails derives a helper name from the engine class — that's harder to
595
+ # compute statically, so we silently skip.
698
596
  def handle_mount(node, context)
699
597
  options = options_hash(node)
700
598
  as_name = options[:as]
@@ -710,15 +608,11 @@ module Rigor
710
608
  )
711
609
  end
712
610
 
713
- # `use_doorkeeper do ... end` — Doorkeeper gem's
714
- # standard OAuth route helpers (`oauth_token_path`,
715
- # `oauth_authorization_path`, `oauth_application_path`,
716
- # etc.). We generate the full catalogue plus walk the
717
- # block body for `skip_controllers <names>` calls so
718
- # the project's omitted controllers don't register.
719
- # `controllers <hash>` mappings inside the block change
720
- # the serving controller class but not the helper
721
- # names — they can stay unmodelled.
611
+ # `use_doorkeeper do ... end` — Doorkeeper gem's standard OAuth route helpers (`oauth_token_path`,
612
+ # `oauth_authorization_path`, `oauth_application_path`, etc.). We generate the full catalogue plus
613
+ # walk the block body for `skip_controllers <names>` calls so the project's omitted controllers
614
+ # don't register. `controllers <hash>` mappings inside the block change the serving controller
615
+ # class but not the helper names they can stay unmodelled.
722
616
  def handle_use_doorkeeper(node, context)
723
617
  skip = collect_doorkeeper_skips(node)
724
618
  DoorkeeperRoutes.generate(skip: skip).each do |entry|
@@ -750,37 +644,27 @@ module Rigor
750
644
 
751
645
  # `scope "/:slug", as: "event" do ... end`
752
646
  #
753
- # When `as:` is present the block body is interpreted under a
754
- # new `:as_scope` stack frame that adds the given prefix to
755
- # every helper registered inside. Dynamic path segments
756
- # (`:slug`) are counted so nested-resource arities stay
757
- # correct.
647
+ # When `as:` is present the block body is interpreted under a new `:as_scope` stack frame that
648
+ # adds the given prefix to every helper registered inside. Dynamic path segments (`:slug`) are
649
+ # counted so nested-resource arities stay correct.
758
650
  #
759
- # When `as:` is absent the block is interpreted without any
760
- # prefix change helper names are unaffected by the scope's
761
- # path, which matches Rails' behaviour for path-only scopes.
651
+ # When `as:` is absent the block is interpreted without any prefix change — helper names are
652
+ # unaffected by the scope's path, which matches Rails' behaviour for path-only scopes.
762
653
  def handle_scope(node, context)
763
654
  as_name = keyword_symbol(node, :as)
764
- # `scope :path_arg, as: :name` (path as a positional
765
- # arg) vs `scope(path: ':project_id', as: :project)`
766
- # (path as a `:path` keyword). GitLab's project routes
767
- # rely on the latter for the `*namespace_id` /
768
- # `:project_id` outer scopes. The leading `/` is added
769
- # if missing so the path-prefix join produces clean
770
- # segments.
655
+ # `scope :path_arg, as: :name` (path as a positional arg) vs `scope(path: ':project_id', as:
656
+ # :project)` (path as a `:path` keyword). GitLab's project routes rely on the latter for the
657
+ # `*namespace_id` / `:project_id` outer scopes. The leading `/` is added if missing so the
658
+ # path-prefix join produces clean segments.
771
659
  path = string_argument(node, 0) || keyword_value_string(node, :path)
772
660
  path = "/#{path}" if path && !path.start_with?("/")
773
661
  arity = path ? count_path_placeholders(path) : 0
774
662
 
775
663
  if as_name.nil?
776
- # Even without `:as`, a `scope(path: 'groups/*id')`
777
- # still contributes its path / arity segments to
778
- # helpers declared inside (GitLab's
779
- # `scope(path: 'groups/*id', controller: :groups)
780
- # do; get :edit, as: :edit_group end` → arity 1).
781
- # Skip the frame when there's also no path
782
- # contribution (a pure `scope(module: :foo)` with
783
- # no helper-name / path effect).
664
+ # Even without `:as`, a `scope(path: 'groups/*id')` still contributes its path / arity segments
665
+ # to helpers declared inside (GitLab's `scope(path: 'groups/*id', controller: :groups) do; get
666
+ # :edit, as: :edit_group end` → arity 1). Skip the frame when there's also no path contribution
667
+ # (a pure `scope(module: :foo)` with no helper-name / path effect).
784
668
  return interpret_block_body(node, context) if path.nil? || arity.zero?
785
669
 
786
670
  context.push_as_scope("", path, arity) do
@@ -801,13 +685,10 @@ module Rigor
801
685
  options = effective_options_for(node, context)
802
686
  actions = restrict_actions_from(options, DEFAULT_RESOURCE_ACTIONS)
803
687
  base_arity = context.parent_segment_count
804
- # `resources :collections, as: :actor_collections`
805
- # remaps the helper name family
806
- # `actor_collections_path` for index,
807
- # `actor_collection_path(id)` for show. Path / arity
808
- # are unaffected. Mastodon uses this inside a
809
- # concern (`resources :collections, only: [:show],
810
- # as: :actor_collections`).
688
+ # `resources :collections, as: :actor_collections` remaps the helper name family —
689
+ # `actor_collections_path` for index, `actor_collection_path(id)` for show. Path / arity are
690
+ # unaffected. Mastodon uses this inside a concern (`resources :collections, only: [:show], as:
691
+ # :actor_collections`).
811
692
  helper_name = options[:as] || name
812
693
 
813
694
  register_resourceful_helpers(helper_name, actions, base_arity, context, plural: true)
@@ -827,30 +708,23 @@ module Rigor
827
708
  base_arity = context.parent_segment_count
828
709
  helper_name = options[:as] || name
829
710
 
830
- # Singular resource — no `:id` segment, no `:index`
831
- # / pluralised helper. The "show" helper is
711
+ # Singular resource — no `:id` segment, no `:index` / pluralised helper. The "show" helper is
832
712
  # `<name>_path` (singular).
833
713
  register_resourceful_helpers(helper_name, actions, base_arity, context, plural: false)
834
714
 
835
- # Push a `:singular_scope` frame so nested
836
- # declarations pick up the singular resource's
837
- # name in their helper prefix (Mastodon's
838
- # `resource :instance do; scope module: :instances
839
- # do; resources :domain_blocks; end; end` →
840
- # `instance_domain_blocks_path`). The singular
841
- # frame adds NO `:id` segment to arity — singular
842
- # resources don't carry one.
715
+ # Push a `:singular_scope` frame so nested declarations pick up the singular resource's name in
716
+ # their helper prefix (Mastodon's `resource :instance do; scope module: :instances do; resources
717
+ # :domain_blocks; end; end` `instance_domain_blocks_path`). The singular frame adds NO `:id`
718
+ # segment to arity singular resources don't carry one.
843
719
  context.push_singular_resource(name) do
844
720
  replay_concerns(node, context)
845
721
  interpret_block_body(node, context)
846
722
  end
847
723
  end
848
724
 
849
- # `concern :account_resources do ... end` registers the
850
- # body for later replay; we DO NOT interpret it at the
851
- # definition site (the body has no parent-resource
852
- # context yet). Concerns at the top level land in the
853
- # Context's `concerns` map by Symbol name.
725
+ # `concern :account_resources do ... end` registers the body for later replay; we DO NOT interpret
726
+ # it at the definition site (the body has no parent-resource context yet). Concerns at the top
727
+ # level land in the Context's `concerns` map by Symbol name.
854
728
  def handle_concern_definition(node, context)
855
729
  name = symbol_argument(node, 0)
856
730
  body = node.block&.body
@@ -859,12 +733,9 @@ module Rigor
859
733
  context.register_concern(name, body)
860
734
  end
861
735
 
862
- # `resources :accounts, concerns: :account_resources do ... end`
863
- # replays the registered concern body inside the
864
- # current Context (which already has the accounts
865
- # resource frame pushed). Supports both single-symbol
866
- # (`concerns: :name`) and array-of-symbols
867
- # (`concerns: [:a, :b]`) forms.
736
+ # `resources :accounts, concerns: :account_resources do ... end` — replays the registered concern
737
+ # body inside the current Context (which already has the accounts resource frame pushed). Supports
738
+ # both single-symbol (`concerns: :name`) and array-of-symbols (`concerns: [:a, :b]`) forms.
868
739
  def replay_concerns(resource_node, context)
869
740
  replay_concerns_from_options(effective_options_for(resource_node, context), context)
870
741
  end
@@ -881,33 +752,25 @@ module Rigor
881
752
  end
882
753
  end
883
754
 
884
- # Returns the merged option hash for a node — the
885
- # caller's own options override `with_options`
886
- # defaults from the surrounding Context stack. Used by
887
- # `handle_resources` / `handle_resource` so a bare
888
- # `resources :foo` inside `with_options only: [:index],
889
- # concerns: :batch do ... end` is treated as if it
890
- # had those options written inline.
755
+ # Returns the merged option hash for a node — the caller's own options override `with_options`
756
+ # defaults from the surrounding Context stack. Used by `handle_resources` / `handle_resource` so a
757
+ # bare `resources :foo` inside `with_options only: [:index], concerns: :batch do ... end` is
758
+ # treated as if it had those options written inline.
891
759
  def effective_options_for(node, context)
892
760
  context.with_options_defaults.merge(options_hash(node))
893
761
  end
894
762
 
895
- # Reads the value of an options-hash key. Distinct from
896
- # `keyword_symbol` / `keyword_array` because `concerns:`
897
- # accepts EITHER a single Symbol or an Array of Symbols
898
- # — same Rails idiom Rails accepts.
763
+ # Reads the value of an options-hash key. Distinct from `keyword_symbol` / `keyword_array` because
764
+ # `concerns:` accepts EITHER a single Symbol or an Array of Symbols — same Rails idiom Rails accepts.
899
765
  def keyword_value(node, key)
900
766
  options_hash(node)[key]
901
767
  end
902
768
 
903
769
  def handle_root(node, context)
904
- # `root to: "..."` / `root "..."` — single helper
905
- # `root_path`, arity 0, GET. Real-world Rails apps also
906
- # use `root :to => 'welcome#index', :as => 'home'` (the
907
- # canonical Redmine idiom across 230+ call sites), which
908
- # registers an additional `home_path` / `home_url` alias
909
- # for the same path. Mastodon and Solidus also use the
910
- # `as:` form occasionally for analytics-friendly URL
770
+ # `root to: "..."` / `root "..."` — single helper `root_path`, arity 0, GET. Real-world Rails
771
+ # apps also use `root :to => 'welcome#index', :as => 'home'` (the canonical Redmine idiom across
772
+ # 230+ call sites), which registers an additional `home_path` / `home_url` alias for the same
773
+ # path. Mastodon and Solidus also use the `as:` form occasionally for analytics-friendly URL
911
774
  # naming.
912
775
  path = context.path_prefix.empty? ? "/" : context.path_prefix
913
776
  context.entries << HelperTable::Entry.new(
@@ -925,23 +788,17 @@ module Rigor
925
788
  end
926
789
 
927
790
  def handle_explicit_route(node, context)
928
- # Member / collection block shorthand: `post :memorialize`
929
- # inside `member do ... end` (no path arg, just a
930
- # SymbolNode). Rails generates a helper based on the
931
- # action name + the enclosing resource: a member
932
- # action becomes `<action>_<singular_chain>_path(id)`,
933
- # a collection action becomes
934
- # `<action>_<plural_chain>_path`.
791
+ # Member / collection block shorthand: `post :memorialize` inside `member do ... end` (no path
792
+ # arg, just a SymbolNode). Rails generates a helper based on the action name + the enclosing
793
+ # resource: a member action becomes `<action>_<singular_chain>_path(id)`, a collection action
794
+ # becomes `<action>_<plural_chain>_path`.
935
795
  return register_member_collection_action(node, context) if member_collection_shorthand?(node, context)
936
796
 
937
- # `:on => :collection` / `:on => :member` inline form
938
- # Rails accepts this as a shorthand for `collection
939
- # do ... end` / `member do ... end` wrappers around a
940
- # single action. `get 'report', :on => :collection`
941
- # inside `resources :time_entries` generates
942
- # `report_time_entries_path` (collection-style: action
943
- # prefix on the plural resource name). Treat the action
944
- # name as the path's basename string (Redmine's idiom).
797
+ # `:on => :collection` / `:on => :member` inline form — Rails accepts this as a shorthand for
798
+ # `collection do ... end` / `member do ... end` wrappers around a single action. `get 'report',
799
+ # :on => :collection` inside `resources :time_entries` generates `report_time_entries_path`
800
+ # (collection-style: action prefix on the plural resource name). Treat the action name as the
801
+ # path's basename string (Redmine's idiom).
945
802
  on_target = keyword_symbol(node, :on)
946
803
  if on_target && context.inside_resource_scope?
947
804
  path_arg = string_argument(node, 0) || symbol_argument(node, 0)&.to_s
@@ -949,36 +806,33 @@ module Rigor
949
806
  return register_on_target_action(node, context, as_name, on_target) if as_name
950
807
  end
951
808
 
952
- # `get "/about", to: "static#about", as: :about` —
953
- # path as the first positional arg. Also handle the
954
- # hashrocket-key form `get 'help/*path' => 'help#show',
955
- # as: :help_page` where the path is the String key in
956
- # the trailing keyword/options hash (its value is the
957
- # `controller#action`). Without this, the arity check
958
- # underestimates because the placeholder count comes
959
- # from a nil path.
809
+ # `get "/about", to: "static#about", as: :about` — path as the first positional arg. Also handle
810
+ # the hashrocket-key form `get 'help/*path' => 'help#show', as: :help_page` where the path is the
811
+ # String key in the trailing keyword/options hash (its value is the `controller#action`). Without
812
+ # this, the arity check underestimates because the placeholder count comes from a nil path.
960
813
  path = string_argument(node, 0) || hashrocket_path_key(node)
814
+ # `get :activity` inside a `scope(as: :user)` — NOT a resources / member / collection block (that
815
+ # shape returned early above), so the symbol is both the path segment (`/activity`) and the action
816
+ # name. Rails composes `<scope_as>_activity_path`. Fall the symbol back into the path so the
817
+ # name-and-arity derivation below picks it up; without it the route registered nothing and every
818
+ # `user_activity_path` call read as `unknown-helper`.
819
+ path ||= symbol_action_path(node)
961
820
  as_name = keyword_symbol(node, :as)
962
821
  return if as_name.nil? && path.nil?
963
822
 
964
- # When `as:` is omitted, Rails derives a helper name
965
- # from the path for static paths (no :segment). We
966
- # do the same when the path has no placeholders. The
967
- # special case `get '/'` inside a `:as_scope` (e.g.
968
- # `scope path: '/blob/*id', as: :blob do; get '/' end`)
969
- # has empty path content after stripping the leading
970
- # slash — Rails reuses the enclosing as_scope's name.
971
- # We surface that by registering the helper with an
972
- # empty `as_name`, which `explicit_route_helper_name`
973
- # then composes from `helper_prefix.chomp("_")`.
823
+ # When `as:` is omitted, Rails derives a helper name from the path for static paths (no
824
+ # :segment). We do the same when the path has no placeholders. The special case `get '/'` inside
825
+ # a `:as_scope` (e.g. `scope path: '/blob/*id', as: :blob do; get '/' end`) has empty path
826
+ # content after stripping the leading slash — Rails reuses the enclosing as_scope's name. We
827
+ # surface that by registering the helper with an empty `as_name`, which
828
+ # `explicit_route_helper_name` then composes from `helper_prefix.chomp("_")`.
974
829
  if as_name.nil?
975
830
  return if path.nil? || path.include?(":")
976
831
 
977
832
  as_name = path.delete_prefix("/").tr("/", "_")
978
833
  if as_name.empty? && context.helper_prefix.empty?
979
- # `get '/'` style — only meaningful when the
980
- # helper_prefix is non-empty (otherwise Rails would
981
- # generate a root helper, handled elsewhere).
834
+ # `get '/'` style — only meaningful when the helper_prefix is non-empty (otherwise Rails
835
+ # would generate a root helper, handled elsewhere).
982
836
  return
983
837
  end
984
838
  end
@@ -987,11 +841,9 @@ module Rigor
987
841
  total_placeholders = count_path_placeholders(path)
988
842
  optional = count_optional_path_placeholders(path)
989
843
  base_arity = context.parent_segment_count + total_placeholders
990
- # Register the maximum-arity entry; if the path
991
- # carries `(...)` optional segments (e.g.
992
- # `'settings(/:tab)'`), register additional entries
993
- # for each smaller arity so the call-site arity check
994
- # accepts `settings_project_path(:id)` (no `:tab`).
844
+ # Register the maximum-arity entry; if the path carries `(...)` optional segments (e.g.
845
+ # `'settings(/:tab)'`), register additional entries for each smaller arity so the call-site
846
+ # arity check accepts `settings_project_path(:id)` (no `:tab`).
995
847
  (0..optional).each do |drop|
996
848
  context.entries << HelperTable::Entry.new(
997
849
  name: name, arity: base_arity - drop,
@@ -1001,28 +853,29 @@ module Rigor
1001
853
  end
1002
854
  end
1003
855
 
1004
- # Builds the helper name for an explicit `get` / `post`
1005
- # / `match` route. Rails uses two distinct shapes:
856
+ # `/activity` for a bare `get :activity` first-arg symbol (used as both path and action name), else
857
+ # nil. Only reached outside a resources / member / collection block the symbol-shorthand there is
858
+ # already handled as a member/collection action upstream.
859
+ def symbol_action_path(node)
860
+ symbol = symbol_argument(node, 0)
861
+ symbol && "/#{symbol}"
862
+ end
863
+
864
+ # Builds the helper name for an explicit `get` / `post` / `match` route. Rails uses two distinct
865
+ # shapes:
1006
866
  #
1007
- # - **Outside a `resources` scope** (top-level or inside
1008
- # `namespace` / `scope`): `<namespace_prefix><as>_path`
1009
- # — e.g. `match 'login', as: :signin` →
1010
- # `signin_path`; inside `namespace :admin`
1011
- # `admin_signin_path`.
1012
- # - **Inside a `resources` scope**: the `:as` acts as
1013
- # the ACTION prefix on the resource's singular chain.
1014
- # `resources :projects do; get 'settings', as:
1015
- # :settings; end` → `settings_project_path(:id)` (NOT
1016
- # `project_settings_path`). The namespace prefix still
1017
- # leads: `namespace :admin do; resources :projects do;
1018
- # get 'foo', as: :bar; end; end` →
867
+ # - **Outside a `resources` scope** (top-level or inside `namespace` / `scope`):
868
+ # `<namespace_prefix><as>_path` — e.g. `match 'login', as: :signin``signin_path`; inside
869
+ # `namespace :admin` → `admin_signin_path`.
870
+ # - **Inside a `resources` scope**: the `:as` acts as the ACTION prefix on the resource's singular
871
+ # chain. `resources :projects do; get 'settings', as: :settings; end` →
872
+ # `settings_project_path(:id)` (NOT `project_settings_path`). The namespace prefix still leads:
873
+ # `namespace :admin do; resources :projects do; get 'foo', as: :bar; end; end` →
1019
874
  # `admin_bar_project_path(:id)`.
1020
875
  def explicit_route_helper_name(context, as_name)
1021
- # Empty `as_name` is the `get '/'`-inside-an-as_scope
1022
- # case (e.g. `scope path: '/blob/*id', as: :blob do;
1023
- # get '/' end` `blob_path(:id)`). Use the
1024
- # helper_prefix as-is (drop the trailing underscore)
1025
- # — Rails reuses the enclosing scope's name verbatim.
876
+ # Empty `as_name` is the `get '/'`-inside-an-as_scope case (e.g. `scope path: '/blob/*id', as:
877
+ # :blob do; get '/' end` → `blob_path(:id)`). Use the helper_prefix as-is (drop the trailing
878
+ # underscore) Rails reuses the enclosing scope's name verbatim.
1026
879
  return "#{context.helper_prefix.chomp('_')}_path" if as_name.to_s.empty?
1027
880
 
1028
881
  if context.inside_resource_scope?
@@ -1032,46 +885,51 @@ module Rigor
1032
885
  end
1033
886
  end
1034
887
 
1035
- # True when the call is `<verb> :symbol [, options...]`
1036
- # or `<verb> 'string'` inside one of:
1037
- # - a `member do ... end` / `collection do ... end`
1038
- # block (canonical shorthand context), or
1039
- # - a `resources :name do ... end` / `resource :name
1040
- # do ... end` block at the *direct* nesting level.
1041
- # Rails defaults a bare symbol- or string-named verb
1042
- # inside resources to a member action (GitLab's
1043
- # `resource :application_settings do; match :general,
1044
- # via: [...] end` → `general_application_settings_path`).
888
+ # True when the call is `<verb> :symbol [, options...]` or `<verb> 'string'` inside one of:
889
+ # - a `member do ... end` / `collection do ... end` block (canonical shorthand context), or
890
+ # - a `resources :name do ... end` / `resource :name do ... end` block at the *direct* nesting
891
+ # level. Rails defaults a bare symbol- or string-named verb inside resources to a member action
892
+ # (GitLab's `resource :application_settings do; match :general, via: [...] end`
893
+ # `general_application_settings_path`).
1045
894
  def member_collection_shorthand?(node, context)
1046
895
  return false unless context.innermost_action_block || context.inside_resource_scope?
1047
896
 
1048
897
  first_arg = node.arguments&.arguments&.first
1049
898
  return true if first_arg.is_a?(Prism::SymbolNode)
1050
-
1051
- # A plain action-name string with no `/` or `:` —
1052
- # treat it as if it were a symbol (`get 'report'` ==
1053
- # `get :report` inside member/collection block).
1054
- first_arg.is_a?(Prism::StringNode) &&
1055
- !first_arg.unescaped.include?("/") &&
1056
- !first_arg.unescaped.include?(":")
899
+ return false unless first_arg.is_a?(Prism::StringNode)
900
+
901
+ path = first_arg.unescaped
902
+ # A dynamic segment (`get 'foo/:id'`) is a real path, not a plain action name — leave it to the
903
+ # generic explicit-route handler.
904
+ return false if path.include?(":")
905
+
906
+ # A bare `get '/'` (or `''`) inside a `collection do` block is the collection root, not an action
907
+ # shorthand — Rails names it after the enclosing resource, not `<empty>_<plural>_path`. An empty
908
+ # normalized action name means "not a shorthand"; fall through to the generic handler.
909
+ return false if path.delete_prefix("/").tr("/", "_").empty?
910
+
911
+ # A single-segment string is an action name in any resource context (`get 'report'` == `get
912
+ # :report`). A MULTI-segment static path (`get 'granular/new'`) is an action too, but only inside
913
+ # an explicit `member do` / `collection do` block, where Rails' `Mapper.normalize_name` maps the
914
+ # slashes to underscores (`granular_new_<scope>_<plural>_path`). Outside such a block (a bare
915
+ # `resources` scope) a multi-segment path is a nested custom route, so keep the generic handler.
916
+ !path.include?("/") || !context.innermost_action_block.nil?
1057
917
  end
1058
918
 
1059
919
  # Generates the member / collection action helper.
1060
920
  # Member: `<action>_<helper_prefix>path(id)`, arity =
1061
- # parent_segment_count (which already includes the
1062
- # enclosing resource's `:id`).
921
+ # parent_segment_count (which already includes the enclosing resource's `:id`).
1063
922
  # Collection: `<action>_<plural_helper_prefix>path`,
1064
- # arity = parent_segment_count - 1 (no `:id` segment;
1065
- # the collection URL is /<resource>/<action>).
923
+ # arity = parent_segment_count - 1 (no `:id` segment; the collection URL is /<resource>/<action>).
1066
924
  def register_member_collection_action(node, context)
925
+ # A multi-segment string action (`get 'granular/new'` in a `collection do` block) is named by
926
+ # Rails via `normalize_name` — leading slash stripped, remaining slashes → underscores.
1067
927
  action_name = symbol_argument(node, 0)&.to_s ||
1068
- string_argument(node, 0).to_s
928
+ string_argument(node, 0).to_s.delete_prefix("/").tr("/", "_")
1069
929
  frame = context.innermost_action_block
1070
- # No `member do` / `collection do` wrapper but we're
1071
- # inside a resources block Rails defaults a bare
1072
- # `<verb> :symbol` inside resources to a MEMBER
1073
- # action (e.g. `get :preview` → `preview_<singular>_path
1074
- # (:id)`).
930
+ # No `member do` / `collection do` wrapper but we're inside a resources block — Rails defaults a
931
+ # bare `<verb> :symbol` inside resources to a MEMBER action (e.g. `get :preview`
932
+ # `preview_<singular>_path(:id)`).
1075
933
  if frame.nil?
1076
934
  register_member_action(node, context, action_name)
1077
935
  elsif frame[:kind] == :member_block
@@ -1081,12 +939,9 @@ module Rigor
1081
939
  end
1082
940
  end
1083
941
 
1084
- # `get 'report', :on => :collection` (or `:member`)
1085
- # inline form inside `resources`. The helper name shape
1086
- # matches member_action / collection_action: the action
1087
- # name prefixes the resource's singular (member) or
1088
- # plural (collection). Closes Redmine's
1089
- # `report_time_entries_path` cluster.
942
+ # `get 'report', :on => :collection` (or `:member`) inline form inside `resources`. The helper name
943
+ # shape matches member_action / collection_action: the action name prefixes the resource's
944
+ # singular (member) or plural (collection). Closes Redmine's `report_time_entries_path` cluster.
1090
945
  def register_on_target_action(node, context, action_name, on_target)
1091
946
  scope_frame = context.innermost_scope_frame
1092
947
  return if scope_frame.nil?
@@ -1114,12 +969,9 @@ module Rigor
1114
969
  end
1115
970
 
1116
971
  def register_member_action(node, context, action_name)
1117
- # `helper_prefix` already ends with "_" (each segment
1118
- # appends one); the formula below yields e.g.
1119
- # `memorialize_admin_account_path`. Arity equals the
1120
- # parent segment count — Rails member URLs carry the
1121
- # enclosing resource's `:id`, which the :scope frame
1122
- # already counts.
972
+ # `helper_prefix` already ends with "_" (each segment appends one); the formula below yields e.g.
973
+ # `memorialize_admin_account_path`. Arity equals the parent segment count — Rails member URLs
974
+ # carry the enclosing resource's `:id`, which the :scope frame already counts.
1123
975
  name = "#{action_name}_#{context.helper_prefix}path"
1124
976
  context.entries << HelperTable::Entry.new(
1125
977
  name: name, arity: context.parent_segment_count,
@@ -1129,10 +981,8 @@ module Rigor
1129
981
  end
1130
982
 
1131
983
  def register_collection_action(node, context, action_name, frame)
1132
- # Collection URL drops the immediate parent's `:id`
1133
- # segment. The plural helper prefix swaps the
1134
- # singular form (in `helper_prefix`) for the plural
1135
- # — the immediate-resource frame stored both.
984
+ # Collection URL drops the immediate parent's `:id` segment. The plural helper prefix swaps the
985
+ # singular form (in `helper_prefix`) for the plural the immediate-resource frame stored both.
1136
986
  plural_prefix = context.helper_prefix.sub(/#{frame[:parent_singular]}_\z/, "#{frame[:parent_plural]}_")
1137
987
  name = "#{action_name}_#{plural_prefix}path"
1138
988
  arity = [context.parent_segment_count - 1, 0].max
@@ -1159,8 +1009,7 @@ module Rigor
1159
1009
  end
1160
1010
 
1161
1011
  def handle_member_or_collection(node, context)
1162
- # Only meaningful when we're inside a `resources` /
1163
- # `resource` block. The Context's stack tells us.
1012
+ # Only meaningful when we're inside a `resources` / `resource` block. The Context's stack tells us.
1164
1013
  resource = context.innermost_resource
1165
1014
  return interpret_block_body(node, context) if resource.nil?
1166
1015
 
@@ -1171,25 +1020,20 @@ module Rigor
1171
1020
  end
1172
1021
 
1173
1022
  def in_singular_resource?(*)
1174
- # Stub: always returns true so member / collection
1175
- # blocks descend. The singular-resource frame
1176
- # (`push_singular_resource`) is modelled in Context;
1177
- # a future caller could use it to tighten this check.
1023
+ # Stub: always returns true so member / collection blocks descend. The singular-resource frame
1024
+ # (`push_singular_resource`) is modelled in Context; a future caller could use it to tighten this
1025
+ # check.
1178
1026
  true
1179
1027
  end
1180
1028
 
1181
- # Generate the standard helpers for a resource(s).
1182
- # `plural: true` for `resources :users`, `false` for
1183
- # `resource :profile`.
1029
+ # Generate the standard helpers for a resource(s). `plural: true` for `resources :users`, `false`
1030
+ # for `resource :profile`.
1184
1031
  def register_resourceful_helpers(name, actions, base_arity, context, plural:)
1185
- # Plural resources (`resources :users`) singularise
1186
- # for show / new / edit helpers `user_path(id)`.
1187
- # Singular resources (`resource :foo`) use the name
1188
- # AS-IS singularising would mangle deliberately-
1189
- # plural names like Mastodon's `resource
1190
- # :relationships` → `relationships_path`, not
1191
- # `relationship_path`. The URL path uses `name`
1192
- # in both shapes (Rails never singularises the URL).
1032
+ # Plural resources (`resources :users`) singularise for show / new / edit helpers →
1033
+ # `user_path(id)`. Singular resources (`resource :foo`) use the name AS-IS — singularising would
1034
+ # mangle deliberately-plural names like Mastodon's `resource :relationships`
1035
+ # `relationships_path`, not `relationship_path`. The URL path uses `name` in both shapes (Rails
1036
+ # never singularises the URL).
1193
1037
  singular = plural ? singularize_word(name.to_s) : name.to_s
1194
1038
  path_base = "#{context.path_prefix}/#{name}"
1195
1039
 
@@ -1203,20 +1047,13 @@ module Rigor
1203
1047
  end
1204
1048
  end
1205
1049
 
1206
- # Maps an action keyword to the route-helper entry it
1207
- # produces. The five "named-helper" actions
1208
- # (`:index` / `:show` / `:new` / `:edit` plus
1209
- # singular-resource `:show`) generate a distinct
1210
- # helper; the three "verb-only" actions (`:create` /
1211
- # `:update` / `:destroy`) Rails serves under the same
1212
- # path-helper Rails reuses for show / index forms so
1213
- # we emit them too, otherwise an `only: [:create]`
1214
- # resource (e.g. Mastodon's `resource :inbox, only:
1215
- # [:create]`) registers NO helpers and downstream
1216
- # callers see a false `unknown-helper inbox_path`.
1217
- # The HelperTable already dedupes by name, so a
1218
- # resource that lists both `:show` and `:update` does
1219
- # not double-register.
1050
+ # Maps an action keyword to the route-helper entry it produces. The five "named-helper" actions
1051
+ # (`:index` / `:show` / `:new` / `:edit` plus singular-resource `:show`) generate a distinct
1052
+ # helper; the three "verb-only" actions (`:create` / `:update` / `:destroy`) Rails serves under the
1053
+ # same path-helper Rails reuses for show / index forms — so we emit them too, otherwise an `only:
1054
+ # [:create]` resource (e.g. Mastodon's `resource :inbox, only: [:create]`) registers NO helpers and
1055
+ # downstream callers see a false `unknown-helper inbox_path`. The HelperTable already dedupes by
1056
+ # name, so a resource that lists both `:show` and `:update` does not double-register.
1220
1057
  def entry_for_action(action, name:, singular:, base_arity:, path_base:, helper_prefix:, plural:)
1221
1058
  case action
1222
1059
  when :index then index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
@@ -1229,18 +1066,14 @@ module Rigor
1229
1066
  )
1230
1067
  when :edit then edit_entry(plural, helper_prefix, singular, base_arity, path_base)
1231
1068
  when :create
1232
- # Plural `resources` collection POST shares the
1233
- # index helper (`<name>_path` → collection URL).
1234
- # Singular `resource` POST shares the show helper
1235
- # (`<name>_path` → resource URL). Both shapes
1236
- # produce a `<prefix><name>_path` entry; only the
1237
- # arity / path differ.
1069
+ # Plural `resources` collection POST shares the index helper (`<name>_path` → collection URL).
1070
+ # Singular `resource` POST shares the show helper (`<name>_path` → resource URL). Both shapes
1071
+ # produce a `<prefix><name>_path` entry; only the arity / path differ.
1238
1072
  create_entry(plural, helper_prefix, name, singular, base_arity, path_base)
1239
1073
  when :update, :destroy
1240
- # Member PATCH / PUT / DELETE on plural resources
1241
- # share the show helper (`<prefix><singular>_path(id)`).
1242
- # Singular-resource PATCH / DELETE shares
1243
- # `<prefix><name>_path` (no `:id`).
1074
+ # Member PATCH / PUT / DELETE on plural resources share the show helper
1075
+ # (`<prefix><singular>_path(id)`). Singular-resource PATCH / DELETE shares `<prefix><name>_path`
1076
+ # (no `:id`).
1244
1077
  show_entry(plural, helper_prefix, singular, base_arity, path_base)
1245
1078
  end
1246
1079
  end
@@ -1264,27 +1097,18 @@ module Rigor
1264
1097
  def index_entry(plural, helper_prefix, name, base_arity, path_base, singular)
1265
1098
  return nil unless plural
1266
1099
 
1267
- # Rails appends `_index_path` to the index helper
1268
- # name when the singular form of the resource matches
1269
- # the plural form AND the noun isn't in the canonical
1270
- # UNCOUNTABLE list. The collision would otherwise put
1271
- # both the index (`:id`-less) and show (`:id`-bearing)
1272
- # helpers under the same name, and Rails disambiguates
1273
- # by suffixing the index form. Mastodon's
1274
- # `resources :reblogged_by, controller:
1275
- # :reblogged_by_accounts, only: :index` and similar
1276
- # rely on this — calls like
1277
- # `api_v1_status_reblogged_by_index_url(status.id)`
1278
- # would otherwise read as `unknown-helper`.
1100
+ # Rails appends `_index_path` to the index helper name when the singular form of the resource
1101
+ # matches the plural form AND the noun isn't in the canonical UNCOUNTABLE list. The collision
1102
+ # would otherwise put both the index (`:id`-less) and show (`:id`-bearing) helpers under the same
1103
+ # name, and Rails disambiguates by suffixing the index form. Mastodon's `resources
1104
+ # :reblogged_by, controller: :reblogged_by_accounts, only: :index` and similar rely on this —
1105
+ # calls like `api_v1_status_reblogged_by_index_url(status.id)` would otherwise read as
1106
+ # `unknown-helper`.
1279
1107
  #
1280
- # Rails adds `_index_` whenever `singular == plural`
1281
- # including for UNCOUNTABLE nouns (Redmine's
1282
- # `resources :news` registers `news_index_path` for
1283
- # the index). The earlier "skip UNCOUNTABLE" carve-out
1284
- # was empirically wrong; only the
1285
- # IRREGULAR-singular path (e.g. `media → medium`)
1286
- # actually has `singular != plural` and skips
1287
- # `_index_`.
1108
+ # Rails adds `_index_` whenever `singular == plural` — including for UNCOUNTABLE nouns (Redmine's
1109
+ # `resources :news` registers `news_index_path` for the index). The earlier "skip UNCOUNTABLE"
1110
+ # carve-out was empirically wrong; only the IRREGULAR-singular path (e.g. `media medium`)
1111
+ # actually has `singular != plural` and skips `_index_`.
1288
1112
  index_name = if name.to_s == singular
1289
1113
  "#{helper_prefix}#{name}_index_path"
1290
1114
  else
@@ -1322,9 +1146,8 @@ module Rigor
1322
1146
  end
1323
1147
 
1324
1148
  def restrict_actions_from(options, default)
1325
- # `resources :foo, only: :show` is the same as
1326
- # `only: [:show]` in Rails; `options_hash` preserves the
1327
- # Symbol shape from the source, so coerce here.
1149
+ # `resources :foo, only: :show` is the same as `only: [:show]` in Rails; `options_hash` preserves
1150
+ # the Symbol shape from the source, so coerce here.
1328
1151
  if (only = options[:only])
1329
1152
  Array(only) & default
1330
1153
  elsif (except = options[:except])
@@ -1362,21 +1185,17 @@ module Rigor
1362
1185
  options_hash(node)[key]
1363
1186
  end
1364
1187
 
1365
- # Reads a keyword option whose value is a literal String
1366
- # (returned as-is). Returns nil when the key is missing
1367
- # or the value is non-literal. Used for `scope(path:
1368
- # ':project_id', ...)` shape parsing where `:path` is
1369
- # passed as a keyword rather than a positional arg.
1188
+ # Reads a keyword option whose value is a literal String (returned as-is). Returns nil when the key
1189
+ # is missing or the value is non-literal. Used for `scope(path: ':project_id', ...)` shape parsing
1190
+ # where `:path` is passed as a keyword rather than a positional arg.
1370
1191
  def keyword_value_string(node, key)
1371
1192
  value = options_hash(node)[key]
1372
1193
  value.is_a?(String) ? value : nil
1373
1194
  end
1374
1195
 
1375
- # `get 'help/*path' => 'help#show', as: :help_page` —
1376
- # the path lives in a String-keyed AssocNode of the
1377
- # trailing keyword/options hash (its value is the
1378
- # `controller#action` String). Returns the path String,
1379
- # or nil when no such hashrocket-key entry exists.
1196
+ # `get 'help/*path' => 'help#show', as: :help_page` — the path lives in a String-keyed AssocNode of
1197
+ # the trailing keyword/options hash (its value is the `controller#action` String). Returns the
1198
+ # path String, or nil when no such hashrocket-key entry exists.
1380
1199
  def hashrocket_path_key(node)
1381
1200
  args = node.arguments&.arguments || []
1382
1201
  last = args.last
@@ -1409,29 +1228,24 @@ module Rigor
1409
1228
  def count_path_placeholders(path)
1410
1229
  return 0 if path.nil?
1411
1230
 
1412
- # Both `:name` (regular segment) and `*name` (wildcard
1413
- # / "globbing" segment) are Rails path parameters and
1414
- # contribute to helper arity. GitLab's
1415
- # `path: '*namespace_id'` outer scope adds 1 to the
1416
- # arity of every helper underneath.
1231
+ # Both `:name` (regular segment) and `*name` (wildcard / "globbing" segment) are Rails path
1232
+ # parameters and contribute to helper arity. GitLab's `path: '*namespace_id'` outer scope adds 1
1233
+ # to the arity of every helper underneath.
1417
1234
  path.scan(/[:*][a-z_][a-z0-9_]*/).size
1418
1235
  end
1419
1236
 
1420
- # Number of placeholders inside `(...)` groups — Rails'
1421
- # optional-segment syntax (`'settings(/:tab)'`). Required
1422
- # arity is `count_path_placeholders - count_optional`;
1423
- # callers accepting a `(required..required+optional)`
1424
- # arity range register an entry per achievable arity.
1237
+ # Number of placeholders inside `(...)` groups — Rails' optional-segment syntax
1238
+ # (`'settings(/:tab)'`). Required arity is `count_path_placeholders - count_optional`; callers
1239
+ # accepting a `(required..required+optional)` arity range register an entry per achievable arity.
1425
1240
  def count_optional_path_placeholders(path)
1426
1241
  return 0 if path.nil?
1427
1242
 
1428
1243
  path.scan(/\([^)]*\)/).sum { |group| group.scan(/[:*][a-z_][a-z0-9_]*/).size }
1429
1244
  end
1430
1245
 
1431
- # ADR-39 — delegates to the shared inflector (real
1432
- # `ActiveSupport::Inflector` when available). Previously a
1433
- # hand-tuned AS-replica kept in sync with `Context#singularize`;
1434
- # both now share the one authoritative implementation.
1246
+ # ADR-39 — delegates to the shared inflector (real `ActiveSupport::Inflector` when available).
1247
+ # Previously a hand-tuned AS-replica kept in sync with `Context#singularize`; both now share the
1248
+ # one authoritative implementation.
1435
1249
  def singularize_word(word)
1436
1250
  Rigor::Plugin::Inflector.singularize(word)
1437
1251
  end