rigortype 0.3.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/enumerable.rbs +51 -0
  4. data/data/core_overlay/enumerator.rbs +84 -0
  5. data/data/core_overlay/hash_rbs3.rbs +41 -0
  6. data/data/core_overlay/process.rbs +40 -0
  7. data/data/core_overlay/string_io.rbs +33 -0
  8. data/data/effects/core.yml +3 -3
  9. data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
  10. data/docs/handbook/03-narrowing.md +95 -10
  11. data/docs/handbook/04-tuples-and-shapes.md +8 -6
  12. data/docs/handbook/07-rbs-and-extended.md +16 -11
  13. data/docs/handbook/10-sorbet.md +9 -10
  14. data/docs/handbook/11-sig-gen.md +454 -13
  15. data/docs/manual/02-cli-reference.md +63 -2
  16. data/docs/manual/03-configuration.md +7 -0
  17. data/docs/manual/04-diagnostics.md +4 -0
  18. data/docs/manual/07-plugins.md +1 -1
  19. data/docs/manual/10-mcp-server.md +3 -2
  20. data/docs/manual/16-rbs-extended-annotations.md +40 -7
  21. data/docs/manual/19-effect-labels.md +10 -0
  22. data/docs/manual/plugins/README.md +7 -0
  23. data/docs/manual/plugins/rigor-actioncable.md +8 -1
  24. data/docs/manual/plugins/rigor-actionmailer.md +7 -0
  25. data/docs/manual/plugins/rigor-actionpack.md +243 -0
  26. data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
  27. data/docs/manual/plugins/rigor-activejob.md +7 -0
  28. data/docs/manual/plugins/rigor-activerecord.md +110 -4
  29. data/docs/manual/plugins/rigor-activestorage.md +8 -1
  30. data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
  31. data/docs/manual/plugins/rigor-grape.md +106 -0
  32. data/docs/manual/plugins/rigor-graphql.md +23 -2
  33. data/docs/manual/plugins/rigor-pundit.md +8 -1
  34. data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
  35. data/docs/manual/plugins/rigor-rails-routes.md +7 -0
  36. data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
  37. data/docs/manual/plugins/rigor-sidekiq.md +8 -1
  38. data/docs/manual/plugins/rigor-sorbet.md +21 -5
  39. data/exe/rigor +19 -4
  40. data/lib/rigor/analysis/baseline.rb +1 -1
  41. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
  42. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
  43. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
  45. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  46. data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
  47. data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
  48. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
  49. data/lib/rigor/analysis/check_rules.rb +520 -49
  50. data/lib/rigor/analysis/dependency_recorder.rb +23 -0
  51. data/lib/rigor/analysis/fact_store.rb +9 -0
  52. data/lib/rigor/analysis/incremental.rb +26 -0
  53. data/lib/rigor/analysis/incremental_session.rb +36 -4
  54. data/lib/rigor/analysis/project_scan.rb +11 -1
  55. data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
  56. data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
  57. data/lib/rigor/analysis/rule_catalog.rb +127 -0
  58. data/lib/rigor/analysis/run_cache_key.rb +20 -11
  59. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
  60. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
  61. data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
  62. data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
  63. data/lib/rigor/analysis/runner.rb +278 -20
  64. data/lib/rigor/analysis/template_unit_collector.rb +303 -0
  65. data/lib/rigor/analysis/template_unit_paths.rb +91 -0
  66. data/lib/rigor/analysis/template_unit_positions.rb +293 -0
  67. data/lib/rigor/analysis/template_units.rb +399 -0
  68. data/lib/rigor/analysis/worker_session.rb +53 -10
  69. data/lib/rigor/bleeding_edge.rb +0 -2
  70. data/lib/rigor/builtins/hkt_builtins.rb +1 -0
  71. data/lib/rigor/builtins/imported_refinements.rb +4 -0
  72. data/lib/rigor/builtins/regex_refinement.rb +17 -9
  73. data/lib/rigor/builtins/static_return_refinements.rb +2 -0
  74. data/lib/rigor/cache/descriptor.rb +6 -1
  75. data/lib/rigor/cache/engine_source.rb +29 -1
  76. data/lib/rigor/cache/incremental_snapshot.rb +33 -1
  77. data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
  78. data/lib/rigor/cache/rbs_descriptor.rb +37 -7
  79. data/lib/rigor/cache/store.rb +0 -1
  80. data/lib/rigor/ci_detector.rb +1 -0
  81. data/lib/rigor/cli/doc_links.rb +1 -1
  82. data/lib/rigor/cli/docs_command.rb +4 -4
  83. data/lib/rigor/cli/plugin_command.rb +3 -3
  84. data/lib/rigor/cli/prism_colorizer.rb +0 -1
  85. data/lib/rigor/cli/sig_gen_command.rb +210 -29
  86. data/lib/rigor/cli/skill_command.rb +1 -1
  87. data/lib/rigor/cli/skill_describe.rb +0 -1
  88. data/lib/rigor/cli/type_of_command.rb +19 -5
  89. data/lib/rigor/cli/type_of_renderer.rb +20 -9
  90. data/lib/rigor/cli/type_of_template_probe.rb +189 -0
  91. data/lib/rigor/cli.rb +21 -3
  92. data/lib/rigor/configuration/severity_profile.rb +19 -3
  93. data/lib/rigor/configuration.rb +66 -3
  94. data/lib/rigor/effects/ancestry_recorder.rb +191 -0
  95. data/lib/rigor/effects/attribution.rb +11 -2
  96. data/lib/rigor/effects/callee_rule.rb +368 -0
  97. data/lib/rigor/effects/catalog.rb +7 -4
  98. data/lib/rigor/effects/collector.rb +11 -5
  99. data/lib/rigor/effects/config_envelopes.rb +9 -2
  100. data/lib/rigor/effects/definition_context.rb +179 -0
  101. data/lib/rigor/effects/effect_table.rb +11 -3
  102. data/lib/rigor/effects/envelope_check.rb +1 -1
  103. data/lib/rigor/effects/envelope_index.rb +15 -0
  104. data/lib/rigor/effects/file_collection.rb +59 -5
  105. data/lib/rigor/effects/framework_units.rb +1 -1
  106. data/lib/rigor/effects/identity.rb +16 -0
  107. data/lib/rigor/effects/local_ownership.rb +38 -12
  108. data/lib/rigor/effects/method_key.rb +21 -0
  109. data/lib/rigor/effects/mutation_classifier.rb +23 -12
  110. data/lib/rigor/effects/plugin_facts.rb +43 -31
  111. data/lib/rigor/effects/propagator.rb +295 -16
  112. data/lib/rigor/effects/registry.rb +1 -1
  113. data/lib/rigor/effects/scanner.rb +121 -72
  114. data/lib/rigor/effects/signature_sources.rb +1 -1
  115. data/lib/rigor/effects/snapshot.rb +2 -1
  116. data/lib/rigor/effects/summary.rb +27 -4
  117. data/lib/rigor/effects/unit_scan.rb +385 -30
  118. data/lib/rigor/effects/visibility.rb +101 -0
  119. data/lib/rigor/environment/lockfile_resolver.rb +17 -0
  120. data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
  121. data/lib/rigor/environment/member_consistency.rb +298 -0
  122. data/lib/rigor/environment/rbs_loader.rb +399 -133
  123. data/lib/rigor/environment.rb +103 -38
  124. data/lib/rigor/hashing/xxh3.rb +264 -0
  125. data/lib/rigor/inference/acceptance.rb +139 -9
  126. data/lib/rigor/inference/block_auto_splat.rb +216 -0
  127. data/lib/rigor/inference/block_call_timing.rb +338 -0
  128. data/lib/rigor/inference/block_parameter_binder.rb +73 -27
  129. data/lib/rigor/inference/block_repetition.rb +71 -0
  130. data/lib/rigor/inference/body_fixpoint.rb +2 -1
  131. data/lib/rigor/inference/budget_trace.rb +2 -1
  132. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  133. data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
  134. data/lib/rigor/inference/captured_locals.rb +387 -15
  135. data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
  136. data/lib/rigor/inference/content_join.rb +200 -27
  137. data/lib/rigor/inference/def_return_typer.rb +11 -7
  138. data/lib/rigor/inference/define_method_block_self.rb +64 -0
  139. data/lib/rigor/inference/element_read_widening.rb +22 -10
  140. data/lib/rigor/inference/error_info.rb +196 -0
  141. data/lib/rigor/inference/expression_typer.rb +1532 -496
  142. data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
  143. data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
  144. data/lib/rigor/inference/global_write_census.rb +239 -0
  145. data/lib/rigor/inference/guard_rebinding.rb +447 -0
  146. data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
  147. data/lib/rigor/inference/index_write_widening.rb +16 -3
  148. data/lib/rigor/inference/indexed_narrowing.rb +61 -9
  149. data/lib/rigor/inference/jump_targets.rb +82 -0
  150. data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
  151. data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
  152. data/lib/rigor/inference/last_line.rb +340 -0
  153. data/lib/rigor/inference/last_status.rb +144 -0
  154. data/lib/rigor/inference/macro_block_self_type.rb +167 -17
  155. data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
  156. data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
  157. data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
  158. data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
  159. data/lib/rigor/inference/match_rebinding.rb +392 -0
  160. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  161. data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
  162. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
  163. data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
  164. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
  165. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
  166. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
  167. data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
  168. data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
  169. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
  170. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
  171. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
  172. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
  173. data/lib/rigor/inference/method_dispatcher.rb +214 -13
  174. data/lib/rigor/inference/method_parameter_binder.rb +8 -3
  175. data/lib/rigor/inference/multi_target_binder.rb +340 -52
  176. data/lib/rigor/inference/mutation_rejoin.rb +4 -1
  177. data/lib/rigor/inference/mutation_widening.rb +70 -48
  178. data/lib/rigor/inference/narrowing.rb +540 -120
  179. data/lib/rigor/inference/operand_effects.rb +167 -0
  180. data/lib/rigor/inference/operand_walk.rb +88 -0
  181. data/lib/rigor/inference/optimistic_origin.rb +152 -9
  182. data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
  183. data/lib/rigor/inference/project_method_ownership.rb +136 -0
  184. data/lib/rigor/inference/project_patched_methods.rb +7 -2
  185. data/lib/rigor/inference/project_patched_scanner.rb +7 -3
  186. data/lib/rigor/inference/receiver_alias.rb +90 -1
  187. data/lib/rigor/inference/receiver_blind_block.rb +219 -0
  188. data/lib/rigor/inference/refinement_mutation.rb +15 -11
  189. data/lib/rigor/inference/repeated_or_writes.rb +463 -0
  190. data/lib/rigor/inference/return_barrier.rb +54 -0
  191. data/lib/rigor/inference/rewrite_mutation.rb +120 -0
  192. data/lib/rigor/inference/scope_indexer.rb +4615 -551
  193. data/lib/rigor/inference/statement_evaluator.rb +3176 -490
  194. data/lib/rigor/inference/stored_block_call.rb +54 -0
  195. data/lib/rigor/inference/string_mutation.rb +44 -7
  196. data/lib/rigor/inference/unknown_store_widening.rb +200 -0
  197. data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
  198. data/lib/rigor/language_server/debouncer.rb +0 -1
  199. data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
  200. data/lib/rigor/language_server/hover_renderer.rb +3 -3
  201. data/lib/rigor/language_server/project_context.rb +5 -3
  202. data/lib/rigor/mcp/server.rb +2 -1
  203. data/lib/rigor/plugin/base.rb +168 -5
  204. data/lib/rigor/plugin/box_probe.rb +91 -0
  205. data/lib/rigor/plugin/bundled_catalog.rb +1 -1
  206. data/lib/rigor/plugin/effect_attribution.rb +58 -4
  207. data/lib/rigor/plugin/loader.rb +2 -1
  208. data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
  209. data/lib/rigor/plugin/manifest.rb +71 -10
  210. data/lib/rigor/plugin/registry.rb +35 -1
  211. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  212. data/lib/rigor/plugin/template_unit.rb +196 -0
  213. data/lib/rigor/plugin.rb +1 -0
  214. data/lib/rigor/protection/discovery_seed.rb +3 -1
  215. data/lib/rigor/protection/kill_signature.rb +0 -1
  216. data/lib/rigor/protection/mutation_cache.rb +1 -2
  217. data/lib/rigor/rbs_extended.rb +27 -0
  218. data/lib/rigor/reflection/constant_ancestors.rb +97 -0
  219. data/lib/rigor/reflection/constant_path.rb +19 -6
  220. data/lib/rigor/reflection.rb +72 -105
  221. data/lib/rigor/scope/discovery_index.rb +135 -2
  222. data/lib/rigor/scope.rb +859 -49
  223. data/lib/rigor/sig_gen/alias_index.rb +289 -0
  224. data/lib/rigor/sig_gen/classification.rb +22 -5
  225. data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
  226. data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
  227. data/lib/rigor/sig_gen/generator.rb +558 -31
  228. data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
  229. data/lib/rigor/sig_gen/method_candidate.rb +47 -3
  230. data/lib/rigor/sig_gen/observation_collector.rb +1 -1
  231. data/lib/rigor/sig_gen/renderer.rb +124 -9
  232. data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
  233. data/lib/rigor/sig_gen/write_result.rb +19 -3
  234. data/lib/rigor/sig_gen/writer.rb +166 -35
  235. data/lib/rigor/sig_gen.rb +3 -0
  236. data/lib/rigor/signature_path_audit.rb +1 -1
  237. data/lib/rigor/source/node_walker.rb +0 -3
  238. data/lib/rigor/source/parameter_envelope.rb +72 -0
  239. data/lib/rigor/source.rb +1 -0
  240. data/lib/rigor/type/combinator.rb +88 -12
  241. data/lib/rigor/type/difference.rb +1 -0
  242. data/lib/rigor/type/hash_shape.rb +1 -1
  243. data/lib/rigor/type/refined.rb +1 -0
  244. data/lib/rigor/version.rb +1 -1
  245. data/lib/rigor.rb +1 -0
  246. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
  247. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
  248. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
  249. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
  250. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
  251. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
  252. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
  253. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
  254. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
  255. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
  256. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
  257. data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
  258. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
  259. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
  260. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
  261. data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
  262. data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
  263. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
  264. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
  265. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
  266. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
  267. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
  268. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
  269. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
  270. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
  271. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
  272. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
  273. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
  274. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
  275. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
  276. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
  277. data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
  278. data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
  279. data/plugins/rigor-grape/sig/grape.rbs +263 -0
  280. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
  281. data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
  282. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
  283. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
  284. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
  285. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
  286. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
  287. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
  288. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
  289. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
  290. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
  291. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
  292. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
  293. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
  294. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
  295. data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
  296. data/sig/rigor/analysis/baseline.rbs +69 -7
  297. data/sig/rigor/analysis/fact_store.rbs +1 -1
  298. data/sig/rigor/analysis/project_scan.rbs +74 -0
  299. data/sig/rigor/effects/config_envelopes.rbs +100 -0
  300. data/sig/rigor/effects/effect_table.rbs +60 -0
  301. data/sig/rigor/effects/envelope.rbs +97 -0
  302. data/sig/rigor/effects/envelope_index.rbs +33 -0
  303. data/sig/rigor/effects/file_collection.rbs +81 -0
  304. data/sig/rigor/effects/label.rbs +26 -0
  305. data/sig/rigor/effects/label_set.rbs +46 -0
  306. data/sig/rigor/effects/method_key.rbs +24 -0
  307. data/sig/rigor/effects/origin.rbs +50 -0
  308. data/sig/rigor/effects/plugin_facts.rbs +141 -0
  309. data/sig/rigor/effects/registry.rbs +68 -0
  310. data/sig/rigor/effects/summary.rbs +50 -0
  311. data/sig/rigor/effects/taint_cause.rbs +12 -0
  312. data/sig/rigor/environment.rbs +12 -10
  313. data/sig/rigor/inference/optimistic_origin.rbs +10 -0
  314. data/sig/rigor/inference.rbs +6 -4
  315. data/sig/rigor/plugin/additional_initializer.rbs +36 -0
  316. data/sig/rigor/plugin/base.rbs +30 -7
  317. data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
  318. data/sig/rigor/plugin/effect_attribution.rbs +66 -0
  319. data/sig/rigor/plugin/effect_edge.rbs +33 -0
  320. data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
  321. data/sig/rigor/plugin/io_boundary.rbs +1 -1
  322. data/sig/rigor/plugin/loader.rbs +3 -3
  323. data/sig/rigor/plugin/manifest.rbs +50 -11
  324. data/sig/rigor/plugin/protocol_contract.rbs +68 -0
  325. data/sig/rigor/plugin/registry.rbs +62 -1
  326. data/sig/rigor/plugin.rbs +1 -1
  327. data/sig/rigor/rbs_extended.rbs +1 -1
  328. data/sig/rigor/reflection.rbs +7 -6
  329. data/sig/rigor/scope.rbs +135 -20
  330. data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
  331. data/sig/rigor/source.rbs +4 -4
  332. data/sig/rigor/testing.rbs +10 -4
  333. data/sig/rigor/type.rbs +6 -0
  334. data/sig/rigor.rbs +38 -20
  335. data/skills/rigor-ask/SKILL.md +8 -5
  336. data/skills/rigor-baseline-reduce/SKILL.md +4 -6
  337. data/skills/rigor-ci-setup/SKILL.md +17 -21
  338. data/skills/rigor-doctor/SKILL.md +24 -22
  339. data/skills/rigor-doctor/references/01-checks.md +97 -33
  340. data/skills/rigor-editor-setup/SKILL.md +6 -4
  341. data/skills/rigor-mcp-setup/SKILL.md +5 -4
  342. data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
  343. data/skills/rigor-next-steps/SKILL.md +4 -2
  344. data/skills/rigor-plugin-author/SKILL.md +19 -23
  345. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
  346. data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
  347. data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
  348. data/skills/rigor-plugin-review/SKILL.md +6 -4
  349. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
  350. data/skills/rigor-plugin-tune/SKILL.md +4 -2
  351. data/skills/rigor-project-init/SKILL.md +9 -7
  352. data/skills/rigor-project-init/references/01-detect.md +13 -9
  353. data/skills/rigor-project-init/references/02-configure.md +33 -8
  354. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
  355. data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
  356. data/skills/rigor-protection-uplift/SKILL.md +4 -6
  357. data/skills/rigor-rbs-setup/SKILL.md +4 -2
  358. data/skills/rigor-type-oracle/SKILL.md +4 -6
  359. data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
  360. data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
  361. data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
  362. data/skills/rigor-upgrade/SKILL.md +13 -8
  363. metadata +108 -1
