rigortype 0.4.7 → 0.4.8

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 (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.4.7"
4
+ VERSION = "0.4.8"
5
5
  end
@@ -22,7 +22,7 @@ module Rigor
22
22
  # `last!`, `new`, `create`, `create!`. A seeded type is a claim the engine acts on, and a wrong one
23
23
  # is worse than none: seeding `@user` from `find_by` (which returns nil at runtime) would hand the
24
24
  # flow rules a non-nil nominal and license folds that draw diagnostics on correct templates — the
25
- # trap `Actionpack::STRONG_PARAMS_CHAIN_METHODS` documents at length for `Parameters#[]`. Anything
25
+ # trap `Actionpack::PARAMS_ONLY_CHAIN_METHODS` documents at length for `Parameters#[]`. Anything
26
26
  # else assigns nothing, the ivar stays unseeded, and the template reads it as `Dynamic`, which
27
27
  # taints honestly (ADR-5). A call on that list that returns, or may return, SEVERAL records —
28
28
  # `find(a, b)`, `find([1, 2])`, `create([{…}, {…}])` — is excluded by the same rule; see
@@ -228,7 +228,7 @@ module Rigor
228
228
  # `case`, a `rescue`, a loop, or a block that may not run — does not contribute, because a seed
229
229
  # is a claim about what the template will FIND, and `@user = User.find(1) if params[:pick]`
230
230
  # leaves `@user` nil on the other path. A non-nil nominal standing in for a runtime nil is the
231
- # `Parameters#[]` trap (`Actionpack::STRONG_PARAMS_CHAIN_METHODS`): the flow rules act on it and
231
+ # `Parameters#[]` trap (`Actionpack::PARAMS_ONLY_CHAIN_METHODS`): the flow rules act on it and
232
232
  # report live branches, which is exactly what a reader of a template would see with
233
233
  # `view_type_checks:` on. The unseeded ivar reads `Dynamic` instead and taints honestly (ADR-5).
234
234
  #
@@ -473,21 +473,21 @@ module Rigor
473
473
  # `ActionController::Parameters` (above), but the chained `params.require(:user).permit(:name)` /
474
474
  # `params.permit(...)` calls returned `Dynamic` at the first hop (Parameters ships no bundled RBS,
475
475
  # so a call on it resolves lenient-to-Dynamic), leaking every downstream site (`.permit`, `.to_h`,
476
- # `.each`) to unprotected. These methods return another `Parameters`, so gate on a
477
- # `Parameters` receiver and re-type the result as the same lenient nominal — the chain stays a
478
- # concrete receiver end-to-end and `coverage --protection` counts the sites as protected.
479
- #
480
- # `require` may return a scalar for a flat key at runtime (`params.require(:id) → String`), but
481
- # typing it as the RBS-less `Parameters` is FP-safe by the same argument as the readers: every
482
- # method on a Parameters value stays engine-lenient (no `undefined-method`), and this types the
483
- # container only, never a caller's argument (ADR-5). The GitLab strong-params survey (108 leaked
484
- # `.permit` sites) is the demand.
485
- # Issue #534 — `expect` (Rails 8's require+permit) and `slice` joined 2026-09-01, from the corpus
486
- # sweep's pair counts (63 and 38 sites on mastodon). Both share `require`'s safety shape: they
487
- # never return nil (`expect` raises like `require`; `slice` always returns a Parameters), so the
488
- # non-nil lenient nominal cannot fold a flow rule wrong.
489
- #
490
- # ## The admission rule for this table
476
+ # `.each`) to unprotected. The methods that return another `Parameters` (`PARAMS_ONLY_CHAIN_METHODS`)
477
+ # gate on a `Parameters` receiver and re-type the result as the same
478
+ # lenient nominal — the sites after them stay concrete receivers and `coverage --protection` counts
479
+ # them as protected. `require` / `expect` are the exception below: the receiver of the call right
480
+ # after one is the carrier, which is not a concrete receiver.
481
+ #
482
+ # `require` and `expect` may return a scalar at runtime (`params.require(:id) → String`), so the
483
+ # earlier "RBS-less nominal is FP-safe" reading of them (2026-07, #534) is withdrawn by #2947: it held
484
+ # only while Parameters shipped no RBS. Once the collection's declaration is loaded, a nominal answer
485
+ # reports a scalar's method as `call.undefined-method`. They answer the gradual carrier
486
+ # `Dynamic[Parameters]` (`SCALAR_CAPABLE_CHAIN_METHODS`), which keeps the chain resolvable and is quiet
487
+ # with or without the RBS. The GitLab strong-params survey (108 leaked `.permit` sites) is the demand.
488
+ # Issue #534 — `expect` and `slice` joined 2026-09-01 (63 and 38 sites on mastodon).
489
+ #
490
+ # ## The admission rule for the nominal table (`PARAMS_ONLY_CHAIN_METHODS`)
491
491
  #
492
492
  # A method belongs here **iff its Rails implementation returns an `ActionController::Parameters`
493
493
  # on every path** — either a fresh `new_instance_with_inherited_permitted_status(...)` or `self`.
@@ -564,27 +564,56 @@ module Rigor
564
564
  # hazard ever returns — and they now carry a positive assertion that `params[:k]` types
565
565
  # `Dynamic[ActionController::Parameters?]` (#2430), so a silent regression to bare `Dynamic[top]`
566
566
  # or to the non-nil nominal fails them too.
567
- STRONG_PARAMS_CHAIN_METHODS = %i[
568
- require permit permit! expect slice
567
+ # The links that return an `ActionController::Parameters` on every path (checked against Rails 8.x
568
+ # `strong_parameters.rb`). Only these may be typed as the nominal.
569
+ PARAMS_ONLY_CHAIN_METHODS = %i[
570
+ permit permit! slice
569
571
  except without extract! slice!
570
572
  merge merge! reverse_merge reverse_merge! with_defaults with_defaults!
571
573
  compact compact_blank deep_dup
572
574
  ].freeze
573
575
 
574
- dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, _scope|
576
+ # Issue #2947 — `require` and `expect` are NOT in the nominal table above. Rails returns the value itself:
577
+ # `require` is `self[key]` (a scalar, or an Array for an Array of keys) and `expect` is `values.first`
578
+ # for one key, else an Array. The "RBS-less nominal is lenient" argument holds only while Parameters
579
+ # ships no RBS; with the collection's declaration loaded, a nominal answer reports `.to_sym` on a
580
+ # scalar as `call.undefined-method`. They answer the gradual carrier `Dynamic[Parameters]` instead:
581
+ # quiet at every boundary, and the chain keeps resolving through the carrier-gated rule.
582
+ SCALAR_CAPABLE_CHAIN_METHODS = %i[require expect].freeze
583
+
584
+ STRONG_PARAMS_CHAIN_METHODS = (PARAMS_ONLY_CHAIN_METHODS + SCALAR_CAPABLE_CHAIN_METHODS).freeze
585
+
586
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: PARAMS_ONLY_CHAIN_METHODS do |call_node, _scope|
575
587
  next nil unless call_node.is_a?(Prism::CallNode)
576
588
 
577
589
  Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
578
590
  end
579
591
 
580
- # Issue #2430 — the chain on a `params[...]` carrier (`params[:user].permit(:name)`). The result stays
581
- # a `Dynamic` of the same nominal, so it crosses a declared boundary as the carrier does.
592
+ dynamic_return receivers: [REQUEST_CONTEXT_READER_TYPES[:params]], methods: SCALAR_CAPABLE_CHAIN_METHODS do |call_node, _scope|
593
+ next nil unless call_node.is_a?(Prism::CallNode)
594
+
595
+ Rigor::Type::Combinator.dynamic(Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params]))
596
+ end
597
+
598
+ # Issue #2430 / #2947 — the chain on a carrier receiver (`params[:user].permit(:name)`,
599
+ # `params.require(:user).permit(:name)`). A carrier often holds an Array or a String at runtime, and
600
+ # those define many of the chain names with other returns (`compact`, `slice`, `without`, `deep_dup`,
601
+ # `extract!`, ...), so only `permit` / `permit!` — which no core or ActiveSupport class defines — can
602
+ # answer the plain nominal there: such a call raises NoMethodError or returns a real Parameters, and
603
+ # the plain nominal keeps the sites after it concrete receivers for `coverage --protection`. Every
604
+ # other chain method stays the gradual carrier.
605
+ CARRIER_NOMINAL_CHAIN_METHODS = %i[permit permit!].freeze
606
+
582
607
  dynamic_return methods: STRONG_PARAMS_CHAIN_METHODS do |call_node, scope|
583
- next nil unless call_node.is_a?(Prism::CallNode) && call_node.receiver
608
+ next nil unless call_node.is_a?(Prism::CallNode)
609
+
610
+ receiver = call_node.receiver
611
+ next nil unless receiver
584
612
  next nil unless controller_scope?(scope)
585
- next nil unless params_carrier_facet?(scope.type_of(call_node.receiver))
613
+ next nil unless params_carrier_facet?(scope.type_of(receiver))
586
614
 
587
- Rigor::Type::Combinator.dynamic(Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params]))
615
+ nominal = Rigor::Type::Combinator.nominal_of(REQUEST_CONTEXT_READER_TYPES[:params])
616
+ CARRIER_NOMINAL_CHAIN_METHODS.include?(call_node.name) ? nominal : Rigor::Type::Combinator.dynamic(nominal)
588
617
  end
589
618
 
590
619
  # Issue #534 item 1, reopened by #574. `#[]` is the single largest named-receiver pair on both survey
@@ -620,9 +649,12 @@ module Rigor
620
649
  # receiver. These two rules gate on the carrier's facet instead, and only inside a controller: a
621
650
  # carrier is minted by the `params` reader, which is controller-only, so no other scope can hold one.
622
651
  dynamic_return methods: %i[[]] do |call_node, scope|