@@ -108,6 +108,18 @@ module Rigor
108
108
  # name — {#resolve_table_name_decorator} keeps walking outward past it, exactly like Rails' own
109
109
  # `respond_to?(:table_name_prefix)` ancestor search.
110
110
  @namespace_table_name_decorators = {}
111
+ # `concern module name => { scopes:, associations:, macro_methods: }` — what a module contributes
112
+ # to every class that includes it (#534 item 5, #1049). Unlike {@type_override_columns} these are
113
+ # NOT global: a declaration reaches a model only along a real `include` edge ({@include_edges}),
114
+ # so a same-named `scope` / association / `delegate` in a concern the model does not include
115
+ # contributes nothing.
116
+ @concern_declarations = {}
117
+ # `constant name => [raw included module name]` — every `include Foo` / `include Foo::Bar` a class
118
+ # or module body spells, as written. Resolution against the walked module set happens after the
119
+ # whole tree is read ({#resolve_concern_name}), because the including file may be walked first.
120
+ @include_edges = {}
121
+ # Every module declaration walked, for resolving an `include`'s relative constant spelling.
122
+ @module_names = Set.new
111
123
  end
112
124
 
113
125
  attr_reader :type_override_columns
@@ -127,7 +139,8 @@ module Rigor
127
139
  # Every file is walked (and every module's `table_name_prefix` / `table_name_suffix` recorded)