623
- next nil unless call_node.is_a?(Prism::CallNode) && call_node.receiver
652
+ next nil unless call_node.is_a?(Prism::CallNode)
653
+
654
+ receiver = call_node.receiver
655
+ next nil unless receiver
624
656
  next nil unless controller_scope?(scope)
625
- next nil unless params_carrier_facet?(scope.type_of(call_node.receiver))
657
+ next nil unless params_carrier_facet?(scope.type_of(receiver))
626
658
 
627
659
  params_read_type
628
660
  end
@@ -161,9 +161,8 @@ module Rigor
161
161
  end
162
162
 
163
163
  def project_defines_object?(serializer_name, scope)
164
- return false unless scope.respond_to?(:user_def_through_ancestors)
165
-
166
- !scope.user_def_through_ancestors(serializer_name, :object).first.nil?
164
+ Rigor::Reflection.project_method_defined(serializer_name, :object, kind: :instance, source: :def,
165
+ scope: scope).yes?
167
166
  end
168
167
 
169
168
  # The model a serializer serializes, or nil where nothing corroborates it. Memoised per serializer
@@ -237,13 +236,11 @@ module Rigor
237
236
  # the check would decline nearly every real serializer. On the serializer side it is a base
238
237
  # serializer's own rendering method, which is not a read of the resource at all.
239
238
  def project_defines?(class_name, method_name, scope)
240
- return false unless scope.respond_to?(:user_def_through_ancestors)
241
-
242
239
  # A name no Symbol can hold cannot be a method the project defines (#1831): decline, do not raise.
243
240
  method = Rigor::Source::SymbolName.of(method_name.to_s)
244
241
  return false if method.nil?
245
242
 
246
- !scope.user_def_through_ancestors(class_name, method).first.nil?
243
+ Rigor::Reflection.project_method_defined(class_name, method, kind: :instance, source: :def, scope: scope).yes?
247
244
  end
248
245
 
249
246
  # `REST::AccountSerializer` offers two readings — the namespaced `REST::Account` and the
@@ -55,8 +55,24 @@ module Rigor
55
55
  )
56
56
  end
57
57
 
58
+ # Ruby binds a trailing `k: v` / `**opts` group to keyword parameters, never to positional ones, so
59
+ # when `#perform` takes keywords the group is not counted (the message's "got N" is then the
60
+ # positional count too). A job without keyword parameters keeps counting the group as one positional
61
+ # argument — `NoArgsJob.perform_later(name: 1)` raises at runtime. A `Hash` literal / local stays
62
+ # positional either way (Ruby 3 does not convert it).
63
+ #
64
+ # The call is declined (no report) when its shape is unknown: a `*splat` or `...` argument may supply
65
+ # any number of positionals (or a trailing keyword group), and a keyword group made only of `**opts`
66
+ # arguments sent to a job without keywords may be empty — declined only when the verdict differs
67
+ # between "empty" and "one positional hash" (`NoArgsJob.perform_later(**opts)`).
58
68
  def arity_violation(call_node, entry)