128
140
  # before any row is resolved — a model's file may sort, and so be visited, before the file that
129
141
  # declares its enclosing module's decorator.
130
- attach_table_name_decorators(resolve_models(candidates), superclass_map: superclass_map)
142
+ rows = fold_concern_declarations(resolve_models(candidates), superclass_map: superclass_map)
143
+ attach_table_name_decorators(rows, superclass_map: superclass_map)
131
144
  end
132
145
 
133
146
  private
@@ -217,7 +230,8 @@ module Rigor
217
230
  scopes: (Array(base[:scopes]) + Array(addition[:scopes])).uniq,
218
231
  validations: (Array(base[:validations]) + Array(addition[:validations])).uniq,
219
232
  callbacks: (Array(base[:callbacks]) + Array(addition[:callbacks])).uniq,
220
- aliases: (base[:aliases] || {}).merge(addition[:aliases] || {})
233
+ aliases: (base[:aliases] || {}).merge(addition[:aliases] || {}),
234
+ macro_methods: (Array(base[:macro_methods]) + Array(addition[:macro_methods])).uniq
221
235
  )
222
236
  end
223
237
 
@@ -423,6 +437,7 @@ module Rigor
423
437
  superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
424
438
 
425
439
  collect_type_overrides(node.body)
440
+ record_include_edges(full_name, node.body)
426
441
  # Rails' `full_table_name_prefix` walks `module_parents.detect { respond_to?(:table_name_prefix) }`