59
- actual = (call_node.arguments&.arguments || []).size
69
+ args = call_node.arguments&.arguments || []
70
+ return nil if args.any? { |arg| arg.is_a?(Prism::SplatNode) || arg.is_a?(Prism::ForwardingArgumentsNode) }
71
+
72
+ keyword_group = args.last.is_a?(Prism::KeywordHashNode) ? args.last : nil
73
+ return nil if keyword_group && !entry.accepts_keywords && empty_group_decides?(args, keyword_group, entry)
74
+
75
+ actual = entry.accepts_keywords && keyword_group ? args.size - 1 : args.size
60
76
  return nil if entry.accepts?(actual)
61
77
 
62
78
  Violation.new(
@@ -66,6 +82,14 @@ module Rigor
66
82
  )
67
83
  end
68
84
 
85
+ # True when the group is only `**splat` arguments (so it may be empty) AND the entry's verdict changes
86
+ # between counting it and not counting it.
87
+ def empty_group_decides?(args, keyword_group, entry)
88
+ return false unless keyword_group.elements.all?(Prism::AssocSplatNode)
89
+
90
+ entry.accepts?(args.size - 1) != entry.accepts?(args.size)
91
+ end
92
+
69
93
  # Renders a constant-path receiver as a String. Mirrors the helpers in rigor-activerecord /
70
94
  # rigor-rails-routes for parity.
71
95
  def constant_receiver_name(node)
@@ -76,7 +76,8 @@ module Rigor
76
76
  base.with(
77
77
  min_arity: [base.min_arity, addition.min_arity].min,
78
78
  max_arity: [base.max_arity, addition.max_arity].max,
79
- keyword_required: base.keyword_required & addition.keyword_required
79
+ keyword_required: base.keyword_required & addition.keyword_required,
80
+ accepts_keywords: base.accepts_keywords || addition.accepts_keywords
80
81
  )
81
82
  end
82
83
 
@@ -187,7 +188,7 @@ module Rigor
187
188
  if perform_def.nil?
188
189
  return JobIndex::Entry.new(
189
190
  class_name: class_name, min_arity: 0,
190
- max_arity: Float::INFINITY, keyword_required: []
191
+ max_arity: Float::INFINITY, keyword_required: [], accepts_keywords: true
191
192
  )
192
193
  end
193
194
 
@@ -195,22 +196,31 @@ module Rigor
195
196
  if parameters.nil?
196
197
  return JobIndex::Entry.new(
197
198
  class_name: class_name, min_arity: 0,
198
- max_arity: 0, keyword_required: []
199
+ max_arity: 0, keyword_required: [], accepts_keywords: false
199
200
  )
200
201
  end
201
202
 
202
- required_count = (parameters.requireds || []).size
203
+ # Parameters after an optional or rest one (`def perform(a, b = 1, c)`) are required too.
204
+ required_count = (parameters.requireds || []).size + (parameters.posts || []).size
203
205
  optional_count = (parameters.optionals || []).size
204
206
  rest_present = !parameters.rest.nil?
205
207
  keyword_required = (parameters.keywords || []).filter_map do |kw|
206
208
  kw.name if kw.is_a?(Prism::RequiredKeywordParameterNode)
207
209
  end
208
210
 
211
+ keyword_rest = parameters.keyword_rest
212
+ # `...` lives in `keyword_rest` and forwards every argument, so it widens the envelope; `**nil`
213
+ # also lives there but declares that NO keywords are taken (ActiveJob then hands them over as a
214
+ # positional hash, exactly like a method with no keyword parameters).
215
+ forwards_all = keyword_rest.is_a?(Prism::ForwardingParameterNode)
216
+ takes_keywords = !(parameters.keywords || []).empty? || keyword_rest.is_a?(Prism::KeywordRestParameterNode)
217
+
209
218
  JobIndex::Entry.new(
210
219
  class_name: class_name,
211
220
  min_arity: required_count,
212
- max_arity: rest_present ? Float::INFINITY : required_count + optional_count,
213
- keyword_required: keyword_required.map(&:to_sym)
221
+ max_arity: rest_present || forwards_all ? Float::INFINITY : required_count + optional_count,
222
+ keyword_required: keyword_required.map(&:to_sym),
223
+ accepts_keywords: takes_keywords || forwards_all
214
224
  )
215
225
  end
216
226
  end
@@ -10,9 +10,11 @@ module Rigor
10
10
  # `min_arity` / `max_arity` form a closed range (`Float::INFINITY` for the upper bound when
11
11
  # `*args` is present). `keyword_required` lists any required keyword arguments — Active Job
12
12
  # supports keyword args but they're rare in user code, so the analyzer validates positional arity