427
442
  # — lexical nesting, which does not care whether the namespace container was written `module Blog`
428
443
  # or `class Blog`. A `class` used purely as a namespace holder (or a model class that happens to
@@ -440,7 +455,8 @@ module Rigor
440
455
  scopes: lookup_scopes(node.body),
441
456
  validations: lookup_validations(node.body),
442
457
  callbacks: lookup_callbacks(node.body),
443
- aliases: lookup_aliases(node.body)
458
+ aliases: lookup_aliases(node.body),
459
+ macro_methods: lookup_macro_methods(node.body)
444
460
  })
445
461
 
446
462
  # Recurse into the body in case nested classes exist.
@@ -457,6 +473,9 @@ module Rigor
457
473
 
458
474
  full_name = declared_constant_name(module_local_name, lexical_path)
459
475
  record_table_name_decorators(full_name, node.body, is_class: false)
476
+ @module_names << full_name
477
+ record_concern_declarations(full_name, node.body)
478
+ record_include_edges(full_name, node.body)
460
479
 
461
480
  inner_path = [full_name]
462
481
  walk_for_classes(node.body, inner_path, &) if node.body
@@ -519,6 +538,177 @@ module Rigor
519
538
  type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
520
539
  end
521
540
 
541
+ # Records what a concern module contributes to every class that includes it (#534 item 5, #1049;
542
+ # mastodon's `scope :without_suspended` in `Account::Suspensions`, `has_one :account_stat` and
543
+ # `delegate :statuses_count, …, to: :account_stat` in `Account::Counters`).
544
+ # ActiveSupport::Concern evaluates an `included do … end` block against the INCLUDING class, so
545
+ # whatever it declares is declared on every model that includes the module — and on no other model,
546
+ # which is why these are kept per-module here rather than globally the way {#collect_type_overrides}
547
+ # keeps column names.
548
+ #
549
+ # Concern-hood is recognised the way {#type_override_declaration_calls} already recognises it: by the
550
+ # `included do … end` block itself, not by an `extend ActiveSupport::Concern` line.
551
+ #
552
+ # The module's OWN top-level body contributes too, but only `delegate`: `delegate` is
553
+ # `Module#delegate`, so at a module's top level it defines ordinary INSTANCE methods on the module,
554
+ # which the including class then inherits through its ancestry — exactly the shape
555
+ # `Account::Counters` uses. A top-level `scope` / `has_one` / `has_attached_file` is by contrast a
556
+ # plain send to the module object (a runtime `NoMethodError`), never a class-level declaration on any
557
+ # model, so those are read from `included do … end` only.
558
+ def record_concern_declarations(module_name, body)
559
+ found = concern_declarations(body)
560
+ return if found.each_value.all?(&:empty?)
561
+
562
+ record = (@concern_declarations[module_name] ||= { scopes: [], associations: [], macro_methods: [] })
563
+ found.each { |key, values| record[key].concat(values) }
564
+ end
565
+
566
+ def concern_declarations(body)
567
+ record = { scopes: [], associations: [], macro_methods: [] }
568
+ return record if body.nil?
569
+
570
+ body.compact_child_nodes.each do |node|
571
+ next unless node.is_a?(Prism::CallNode) && node.receiver.nil?
572
+
573
+ if node.name == :included && node.block.is_a?(Prism::BlockNode)
574
+ block_body = node.block.body
575
+ record[:scopes].concat(lookup_scopes(block_body))
576
+ record[:associations].concat(lookup_associations(block_body))
577
+ record[:macro_methods].concat(lookup_macro_methods(block_body))
578
+ elsif node.name == :delegate
579
+ record[:macro_methods].concat(delegate_method_names(node))
580
+ end
581
+ end
582
+ record
583
+ end
584
+
585
+ # Records every `include Foo` / `include Foo::Bar` a class or module body spells, AS WRITTEN — the
586
+ # constant is resolved only once the whole tree has been walked ({#resolve_concern_name}), because the
587
+ # concern's file may well sort after the file that includes it. An `include` inside a concern's own
588
+ # `included do … end` block counts too: that block runs against the including class, so the module
589
+ # lands on the model exactly as a body-level `include` does. A non-constant argument
590
+ # (`include build_module`) renders as nil and declines.
591
+ def record_include_edges(constant_name, body)
592
+ names = include_call_targets(body)
593
+ return if names.empty?
594
+
595
+ (@include_edges[constant_name] ||= []).concat(names)
596
+ end
597
+
598
+ def include_call_targets(body)
599
+ return [] if body.nil?
600
+
601
+ body.compact_child_nodes.flat_map do |node|
602
+ next [] unless node.is_a?(Prism::CallNode) && node.receiver.nil?
603
+
604
+ if node.name == :included && node.block.is_a?(Prism::BlockNode)
605
+ include_call_targets(node.block.body)
606
+ elsif node.name == :include
607
+ Array(node.arguments&.arguments).filter_map { |arg| constant_path_name(arg) }
608
+ else
609
+ []
610
+ end
611
+ end
612
+ end
613
+
614
+ # Adds each model's concern-declared scopes, associations and macro methods to the ones its own body
615
+ # declares. Attribution is by real `include` edge: a concern nobody includes contributes nothing, and
616
+ # a same-named `scope` / `has_one` / `delegate` in an unrelated concern does not leak onto this model.
617
+ # A concern that includes another concern is followed transitively (Ruby's own semantics — the inner
618
+ # module ends up in the model's ancestry either way).
619
+ #
620
+ # The model's SUPERCLASS chain is walked too, because a concern is routinely included once in
621
+ # `ApplicationRecord` for every model to get. The chain is walked here rather than left to
622
+ # {ModelIndex.sti_chain}, which only links a model to another MODEL: a configured base class is not a
623
+ # discovered model, so `class Account < ApplicationRecord` has no `sti_parent` and the index's chain
624
+ # stops at `Account`. (A `scope` written directly in the base class's own body is still not
625
+ # propagated — that pre-existing gap is a different mechanism and is documented, not widened here.)
626
+ #
627
+ # The model's OWN association rows are kept LAST so {#dedup_named_rows}'s last-wins rule lets a class
628
+ # body redeclaration override the concern's, which is what Ruby does: the class's own
629
+ # `has_one :user, class_name: "…"` beats the one the included module installed.
630
+ def fold_concern_declarations(rows, superclass_map: {})
631
+ return rows if @concern_declarations.empty?
632
+
633
+ rows.map do |row|
634
+ inherited = ancestry_concern_declarations(row.fetch(:class_name), superclass_map)
635
+ next row if inherited.each_value.all?(&:empty?)
636
+
637
+ row.merge(
638
+ scopes: (Array(row[:scopes]) + inherited[:scopes]).uniq.freeze,
639
+ associations: dedup_named_rows(inherited[:associations] + Array(row[:associations])),
640
+ macro_methods: (Array(row[:macro_methods]) + inherited[:macro_methods]).uniq.freeze
641
+ )
642
+ end
643
+ end
644
+
645
+ # The concern declarations reaching `class_name` through its own includes and those of every
646
+ # superclass up the chain (`visited` stops a cycle a malformed source could spell).
647
+ def ancestry_concern_declarations(class_name, superclass_map)
648
+ found = { scopes: [], associations: [], macro_methods: [] }
649
+ curr = class_name
650
+ visited = Set.new
651
+
652
+ while curr && visited.add?(curr)
653
+ concern_declarations_for(curr).each { |key, values| found[key].concat(values) }
654
+ curr = superclass_map[curr]
655
+ end
656
+
657
+ found[:scopes].uniq!
658
+ found[:macro_methods].uniq!
659
+ found
660
+ end
661
+
662
+ # Breadth-first over the `include` edges reachable from `class_name`, unioning each visited concern's
663
+ # declarations. `visited` makes a mutually-including pair of concerns terminate rather than loop.
664
+ def concern_declarations_for(class_name)
665
+ found = { scopes: [], associations: [], macro_methods: [] }
666
+ queue = Array(@include_edges[class_name]).map { |raw| [raw, class_name] }
667
+ visited = Set.new([class_name])
668
+
669
+ until queue.empty?
670
+ raw, from_name = queue.shift
671
+ resolved = resolve_concern_name(raw, from_name)
672
+ next if visited.include?(resolved)
673
+
674
+ visited << resolved
675
+ (@concern_declarations[resolved] || {}).each { |key, values| found[key].concat(values) }
676
+ queue.concat(Array(@include_edges[resolved]).map { |inner| [inner, resolved] })
677
+ end
678
+
679
+ found
680
+ end
681
+
682
+ # Resolves an `include`'s constant spelling against the modules actually walked, mirroring Ruby's
683
+ # lexical lookup closely enough for the shapes concerns use: a rooted name (`include ::Suspendable`)
684
+ # names the top-level constant, a relative one is tried under the including constant and each of its
685
+ # enclosing namespaces first (`include Suspensions` inside `class Account` → `Account::Suspensions`,
686
+ # which is what Ruby resolves), and otherwise it is taken as written. A name no walked module answers
687
+ # to simply contributes nothing.
688
+ #
689
+ # Two known imprecisions, both cheap only to state:
690
+ #
691
+ # - The enclosing-namespace attempts are made for a COMPACT declaration too (`module A::B`'s own
692
+ # `include C` tries `A::B::C` then `A::C`), where Ruby's cref would only see `A::B` and the top
693
+ # level. Harmless: the extra candidates must match a module that was actually walked, and a
694
+ # project with both `A::B::C` and a top-level `C` concern would have to want the latter.
695
+ # - The final bare-name fall-back attributes BY SPELLING. A nearer constant declared outside
696
+ # `model_search_paths` — `Account::Suspensions` in `lib/`, say, while the concern roots hold a
697
+ # top-level `Suspensions` — is invisible to this walker, so the wrong one can win. Reading
698
+ # further than the model roots needs its own cache descriptor (see {#discover}'s `watch:` note),
699
+ # so the spelling is trusted instead.
700
+ def resolve_concern_name(raw, from_name)
701
+ name = strip_root(raw)
702
+ return name if raw.start_with?("::")
703
+
704
+ ([from_name] + namespace_ancestors(from_name)).each do |prefix|
705
+ candidate = "#{prefix}::#{name}"
706
+ return candidate if @module_names.include?(candidate)
707
+ end
708
+
709
+ name
710
+ end
711
+
522
712
  # Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String, keeping the