13
- # only (keyword arity validation is deferred).
13
+ # only (keyword name validation is deferred). `accepts_keywords` records whether `#perform` binds
14
+ # keywords at all (`k:`, `k: 1` or `**opts`), so the analyzer can keep a call's keyword arguments out
15
+ # of the positional count.
14
16
  class JobIndex
15
- Entry = Data.define(:class_name, :min_arity, :max_arity, :keyword_required) do
17
+ Entry = Data.define(:class_name, :min_arity, :max_arity, :keyword_required, :accepts_keywords) do
16
18
  # Flexible-friendly textual form of the arity for error messages: `1`, `1..2`, `2+`.
17
19
  def arity_label
18
20
  return "#{min_arity}+" if max_arity == Float::INFINITY
@@ -110,7 +110,8 @@ module Rigor
110
110
  # The predicate `Activerecord#finder_return_type` declines on, so the note and the type agree in a
111
111
  # CLI run. In an editor run this scope lacks the file's own methods (#1329).
112
112
  def own_find?(entry)
113
- @scope.discovered_method_through_ancestors?(entry.class_name, :find, :singleton)
113
+ !Rigor::Reflection.project_method_defined(entry.class_name, :find, kind: :singleton, source: :discovered,
114
+ scope: @scope).no?
114
115
  end
115
116
 
116
117
  def validate_column_hash_call(node, entry)
@@ -31,10 +31,17 @@ module Rigor
31
31
  # `Object` per `SchemaTable.ruby_type_for` rather than being dropped. Only the structural calls in
32
32
  # {NON_COLUMN_METHODS} (indexes, constraints, foreign keys) are skipped.
33
33
  #
34
+ # `create_table "x", primary_key: "name"` adds that column, typed from the `id:` option when the call is
35
+ # read completely and untyped otherwise. `record.id` reads the primary key column's value, so the `id`
36
+ # column takes the same type; a composite or unreadable `primary_key:` leaves it untyped. `id: false` or
37
+ # `id: nil` removes it and adds neither.
38
+ #
34
39
  # Designed for the Prism interpretation pattern from rigor-lisp-eval — recursive descent on the AST,
35
40
  # no eval.
36
41
  class SchemaParser
37
42
  TIMESTAMPS_COLUMNS = %w[created_at updated_at].freeze
43
+ DEFAULT_PRIMARY_KEY_TYPE = :bigint
44
+ UNKNOWN_PRIMARY_KEY_TYPE = :unknown
38
45
 
39
46
  # Structural `t.<method>` calls inside a `create_table` block that declare indexes / constraints
40
47
  # rather than columns. Everything NOT in this set that carries a literal column name is treated as
@@ -75,8 +82,10 @@ module Rigor
75
82
  return [nil, nil] if table_name.nil?
76
83
 
77
84
  block_node = call_node.block
78
- columns = { "id" => SchemaTable::Column.new(name: "id", type: :integer, ruby_type: "Integer") }
85
+ columns = { "id" => implicit_id_column(call_node) }
79
86
  columns.delete("id") if id_disabled?(call_node)
87
+ primary_key = named_primary_key_column(call_node)
88
+ columns[primary_key.name] = primary_key if primary_key
80
89
 
81
90
  if block_node.is_a?(Prism::BlockNode) && block_node.body
82
91
  collect_column_calls(block_node.body) do |column_call|
@@ -92,20 +101,129 @@ module Rigor
92
101
  [table_name, columns.freeze]
93
102
  end
94
103
 
104
+ # `record.id` reads the primary key column's value, so the `id` reader takes the key's type — the
105
+ # same shape the `id:` option gives the column `primary_key:` names or the implicit `id` column.
106
+ # A composite `primary_key:` makes `id` an Array this parser does not type, and a `primary_key:`
107
+ # it cannot read leaves `id` untyped as well.
108
+ def implicit_id_column(call_node)
109
+ unless single_primary_key?(call_node)
110
+ return SchemaTable::Column.new(name: "id", type: UNKNOWN_PRIMARY_KEY_TYPE, ruby_type: "Object")
111
+ end
112
+
113
+ SchemaTable::Column.new(name: "id", **primary_key_shape(call_node))
114
+ end
115
+
116
+ # `primary_key:` names one key column when it is a String or Symbol literal; `nil` or `false` falls
117
+ # back to the default `id` column the way `primary_key || ...` does in Rails.
118
+ def single_primary_key?(call_node)
119
+ primary_key = keyword_value(call_node, :primary_key)
120
+ primary_key.nil? || primary_key.is_a?(Prism::NilNode) || primary_key.is_a?(Prism::FalseNode) ||
121
+ !literal_name(primary_key).nil?
122
+ end
123
+
95
124
  def id_disabled?(call_node)
96
- return false if call_node.arguments.nil?
125
+ id = keyword_value(call_node, :id)
126
+ (id.is_a?(Prism::FalseNode) || id.is_a?(Prism::NilNode)) && keywords_read?(call_keywords(call_node))
127
+ end
128
+
129
+ # `create_table "settings", primary_key: "name", id: { type: :string }` creates the column `name`
130
+ # without the block declaring it, so the option is the only place the schema names it.
131
+ def named_primary_key_column(call_node)
132
+ return nil if id_disabled?(call_node)
133
+
134
+ name = literal_name(keyword_value(call_node, :primary_key))
135
+ return nil if name.nil?
136
+
137
+ SchemaTable::Column.new(name: name, **primary_key_shape(call_node))
138
+ end
139
+
140
+ # The `id:` option describes the primary key column's type whether the key column is `id` itself or
141
+ # the column `primary_key:` names. An unreadable call still yields a shape — the column stays
142
+ # queryable, just untyped.
143
+ def primary_key_shape(call_node)
144
+ id = keyword_value(call_node, :id)
145
+ options = id.is_a?(Prism::HashNode) ? id.elements : []
146
+ array = keyword_in(options, :array).is_a?(Prism::TrueNode)
147
+ unless primary_key_type_stated?(call_node, options)
148
+ return { type: UNKNOWN_PRIMARY_KEY_TYPE, ruby_type: "Object", array: array }
149
+ end
150
+
151
+ type = primary_key_type(id, options)
152
+ { type: type, ruby_type: primary_key_ruby_type(type), array: array }
153
+ end
154
+
155
+ # `id: :string` or `id: { type: :string, ... }`. `id: true` asks for the default key, as does an
156
+ # `id:` hash that names no `type:`. A type this parser cannot read as a literal is left unknown
157
+ # rather than assumed to be bigint.
158
+ def primary_key_type(id, options)
159
+ return DEFAULT_PRIMARY_KEY_TYPE if id.is_a?(Prism::TrueNode)
160
+
161
+ declared = id.is_a?(Prism::HashNode) ? keyword_in(options, :type) : id
162
+ return DEFAULT_PRIMARY_KEY_TYPE if declared.nil?
163
+
164
+ literal_name(declared)&.to_sym || UNKNOWN_PRIMARY_KEY_TYPE
165
+ end
166
+
167
+ # The key is typed only from a call this parser reads completely. `as:` is excluded because the query
168
+ # may define the column, and a non-literal `array:` because it decides the reader's shape.
169
+ def primary_key_type_stated?(call_node, options)
170
+ array = keyword_in(options, :array)
171
+ keywords_read?(call_keywords(call_node)) && keywords_read?(options) &&
172
+ keyword_value(call_node, :as).nil? &&
173
+ (array.nil? || array.is_a?(Prism::TrueNode) || array.is_a?(Prism::FalseNode))
174
+ end
175
+
176
+ def call_keywords(call_node)
177
+ call_node.arguments.arguments.grep(Prism::KeywordHashNode).flat_map(&:elements)
178
+ end
179
+
180
+ # Every element has a static Symbol key and no key repeats, so `keyword_in` sees what Ruby would pass.
181
+ def keywords_read?(elements)
182
+ keys = elements.map { |element| symbol_key(element.key) if element.is_a?(Prism::AssocNode) }
183
+ keys.none?(&:nil?) && keys.uniq.size == keys.size
184
+ end
185
+
186
+ # A decimal key stays untyped, because its class depends on the scale: `Type::DecimalWithoutScale`
187
+ # at scale 0, `Type::Decimal` otherwise.
188
+ def primary_key_ruby_type(type)
189
+ type == :decimal ? "Object" : SchemaTable.ruby_type_for(type)
190
+ end
191
+
192
+ # A `decimal` column's class follows its literal `scale:`. Any other type takes its SchemaTable mapping.
193
+ # A call this parser does not read completely may still hold a different scale, so it reads untyped.
194
+ def column_ruby_type(type, call_node)
195
+ return SchemaTable.ruby_type_for(type) unless type == :decimal
196
+ return "Object" unless keywords_read?(call_keywords(call_node))
197
+
198
+ scale = keyword_value(call_node, :scale)
199
+ SchemaTable.decimal_ruby_type(scale.is_a?(Prism::IntegerNode) ? scale.value : nil)
200
+ end
201
+
202
+ def literal_name(node)
203
+ case node
204
+ when Prism::StringNode, Prism::SymbolNode then node.unescaped
205
+ end
206
+ end
207
+
208
+ def keyword_value(call_node, name)
209
+ return nil if call_node.arguments.nil?
97
210
 