523
713
  # leading `::` of a rooted path — {#declared_constant_name} reads it as "reset the lexical path"
524
714
  # before the marker is dropped. Returns nil for shapes the discoverer chooses not to handle.
@@ -1333,6 +1523,193 @@ module Rigor
1333
1523
  aliases.freeze
1334
1524
  end
1335
1525
 
1526
+ # The attachment macros whose reader names are folded, mapped to the reader SUFFIXES each one
1527
+ # installs beside the bare accessor (#1049).
1528
+ #
1529
+ # Each key is the macro name of exactly one gem, and none of the three is a name another common
1530
+ # library claims — `has_attached_file` is Paperclip's, `has_one_attached` / `has_many_attached` are
1531
+ # Active Storage's — so the macro's presence in a model body IS the evidence that the project uses
1532
+ # that gem. No lockfile probe is needed (and none would be sound for a vendored or path-sourced
1533
+ # gem); a project that spells none of the three gets nothing folded.
1534
+ #
1535
+ # PAPERCLIP's set is deliberately minimal: `has_attached_file :file` installs `file`, `file=` and
1536
+ # `file?`, and those are the names #1049's serializer table needs. The `file_file_name` /
1537
+ # `file_content_type` / `file_file_size` / `file_updated_at` quartet is NOT listed because Paperclip
1538
+ # backs it with four real schema columns, which the index already carries from `db/schema.rb`;
1539
+ # synthesising them here would claim them on a model whose table lacks them.
1540
+ #
1541
+ # ACTIVE STORAGE's set is `avatar`, `avatar=`, `avatar_attachment`, `avatar_blob` for the singular
1542
+ # macro and `images`, `images=`, `images_attachments`, `images_blobs` for the collection one — the
1543
+ # `has_one_attached` / `has_many_attached` generated-methods surface, none of it column-backed.
1544
+ # This is name MEMBERSHIP only, and does not overlap `rigor-activestorage`, which contributes the
1545
+ # accessor's TYPE (`ActiveStorage::Attached::One`) and publishes no fact: a consumer that reads
1546
+ # `:model_index` to ask "does this model answer `avatar`?" gets nothing from that plugin, and gets
1547
+ # nothing at all when the project does not load it.
1548
+ ATTACHMENT_MACROS = {
1549
+ has_attached_file: ["", "=", "?"],
1550
+ has_one_attached: ["", "=", "_attachment", "_blob"],
1551
+ has_many_attached: ["", "=", "_attachments", "_blobs"]
1552
+ }.freeze
1553
+ Ractor.make_shareable(ATTACHMENT_MACROS)
1554
+ private_constant :ATTACHMENT_MACROS
1555
+
1556
+ # Instance-method names a declaration macro installs that neither the schema nor the association /
1557
+ # alias / scope lists already carry (#1049): `delegate`'s method names, the attachment macros'
1558
+ # readers, and an `enum`'s per-value predicates. Names ONLY — what a delegate target returns is a
1559
+ # separate question no consumer asks today.
1560
+ #
1561
+ # Every name here is spelled out by the declaration itself. A shape this walker cannot read off the
1562
+ # source (a non-literal `prefix:`, a `to:` that is not a Symbol when `prefix: true` needs its
1563
+ # spelling, a computed enum value list) contributes NOTHING rather than a guess: a consumer that
1564
+ # fails closed on an unanswered name loses precision when a name is missing, but is answered WRONG
1565
+ # when a name is invented.
1566
+ def lookup_macro_methods(body)
1567
+ return [] if body.nil?
1568
+
1569
+ names = []
1570
+ declaration_calls(body).each do |node|
1571
+ next if node.receiver
1572
+
1573
+ if node.name == :delegate
1574
+ names.concat(delegate_method_names(node))
1575
+ elsif ATTACHMENT_MACROS.key?(node.name)
1576
+ names.concat(attachment_method_names(node))
1577
+ elsif node.name == :enum
1578
+ names.concat(enum_predicate_names(node))
1579
+ end
1580
+ end
1581
+ names.uniq.freeze
1582
+ end
1583
+
1584
+ # `delegate :a, :b, to: :x` → `["a", "b"]`; `prefix: true` prepends the `to:` target's own spelling
1585
+ # (`delegate :can?, to: :user, prefix: true` → `"user_can?"`), and `prefix: :admin` prepends the
1586
+ # given one. ActiveSupport builds the name by plain concatenation, so a `?` / `!` / `=` suffix rides
1587
+ # along untouched.
1588
+ #
1589
+ # `allow_nil:` does not enter into it — it changes what the method RETURNS, never what it is called.
1590
+ # `prefix: true` with a `to:` this walker cannot render as a name declines the whole call.
1591
+ def delegate_method_names(node)
1592
+ args = node.arguments&.arguments
1593
+ return [] if args.nil? || args.empty?
1594
+
1595
+ names = args.filter_map { |arg| Rigor::Source::Literals.symbol_name(arg) }
1596
+ return [] if names.empty?
1597
+
1598
+ prefix = delegate_prefix(args)
1599
+ return [] if prefix == :unfoldable
1600
+
1601
+ names.map { |name| "#{prefix}#{name}" }
1602
+ end
1603
+
1604
+ # The `"user_"`-style prefix a `delegate` call's `prefix:` option establishes: `""` when the option
1605
+ # is absent or false, `:unfoldable` when it is present in a shape this walker cannot read.
1606
+ def delegate_prefix(args)
1607
+ value = keyword_option(args, "prefix")
1608
+ # `prefix: nil` is what ActiveSupport's own default is, and it reads as "no prefix" there, not as
1609
+ # an option the call spelled unreadably.
1610
+ return "" if value.nil? || value.is_a?(Prism::FalseNode) || value.is_a?(Prism::NilNode)
1611
+
1612
+ return delegate_target_prefix(args) if value.is_a?(Prism::TrueNode)
1613
+
1614
+ literal = name_literal(value)
1615
+ return :unfoldable if literal.nil? || literal.empty?
1616
+
1617
+ "#{literal}_"
1618
+ end
1619
+
1620
+ # `prefix: true` names the prefix after the `to:` target, so the target has to render as a name.
1621
+ # An instance-variable target (`to: :@user`, the spelling ActiveSupport accepts for delegating to an
1622
+ # ivar) does NOT: Rails strips the `@` when it builds the method name, and reading it as written
1623
+ # would invent `@user_email`.
1624
+ def delegate_target_prefix(args)
1625
+ name = name_literal(keyword_option(args, "to"))
1626
+ return :unfoldable if name.nil? || name.empty? || name.start_with?("@")
1627
+
1628
+ "#{name}_"
1629
+ end
1630
+
1631
+ # A Symbol or String literal rendered as a plain name, or nil for anything else. Rails accepts both
1632
+ # spellings wherever it takes a method or prefix name.
1633
+ def name_literal(node)
1634
+ Rigor::Source::Literals.symbol_name(node) || (node.is_a?(Prism::StringNode) ? node.unescaped : nil)
1635
+ end
1636
+
1637
+ # `has_attached_file :file` / `has_one_attached :avatar` / `has_many_attached :images` → the reader
1638
+ # names {ATTACHMENT_MACROS} lists for that macro. A non-Symbol-literal name declines.
1639
+ def attachment_method_names(node)
1640
+ name = Rigor::Source::Literals.symbol_name(node.arguments&.arguments&.first)
1641
+ return [] if name.nil? || name.empty?
1642
+
1643
+ ATTACHMENT_MACROS.fetch(node.name).map { |suffix| "#{name}#{suffix}" }
1644
+ end
1645
+
1646
+ # An `enum`'s per-value predicates — `enum :visibility, { … limited: 4 }, suffix: :visibility` →
1647
+ # `"limited_visibility?"` (mastodon's `Status::Visibility`). Rails composes the name as
1648
+ # `"#{prefix}#{value}#{suffix}"`, where `prefix: true` / `suffix: true` use the enum's own column
1649
+ # name and a Symbol / String uses that spelling; Rails 6's `_prefix:` / `_suffix:` keywords are read
1650
+ # as the same option.
1651
+ #
1652
+ # Only the `?` predicate is folded. The bang (`limited_visibility!`) writes to the database and the
1653
+ # same-named class-side scope is not an instance method at all; neither is a name a consumer of this
1654
+ # fact asks about, and both would be invention beyond what the table needs.
1655
+ #
1656
+ # The enum's COLUMN and value list are deliberately not folded into the row's `enums:` from here —
1657
+ # see {#concern_declarations}. This method contributes names, which every consumer of the fact
1658
+ # WIDENS a known-name set with; `enums:` drives `Analyzer#validate_enum_value`, which FIRES.
1659
+ #
1660
+ # `instance_methods: false` (Rails 7.1+) declines the whole call: that option is how a model asks
1661
+ # Rails NOT to define the per-value predicates, so folding them would claim methods the application
1662
+ # does not have — the one way this family could answer a fail-closed consumer wrongly rather than
1663
+ # just incompletely. A non-literal value for the option declines for the same reason.
1664
+ def enum_predicate_names(node)
1665
+ row = parse_enum_call(node)
1666
+ return [] if row.nil? || row[:values].empty?
1667
+
1668
+ args = node.arguments&.arguments || []
1669
+ instance_methods = keyword_option(args, "instance_methods")
1670
+ return [] unless instance_methods.nil? || instance_methods.is_a?(Prism::TrueNode)
1671
+
1672
+ prefix = enum_affix(args, %w[prefix _prefix], row[:column]) { |literal| "#{literal}_" }
1673
+ suffix = enum_affix(args, %w[suffix _suffix], row[:column]) { |literal| "_#{literal}" }
1674
+ return [] if prefix == :unfoldable || suffix == :unfoldable
1675
+
1676
+ row[:values].map { |value| "#{prefix}#{value}#{suffix}?" }
1677
+ end
1678
+
1679
+ # The rendered `prefix:` / `suffix:` affix of an `enum` call, `""` when the option is absent or
1680
+ # false and `:unfoldable` when it is present in a shape this walker cannot read.
1681
+ def enum_affix(args, keys, column_name)
1682
+ value = keys.filter_map { |key| keyword_option(args, key) }.first
1683
+ return "" if value.nil? || value.is_a?(Prism::FalseNode)
1684
+ return yield(column_name) if value.is_a?(Prism::TrueNode)
1685
+
1686
+ literal = name_literal(value)
1687
+ return :unfoldable if literal.nil? || literal.empty?
1688
+
1689
+ yield(literal)
1690
+ end
1691
+
1692
+ # The VALUE node of a keyword argument, across every keyword hash in the call's argument list, or
1693
+ # nil when the call does not spell that key. Unlike {#association_option} this hands back the node
1694
+ # rather than a Boolean, because the callers need Symbol and String values too.
1695
+ #
1696
+ # A BRACED hash argument (`enum :visibility, { limited: 4 }, suffix: :visibility`) is a
1697
+ # `Prism::HashNode` and is deliberately not scanned: there the braces hold the enum's own value map,
1698
+ # and an enum value spelled `prefix:` would otherwise read as the call's `prefix:` option. Rails 6's
1699
+ # `enum status: { … }, _prefix: true` puts both in ONE `KeywordHashNode`, which is scanned.
1700
+ def keyword_option(args, key)
1701
+ args.each do |arg|
1702
+ next unless arg.is_a?(Prism::KeywordHashNode)
1703
+
1704
+ arg.elements.each do |pair|
1705
+ next unless pair.is_a?(Prism::AssocNode) && Source::Literals.symbol_named?(pair.key, key)
1706
+
1707
+ return pair.value
1708
+ end
1709
+ end
1710
+ nil
1711
+ end
1712
+
1336
1713
  # Collects every Symbol-literal positional argument from a CallNode. Used by both