98
211
  call_node.arguments.arguments.each do |arg|
99
212
  next unless arg.is_a?(Prism::KeywordHashNode)
100
213
 
101
- arg.elements.each do |pair|
102
- next unless pair.is_a?(Prism::AssocNode)
214
+ value = keyword_in(arg.elements, name)
215
+ return value if value
216
+ end
217
+ nil
218
+ end
103
219
 
104
- key = symbol_key(pair.key)
105
- return true if key == :id && pair.value.is_a?(Prism::FalseNode)
106
- end
220
+ def keyword_in(elements, name)
221
+ elements.each do |pair|
222
+ next unless pair.is_a?(Prism::AssocNode)
223
+
224
+ return pair.value if symbol_key(pair.key) == name
107
225
  end
108
- false
226
+ nil
109
227
  end
110
228
 
111
229
  # Walks the block body collecting `t.<method>(...)` calls. It descends through nested constructs
@@ -147,7 +265,7 @@ module Rigor
147
265
  SchemaTable::Column.new(
148
266
  name: name,
149
267
  type: type,
150
- ruby_type: SchemaTable.ruby_type_for(type),
268
+ ruby_type: column_ruby_type(type, call_node),
151
269
  array: keyword_true?(call_node, :array)
152
270
  )
153
271
  end
@@ -176,19 +294,7 @@ module Rigor
176
294
  # modifiers like `t.bigint "status_ids", array: true` (Postgres array column) and `t.references
177
295
  # "x", polymorphic: true`.
178
296
  def keyword_true?(call_node, name)
179
- return false if call_node.arguments.nil?
180
-
181
- call_node.arguments.arguments.each do |arg|
182
- next unless arg.is_a?(Prism::KeywordHashNode)
183
-
184
- arg.elements.each do |pair|
185
- next unless pair.is_a?(Prism::AssocNode)
186
- next unless symbol_key(pair.key) == name
187
-
188
- return pair.value.is_a?(Prism::TrueNode)
189
- end
190
- end
191
- false
297
+ keyword_value(call_node, name).is_a?(Prism::TrueNode)
192
298
  end
193
299
 
194
300
  # `t.column "name", "type"` / `t.column "name", :type` — the explicit generic column form. The
@@ -202,7 +308,7 @@ module Rigor
202
308
  SchemaTable::Column.new(
203
309
  name: name,
204
310
  type: type_sym,
205
- ruby_type: SchemaTable.ruby_type_for(type_sym),
311
+ ruby_type: column_ruby_type(type_sym, call_node),
206
312
  array: keyword_true?(call_node, :array)
207
313
  )
208
314
  end
@@ -216,13 +322,7 @@ module Rigor
216
322
  def string_argument(call_node, index)
217
323
  return nil if call_node.arguments.nil?
218
324
 
219
- arg = call_node.arguments.arguments[index]
220
- return nil if arg.nil?
221
-
222
- case arg
223
- when Prism::StringNode then arg.unescaped
224
- when Prism::SymbolNode then arg.unescaped
225
- end
325
+ literal_name(call_node.arguments.arguments[index])
226
326
  end
227
327
 
228
328
  def symbol_key(node)
@@ -9,9 +9,10 @@ module Rigor
9
9
  #
10
10
  # The mapping from Rails column types to Ruby class names is deliberately conservative —
11
11
  # `:string`/`:text` → `String`, `:integer`/`:bigint` → `Integer`, `:boolean` → `bool`,
12
- # `:datetime`/`:timestamp` → `Time`, `:date` → `Date`, `:decimal`/`:float` → `Float`. Exotic types
13
- # (json, jsonb, ltree, hstore, custom) fall back to `Object` so the plugin stays silent rather than
14
- # guessing.
12
+ # `:datetime`/`:timestamp` → `Time`, `:date` → `Date`, `:float` → `Float`. A `:decimal` column's class
13
+ # follows its scale, so it is typed by {.decimal_ruby_type} from the parser that reads the scale, and
14
+ # reads `Object` here. Exotic types (json, jsonb, ltree, hstore, custom) fall back to `Object` so the
15
+ # plugin stays silent rather than guessing.
15
16
  class SchemaTable