1337
1714
  # `lookup_validations` and `lookup_callbacks` to extract the attribute / method name list.
1338
1715
  def symbol_args(node)
@@ -15,7 +15,7 @@ module Rigor
15
15
  # Phase 2's schema is OPTIONAL. A project that ships raw migrations only — the DB-agnostic Rails
16
16
  # pattern, where `db/schema.rb` is gitignored (Redmine) — has no schema to combine, and `schema_table:
17
17
  # nil` then yields the REDUCED index: every {Entry} carries its resolved table name, associations,
18
- # enums, scopes, validations, callbacks and aliases, and an EMPTY column set. Only the column-keyed
18
+ # enums, scopes, validations, callbacks, aliases and macro methods, and an EMPTY column set. Only the column-keyed
19
19
  # surface stands down; the schema was never an input to any of the rest. {#columns_known?} tells the
20
20
  # two modes apart — an empty column set means "the schema is unknown", not "this model has no
21
21
  # columns", and consumers that would fire on an unrecognised column must not read the two the same
@@ -27,7 +27,7 @@ module Rigor
27
27
  #
28
28
  # Single-table-inheritance subclasses (`class Admin < User`) carry an `sti_parent:` pointer; their
29
29
  # {Entry} resolves its table from the root model and inherits the chain's declared associations /
30
- # enums / aliases / scopes / validations / callbacks.
30
+ # enums / aliases / scopes / validations / callbacks / macro methods.
31
31
  class ModelIndex
32
32
  # `associations` is a frozen `Array<Hash>` where each row carries `{ name:, kind:, target:,
33
33
  # nullable: }`:
@@ -47,7 +47,7 @@ module Rigor
47
47
  # exact. Only a true reading licenses pinning the name to a value; see
48
48
  # `Activerecord#table_name_return_type` for why the schema is not admitted as corroboration.
49
49
  Entry = Struct.new(:class_name, :table_name, :table_name_exact, :columns, :associations,
50
- :enums, :scopes, :validations, :callbacks, :aliases,
50
+ :enums, :scopes, :validations, :callbacks, :aliases, :macro_methods,
51
51
  keyword_init: true) do
52
52
  def table_name_exact? = table_name_exact == true
53
53
 
@@ -90,6 +90,13 @@ module Rigor
90
90
  # declarations.
91
91
  def alias?(name) = aliases.key?(name.to_s)
92
92
  def resolve_alias(name) = aliases[name.to_s]
93
+
94
+ # `macro_methods` is `Array<method_name>` — the instance methods a declaration macro installs that
95
+ # none of the sets above already carries (#1049): `delegate`'s names, the Paperclip / Active
96
+ # Storage attachment readers, and an `enum`'s per-value predicates. Names only, with no type: a
97
+ # consumer asks "does this model answer `followers_count`?", which is what a fail-closed check
98
+ # needs and all the declaration states.
99
+ def macro_method?(name) = macro_methods.include?(name.to_s)
93
100
  end