16
17
  Column = Struct.new(:name, :type, :ruby_type, :array, keyword_init: true) do
17
18
  def to_h = { name: name, type: type, ruby_type: ruby_type, array: array }
@@ -26,8 +27,11 @@ module Rigor
26
27
  text: "String",
27
28
  integer: "Integer",
28
29
  bigint: "Integer",
30
+ # PostgreSQL's dumper writes `id: :serial` / `t.serial` for a sequence-backed integer column.
31
+ serial: "Integer",
32
+ bigserial: "Integer",
29
33
  float: "Float",
30
- decimal: "Float",
34
+ decimal: "Object",
31
35
  boolean: "bool",
32
36
  datetime: "Time",
33
37
  timestamp: "Time",
@@ -45,7 +49,7 @@ module Rigor
45
49
  }.freeze
46
50
 
47
51
  # Implicit columns that every Rails table has unless the schema explicitly opts out. The plugin
48
- # assumes these exist; users who run `create_table id: false` get no implicit `id` column from
52
+ # assumes these exist; users who run `create_table id: false` or `id: nil` get no implicit `id` column from
49
53
  # the parser, but most apps never disable it.
50
54
  IMPLICIT_COLUMNS = [
51
55
  Column.new(name: "id", type: :integer, ruby_type: "Integer").freeze
@@ -83,6 +87,17 @@ module Rigor
83
87
  def self.ruby_type_for(column_type)
84
88
  RUBY_TYPE_MAPPING.fetch(column_type.to_sym, "Object")
85
89
  end
90
+
91
+ # Maps a `decimal` column's scale to its Ruby class. Rails registers `Type::DecimalWithoutScale`
92
+ # (an Integer) at scale 0 and `Type::Decimal` (a BigDecimal) at any other scale. An absent or
93
+ # non-literal scale (`nil`) is left to the adapter, which decides it, so it reads as `Object`.
94
+ def self.decimal_ruby_type(scale)
95
+ case scale
96
+ when 0 then "Integer"
97
+ when Integer then "BigDecimal"
98
+ else "Object"
99
+ end
100
+ end
86
101
  end
87
102
  end
88
103
  end
@@ -138,21 +138,23 @@ module Rigor
138
138
  end
139
139
 
140
140
  def build_column(name, rest)
141
- type_str, array = extract_type(rest)
141
+ type_str, array, scale = extract_type(rest)
142
142
  return nil if type_str.empty?
143
143
 
144
144
  rails_type = PG_TYPE_TO_RAILS.fetch(type_str, type_str.to_sym)
145
+ ruby_type = rails_type == :decimal ? SchemaTable.decimal_ruby_type(scale) : SchemaTable.ruby_type_for(rails_type)
145
146
  SchemaTable::Column.new(
146
147
  name: name,
147
148
  type: rails_type,
148
- ruby_type: SchemaTable.ruby_type_for(rails_type),
149
+ ruby_type: ruby_type,
149
150
  array: array
150
151
  )
151
152
  end
152
153
 
153
154
  # The type is every token before the first uppercase-initial token (a column modifier such as
154
155
  # `NOT`, `NULL`, `DEFAULT`, `GENERATED`, `COLLATE`). A trailing `[]` marks a Postgres array column;
155
- # a `(length[,scale])` specifier is dropped. Returns `[normalized_type, array?]`.
156
+ # a `(length[,scale])` specifier is dropped. Returns `[normalized_type, array?, scale]`, where `scale` is
157
+ # the literal second number of a `(precision, scale)` specifier, or nil when there is none.
156
158
  def extract_type(rest)
157
159
  type_tokens = []
158
160
  rest.split(/\s+/).each do |token|
@@ -162,8 +164,9 @@ module Rigor
162
164
  end
163
165
  type_str = type_tokens.join(" ")
164
166
  array = type_str.include?("[]")
167
+ scale = type_str[/\(\s*\d+\s*,\s*(\d+)\s*\)/, 1]&.to_i
165
168
  type_str = type_str.gsub("[]", "").gsub(/\([0-9,\s]*\)/, "").strip.downcase
166
- [type_str, array]
169
+ [type_str, array, scale]
167
170
  end
168
171
  end
169
172
  end