94
101
 
95
102
  attr_reader :entries
@@ -149,8 +156,8 @@ module Rigor
149
156
  end
150
157
 
151
158
  # STI children inherit their ancestors' declared associations / enums / aliases / scopes /
152
- # validations / callbacks. Without the merge a `where(<parent-association>: ...)` on the
153
- # child would surface as a false `unknown-column`.
159
+ # validations / callbacks / macro methods. Without the merge a `where(<parent-association>: ...)`
160
+ # on the child would surface as a false `unknown-column`.
154
161
  acc[class_name] = Entry.new(
155
162
  class_name: class_name,
156
163
  table_name: table_name,
@@ -161,7 +168,8 @@ module Rigor
161
168
  scopes: chain.flat_map { |r| Array(r[:scopes]) }.uniq.freeze,
162
169
  validations: chain.flat_map { |r| Array(r[:validations]) }.uniq.freeze,
163
170
  callbacks: chain.flat_map { |r| Array(r[:callbacks]) }.map(&:freeze).freeze,
164
- aliases: merge_aliases(chain)
171
+ aliases: merge_aliases(chain),
172
+ macro_methods: chain.flat_map { |r| Array(r[:macro_methods]) }.uniq.freeze
165
173
  ).freeze
166
174
  end
167
175
  new(entries.freeze, columns_known: !schema_table.nil?)