rigortype 0.3.5 → 0.3.7

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 (266) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
  14. data/docs/handbook/02-everyday-types.md +1 -1
  15. data/docs/handbook/04-tuples-and-shapes.md +1 -1
  16. data/docs/handbook/08-understanding-errors.md +1 -1
  17. data/docs/handbook/09-plugins.md +2 -2
  18. data/docs/handbook/10-sorbet.md +1 -1
  19. data/docs/handbook/README.md +2 -2
  20. data/docs/handbook/appendix-go.md +1 -1
  21. data/docs/handbook/appendix-java-csharp.md +2 -2
  22. data/docs/handbook/appendix-mypy.md +1 -1
  23. data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
  24. data/docs/handbook/appendix-rust.md +1 -1
  25. data/docs/handbook/appendix-type-theory.md +2 -2
  26. data/docs/handbook/appendix-typescript.md +4 -4
  27. data/docs/manual/02-cli-reference.md +29 -16
  28. data/docs/manual/03-configuration.md +1 -1
  29. data/docs/manual/04-diagnostics.md +12 -0
  30. data/docs/manual/05-inspecting-types.md +20 -8
  31. data/docs/manual/07-plugins.md +4 -0
  32. data/docs/manual/08-skills.md +1 -1
  33. data/docs/manual/11-ci.md +1 -1
  34. data/docs/manual/12-caching.md +1 -1
  35. data/docs/manual/15-type-protection-coverage.md +1 -1
  36. data/docs/manual/18-removing-dead-code.md +13 -4
  37. data/docs/manual/19-effect-labels.md +19 -12
  38. data/docs/manual/README.md +2 -2
  39. data/docs/manual/plugins/rigor-actionmailer.md +4 -4
  40. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  41. data/docs/manual/plugins/rigor-activejob.md +3 -3
  42. data/docs/manual/plugins/rigor-activerecord.md +78 -5
  43. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  44. data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
  45. data/docs/manual/plugins/rigor-rspec.md +6 -2
  46. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  47. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  48. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  49. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  50. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  51. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  52. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  53. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  54. data/lib/rigor/analysis/check_rules.rb +383 -48
  55. data/lib/rigor/analysis/crash_signature.rb +116 -0
  56. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  57. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  58. data/lib/rigor/analysis/diagnostic.rb +17 -6
  59. data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
  60. data/lib/rigor/analysis/incremental.rb +28 -0
  61. data/lib/rigor/analysis/incremental_session.rb +44 -9
  62. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  63. data/lib/rigor/analysis/reachability/graph.rb +44 -16
  64. data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
  65. data/lib/rigor/analysis/reachability/scan.rb +27 -7
  66. data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
  67. data/lib/rigor/analysis/result.rb +32 -0
  68. data/lib/rigor/analysis/run_cache_key.rb +53 -1
  69. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
  70. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
  71. data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
  72. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  73. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
  74. data/lib/rigor/analysis/runner.rb +298 -38
  75. data/lib/rigor/analysis/worker_session.rb +10 -1
  76. data/lib/rigor/bleeding_edge.rb +7 -6
  77. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  78. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  79. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  80. data/lib/rigor/cache/descriptor.rb +42 -2
  81. data/lib/rigor/cache/file_digest.rb +20 -2
  82. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  83. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
  84. data/lib/rigor/cache/store.rb +10 -1
  85. data/lib/rigor/cli/check_command.rb +67 -48
  86. data/lib/rigor/cli/coverage_command.rb +28 -24
  87. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  88. data/lib/rigor/cli/coverage_scan.rb +47 -6
  89. data/lib/rigor/cli/doc_links.rb +100 -0
  90. data/lib/rigor/cli/docs_command.rb +32 -2
  91. data/lib/rigor/cli/effects_command.rb +27 -2
  92. data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
  93. data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
  94. data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
  95. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  96. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  97. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  98. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  99. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  100. data/lib/rigor/cli/sig_gen_command.rb +18 -0
  101. data/lib/rigor/cli/type_of_command.rb +188 -41
  102. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  103. data/lib/rigor/cli/type_scan_command.rb +10 -1
  104. data/lib/rigor/cli/unused_command.rb +65 -16
  105. data/lib/rigor/cli.rb +22 -9
  106. data/lib/rigor/configuration.rb +7 -4
  107. data/lib/rigor/effects/definition_lines.rb +100 -0
  108. data/lib/rigor/effects/envelope_check.rb +18 -1
  109. data/lib/rigor/effects/liskov_check.rb +17 -8
  110. data/lib/rigor/effects/plugin_facts.rb +1 -1
  111. data/lib/rigor/effects/scanner.rb +2 -3
  112. data/lib/rigor/effects/signature_sources.rb +13 -2
  113. data/lib/rigor/effects/snapshot.rb +53 -21
  114. data/lib/rigor/effects/snapshot_diff.rb +26 -3
  115. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  116. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  117. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  118. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  119. data/lib/rigor/environment/rbs_loader.rb +525 -46
  120. data/lib/rigor/environment.rb +38 -15
  121. data/lib/rigor/inference/acceptance.rb +172 -20
  122. data/lib/rigor/inference/captured_locals.rb +62 -0
  123. data/lib/rigor/inference/content_join.rb +347 -0
  124. data/lib/rigor/inference/def_handle.rb +15 -3
  125. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  126. data/lib/rigor/inference/expression_typer.rb +1020 -141
  127. data/lib/rigor/inference/fork_map.rb +6 -1
  128. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  129. data/lib/rigor/inference/hkt_registry.rb +46 -0
  130. data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
  131. data/lib/rigor/inference/index_write_widening.rb +48 -0
  132. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  133. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  134. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  135. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  136. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  137. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  138. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  139. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  140. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  141. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  142. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  143. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  144. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  145. data/lib/rigor/inference/mutation_widening.rb +244 -174
  146. data/lib/rigor/inference/narrowing.rb +331 -27
  147. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  148. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  149. data/lib/rigor/inference/precision_scanner.rb +5 -2
  150. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  151. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  152. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  153. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  154. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  155. data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
  156. data/lib/rigor/inference/version_guard.rb +229 -0
  157. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  158. data/lib/rigor/plugin/base.rb +3 -2
  159. data/lib/rigor/plugin/inflector.rb +14 -5
  160. data/lib/rigor/plugin/io_boundary.rb +107 -4
  161. data/lib/rigor/plugin/loader.rb +14 -0
  162. data/lib/rigor/protection/analysis_guard.rb +60 -0
  163. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  164. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  165. data/lib/rigor/protection/discovery_seed.rb +1 -1
  166. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  167. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  168. data/lib/rigor/protection/mutator.rb +121 -18
  169. data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
  170. data/lib/rigor/reflection.rb +182 -30
  171. data/lib/rigor/scope/discovery_index.rb +39 -1
  172. data/lib/rigor/scope.rb +385 -4
  173. data/lib/rigor/sig_gen/classification.rb +12 -1
  174. data/lib/rigor/sig_gen/generator.rb +254 -5
  175. data/lib/rigor/sig_gen/renderer.rb +1 -6
  176. data/lib/rigor/sig_gen/writer.rb +3 -0
  177. data/lib/rigor/source/constant_path.rb +79 -2
  178. data/lib/rigor/triage/catalogue.rb +1 -1
  179. data/lib/rigor/type/combinator.rb +10 -0
  180. data/lib/rigor/type/maybe.rb +47 -0
  181. data/lib/rigor/type/refined.rb +1 -2
  182. data/lib/rigor/type/result.rb +53 -0
  183. data/lib/rigor/type.rb +2 -0
  184. data/lib/rigor/version.rb +1 -1
  185. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  186. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  187. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  188. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  189. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  190. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  191. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  192. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  193. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  194. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  195. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  196. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  197. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  198. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  199. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  200. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  201. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  202. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  203. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  204. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  205. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  206. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  207. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  208. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  209. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  210. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  211. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  212. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  213. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  214. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  215. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  216. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  217. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  218. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  219. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  220. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  221. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  222. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  223. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  224. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  225. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  226. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  227. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  228. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  229. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  230. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  231. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  232. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  233. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  234. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  235. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  236. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  237. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  238. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  239. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  240. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  241. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  242. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  243. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  244. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  245. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  246. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  247. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  248. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  249. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  250. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  251. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  252. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  253. data/sig/rigor/analysis/baseline.rbs +1 -1
  254. data/sig/rigor/environment.rbs +3 -0
  255. data/sig/rigor/inference.rbs +4 -1
  256. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  257. data/sig/rigor/reflection.rbs +3 -1
  258. data/sig/rigor/scope.rbs +25 -1
  259. data/sig/rigor/type.rbs +95 -1
  260. data/sig/rigor.rbs +16 -0
  261. data/skills/rigor-ci-setup/SKILL.md +2 -2
  262. data/skills/rigor-editor-setup/SKILL.md +2 -2
  263. data/skills/rigor-mcp-setup/SKILL.md +2 -2
  264. data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
  265. data/skills/rigor-plugin-review/SKILL.md +3 -3
  266. metadata +73 -3
@@ -18,19 +18,64 @@ module Rigor
18
18
  # Each STI child carries an `sti_parent:` pointer the {ModelIndex} uses to inherit the root model's
19
19
  # table and DSL surface.
20
20
  #
21
- # Returns rows the {ModelIndex} consumes:
21
+ # Returns rows the {ModelIndex} consumes — exactly ONE per model, whatever the number of source
22
+ # declarations: a reopened class contributes its DSL surface into the single row for that name
23
+ # ({#merge_redeclarations}), because a reopen ADDS to a class rather than replacing it.
22
24
  #
23
25
  # { class_name: "User", table_name_override: nil, sti_parent: nil, ... }
24
26
  # { class_name: "Admin", table_name_override: nil, sti_parent: "User", ... }
25
27
  #
28
+ # `class_name` (and `superclass_name`) is the constant path WITHOUT a leading `::` — `class ::User`
29
+ # yields `"User"`, exactly as `class User` does, and `class ::User` nested inside `module Admin`
30
+ # yields `"User"` rather than `"Admin::::User"`, because a rooted declaration names the top-level
31
+ # constant regardless of its lexical nesting. Every downstream key — the {ModelIndex} entries, the
32
+ # published `:model_index` fact, `Entry#class_name` — inherits this spelling, and every consumer
33
+ # anchors with the plain constant rendering (#583: the rooted `"::User"` key used to make all three
34
+ # Hash consumers miss such models silently).
35
+ #
26
36
  # Limitations (intentional for v0.1.0 of the plugin):
27
37
  #
28
38
  # - `self.table_name = "..."` recognised only when the RHS is a String literal. Computed names
29
39
  # (`self.table_name = "#{tenant}_users"`) are skipped.
30
40
  # - Modules (`class Admin::User < ApplicationRecord`) are recognised; the resulting class name is
31
- # the lexical path (`Admin::User`).
32
- # - The STI fixpoint matches a superclass name against model class names tolerating a leading `::`;
33
- # richer constant resolution (relative namespacing) is not modelled.
41
+ # the lexical path (`Admin::User`). {ModelIndex.inflected_table_name} derives its TABLE name from
42
+ # only the demodulized last segment, `table_name_prefix` / `table_name_suffix` (#623).
43
+ # - The STI fixpoint matches a superclass name against model class names by exact spelling; richer
44
+ # constant resolution (relative namespacing) is not modelled.
45
+ # - An ENCLOSING namespace's (module OR class — Rails' own ancestor search does not care which)
46
+ # `table_name_prefix` / `table_name_suffix` is recognised as a String literal on `def self.NAME`,
47
+ # `class << self; def NAME`, a plain `self.NAME = "..."` assignment, or one of
48
+ # {ACCESSOR_DECLARATION_METHODS}'s `default:` (a `*_writer` macro does NOT establish the reader
49
+ # Rails' walk probes, so it is not recognised as declaring anything). Any other shape a reader
50
+ # establishment was observed for (a computed RHS, disagreeing reopenings, or a plain assignment with
51
+ # no tracked reader — an unrecognised reader spelling such as `define_singleton_method`,
52
+ # `module_function`, `extend self`, or a hand-rolled ivar reader all fall into this last case) is
53
+ # treated as DECLARED-BUT-UNFOLDABLE — and, per {ModelIndex.inflected_table_name_unreliable?}, does
54
+ # NOT fall back to a guessed value: the whole model's column/alias/association checks stand down
55
+ # instead, because a wrong guess can corroborate a real-but-unrelated table exactly as easily as it
56
+ # can miss.
57
+ # - A model class NESTED INSIDE ANOTHER MODEL CLASS (`Post::Comment` where `Post < ApplicationRecord`
58
+ # — Rails splices the parent's own table name into the middle of the child's, a wholly different
59
+ # mechanism from prefix/suffix) is recognised and stands down the same way (#623 review). A model
60
+ # nested inside an ABSTRACT model reads its OWN demodulized name in real Rails (the splice only
61
+ # applies to a non-abstract parent) but this walker does not track `abstract_class?` and stands it
62
+ # down too — a coverage loss, never a false positive.
63
+ # - A namespace's decorator declared OUTSIDE `model_search_paths` entirely is INVISIBLE, not stood
64
+ # down — this walker only reads files under the configured model roots, so a `table_name_prefix`
65
+ # anywhere else (a plain `def self.table_name_prefix` in `lib/blog.rb`, a
66
+ # `Rails::Engine.isolate_namespace` call in `lib/<engine>/engine.rb`, or any other path
67
+ # `model_search_paths` does not cover) resolves the bare demodulized guess uncorrected, exactly as
68
+ # if nothing had been declared. `Rigor::Plugin::IoBoundary`'s trust policy would permit reading
69
+ # further than `model_search_paths` for this plugin (its `allowed_read_roots` covers the project
70
+ # root), so an EXISTENCE-only scan of a wider file set is reachable in principle; it is not
71
+ # implemented because the right file set is a real design choice (the whole project root? `lib/`?
72
+ # each `engines/*/{app/models,lib}`?) and reading outside `model_search_paths` needs its own cache
73
+ # descriptor / `watch:` wiring to invalidate correctly — see the `producer :model_index` call site's
74
+ # `watch:` block, keyed on `model_search_paths` today.
75
+ # - NOT modelled at all (pre-existing, unrelated to namespace prefixes): a model declaring
76
+ # `table_name_prefix` on ITSELF rather than an enclosing namespace, and a base class (e.g.
77
+ # `ApplicationRecord`) setting `self.table_name_prefix` for every model under it — both already
78
+ # guess wrong identically on unpatched code.
34
79
  class ModelDiscoverer
35
80
  # @param io_boundary [Rigor::Plugin::IoBoundary]
36
81
  # @param search_paths [Array<String>] absolute or project-relative paths.
@@ -38,11 +83,16 @@ module Rigor
38
83
  # Declaration macros whose column's runtime value is a rich object, not the SQL scalar. Their column
39
84
  # must NOT be narrowed to the schema type (see {ModelIndex.build}'s type-override remap).
40
85
  TYPE_OVERRIDE_METHODS = %i[serialize mount_uploader mount_uploaders].freeze
86
+ OUTSIDE_SEARCH_DIRECTORIES = %w[lib config engines].freeze
41
87
 
42
- def initialize(io_boundary:, search_paths:, base_classes:)
88
+ def initialize(io_boundary:, search_paths:, base_classes:, project_root: ".")
43
89
  @io_boundary = io_boundary
44
90
  @search_paths = search_paths
45
- @base_classes = base_classes.to_set
91
+ @project_root = project_root
92
+ # De-rooted for the same reason superclass names are ({#visit_class}): the match is by exact
93
+ # spelling, so a configured `model_base_classes: ["::ApplicationRecord"]` would otherwise never
94
+ # match anything a declaration can render.
95
+ @base_classes = base_classes.to_set { |name| strip_root(name.to_s) }
46
96
  # Global set of column names whose declared runtime type overrides the schema scalar
47
97
  # (`serialize` / `mount_uploader` / `attribute :x, CustomType`). Collected across every class AND
48
98
  # concern module walked — a serialize inside a concern's `included do … end` is invisible to the
@@ -50,6 +100,15 @@ module Rigor
50
100
  # column name and applied wherever that column appears. Over-suppresses a same-named scalar
51
101
  # column elsewhere (a precision cost, never a false positive).
52
102
  @type_override_columns = Set.new
103
+ # `full namespace name => { prefix: decorator?, suffix: decorator? }` — what each walked module OR
104
+ # class body declares about `table_name_prefix` / `table_name_suffix` (#623; keyed by full name
105
+ # regardless of whether the source spelled the namespace `module` or `class` — Rails'
106
+ # `module_parents` ancestor walk does not care either). A `decorator` is `{ literal: }` when
107
+ # foldable to a String, `{ computed: true }` when the namespace declares the name in a shape this
108
+ # walker cannot fold. A key absent from the inner Hash means the namespace says nothing about that
109
+ # name — {#resolve_table_name_decorator} keeps walking outward past it, exactly like Rails' own
110
+ # `respond_to?(:table_name_prefix)` ancestor search.
111
+ @namespace_table_name_decorators = {}
53
112
  end
54
113
 
55
114
  attr_reader :type_override_columns
@@ -64,7 +123,12 @@ module Rigor
64
123
  tree = Prism.parse(contents).value
65
124
  walk_for_classes(tree, []) { |candidate| candidates << candidate }
66
125
  end
67
- resolve_models(candidates)
126
+ scan_outside_decorators
127
+ superclass_map = build_superclass_map(candidates)
128
+ # Every file is walked (and every module's `table_name_prefix` / `table_name_suffix` recorded)
129
+ # before any row is resolved — a model's file may sort, and so be visited, before the file that
130
+ # declares its enclosing module's decorator.
131
+ attach_table_name_decorators(resolve_models(candidates), superclass_map: superclass_map)
68
132
  end
69
133
 
70
134
  private
@@ -101,21 +165,189 @@ module Rigor
101
165
  break unless added
102
166
  end
103
167
 
104
- candidates.filter_map do |candidate|
168
+ rows = candidates.filter_map do |candidate|
105
169
  name = candidate[:class_name]
106
170
  next unless model_names.key?(name)
107
171
 
108
172
  candidate.merge(sti_parent: sti_parent[name])
109
173
  end
174
+ merge_redeclarations(rows)
110
175
  end
111
176
 
112
- # Resolves a superclass NAME against the set of known model class names, tolerating a leading
113
- # `::`. Returns the matched model class name, or nil.
114
- def model_match(superclass_name, model_names)
115
- return superclass_name if model_names.key?(superclass_name)
177
+ # Collapses the rows that declare the SAME constant into one, so {ModelIndex.build} which keys
178
+ # its entries by `class_name` receives at most one row per model.
179
+ #
180
+ # A model is routinely declared more than once: `app/models/user.rb` holds `class User <
181
+ # ApplicationRecord` and a second file reopens it (`class ::User`, `class User`, or a full
182
+ # `class User < ApplicationRecord` redeclaration) to add a method. Ruby's own semantics are
183
+ # ADDITIVE — a reopen contributes what it spells and leaves the rest of the class alone — so the
184
+ # rows are UNIONed here. Taking the last row instead dropped the real declaration's associations,
185
+ # scopes, enums, validations, callbacks and `alias_attribute`s whenever the reopen sorted later in
186
+ # the glob, and `where(<a declared alias>: …)` then surfaced a false `unknown-column` on correct
187
+ # code; taking the first dropped whatever the reopen added. Neither order loses anything now.
188
+ #
189
+ # Merge is by field: the first non-nil `superclass_name` wins (a second declaration is the same
190
+ # thing, or invalid Ruby); `self.table_name =` is an ASSIGNMENT, so the LATER declaration's
191
+ # `table_name_override` wins as it does at load time, and when the two declarations disagree the
192
+ # resolved name is marked computed so no value is pinned — the glob order is not the load order for
193
+ # two full declarations, and a pinned wrong literal folds `Model.table_name == "…"` on correct code;
194
+ # `table_name_computed` is otherwise an OR (any computed name in the class makes the resolved one
195
+ # inexact); name-keyed rows (associations, enums, aliases) let the LAST declaration override an
196
+ # earlier same-name row exactly as {ModelIndex.merge_named_rows} does across an STI chain, and the
197
+ # plain lists union.
198
+ def merge_redeclarations(rows)
199
+ return rows if rows.length < 2
200
+
201
+ rows.each_with_object({}) do |row, acc|
202
+ name = row.fetch(:class_name)
203
+ acc[name] = acc.key?(name) ? merged_row(acc[name], row) : row
204
+ end.values
205
+ end
206
+
207
+ # `base` is the earlier declaration, `addition` the later one. See {#merge_redeclarations}.
208
+ def merged_row(base, addition)
209
+ base.merge(
210
+ superclass_name: base[:superclass_name] || addition[:superclass_name],
211
+ abstract_class: base[:abstract_class] || addition[:abstract_class],
212
+ sti_parent: base[:sti_parent] || addition[:sti_parent],
213
+ table_name_override: addition[:table_name_override] || base[:table_name_override],
214
+ table_name_computed: base[:table_name_computed] || addition[:table_name_computed] ||
215
+ conflicting_table_names?(base, addition),
216
+ associations: dedup_named_rows(Array(base[:associations]) + Array(addition[:associations])),
217
+ enums: (base[:enums] || {}).merge(addition[:enums] || {}),
218
+ scopes: (Array(base[:scopes]) + Array(addition[:scopes])).uniq,
219
+ validations: (Array(base[:validations]) + Array(addition[:validations])).uniq,
220
+ callbacks: (Array(base[:callbacks]) + Array(addition[:callbacks])).uniq,
221
+ aliases: (base[:aliases] || {}).merge(addition[:aliases] || {})
222
+ )
223
+ end
224
+
225
+ # Two literal `self.table_name =` assignments that disagree: see {#merge_redeclarations}.
226
+ def conflicting_table_names?(base, addition)
227
+ a = base[:table_name_override]
228
+ b = addition[:table_name_override]
229
+ !a.nil? && !b.nil? && a != b
230
+ end
231
+
232
+ # Keeps the LAST row per `:name`, matching {ModelIndex.merge_named_rows}'s override rule.
233
+ def dedup_named_rows(rows)
234
+ rows.to_h { |row| [row[:name], row] }.values
235
+ end
236
+
237
+ # Resolves each model row's `table_name_prefix:` / `table_name_suffix:` / `table_name_nested_in_model:`
238
+ # (#623, #671, #678, #679) against enclosing namespaces, model's own decorators, superclasses, and the
239
+ # full discovered model set.
240
+ #
241
+ # Deliberately does NOT fold any of this into `table_name_computed` — that flag gates
242
+ # `ModelIndex.table_name_exact?`, which only ever matters when a SOURCE-DECLARED literal
243
+ # (`self.table_name = "…"`) exists, and a literal override bypasses Rails' whole
244
+ # prefix/suffix/nesting computation at runtime. An unrelated namespace's unreadable decorator has no
245
+ # bearing on whether that literal is what the app actually uses. What DOES depend on these three
246
+ # fields is whether {ModelIndex.build} trusts the INFLECTED name enough to look up its columns —
247
+ # see {ModelIndex.inflected_table_name_unreliable?}.
248
+ def attach_table_name_decorators(rows, superclass_map: {})
249
+ models_by_name = rows.to_h { |row| [row.fetch(:class_name), row] }
250
+ rows.map do |row|
251
+ class_name = row.fetch(:class_name)
252
+ row.merge(
253
+ table_name_prefix: resolve_table_name_decorator(class_name, :prefix, superclass_map: superclass_map),
254
+ table_name_suffix: resolve_table_name_decorator(class_name, :suffix, superclass_map: superclass_map),
255
+ table_name_nested_in_model: nested_in_model_class?(class_name, models_by_name)
256
+ )
257
+ end
258
+ end
259
+
260
+ # The decorator (`{ literal: }` / `{ computed: true }`) for `key`:
261
+ # 1. First checks enclosing namespaces outward (mirroring Rails' `full_table_name_prefix`
262
+ # `module_parents` ancestor search).
263
+ # 2. Checks the model's own class decorator.
264
+ # 3. Walks the superclass hierarchy via `superclass_map` (e.g. `ApplicationRecord` or base classes).
265
+ # Falls back to `{ literal: "" }` (AR::Base's own default) when nothing says anything about `key`.
266
+ def resolve_table_name_decorator(class_name, key, superclass_map: {})
267
+ namespace_ancestors(class_name).each do |namespace_name|
268
+ decorator = @namespace_table_name_decorators[namespace_name]
269
+ next unless decorator&.key?(key)
270
+
271
+ return decorator[key]
272
+ end
273
+
274
+ decorator = @namespace_table_name_decorators[class_name]
275
+ return decorator[key] if decorator&.key?(key)
276
+
277
+ curr = superclass_map[class_name]
278
+ visited = Set.new([class_name])
279
+ while curr && !visited.include?(curr)
280
+ visited << curr
281
+ decorator = @namespace_table_name_decorators[curr]
282
+ return decorator[key] if decorator&.key?(key)
283
+
284
+ curr = superclass_map[curr]
285
+ end
286
+
287
+ { literal: "" }
288
+ end
289
+
290
+ def build_superclass_map(candidates)
291
+ candidates_by_name = candidates.to_h { |c| [c[:class_name], c] }
292
+ superclass_map = {}
293
+ candidates.each do |c|
294
+ name = c[:class_name]
295
+ super_name = resolve_superclass_name(c, candidates_by_name)
296
+ superclass_map[name] ||= super_name if super_name
297
+ end
298
+ superclass_map
299
+ end
300
+
301
+ def resolve_superclass_name(candidate, candidates_by_name)
302
+ super_name = candidate[:superclass_name]
303
+ return nil if super_name.nil?
304
+
305
+ return super_name if candidates_by_name.key?(super_name) || @base_classes.include?(super_name)
306
+
307
+ segments = candidate[:class_name].split("::")
308
+ (segments.length - 1).downto(1).each do |n|
309
+ prefix = segments.first(n).join("::")
310
+ candidate_super = "#{prefix}::#{super_name}"
311
+ return candidate_super if candidates_by_name.key?(candidate_super)
312
+ end
313
+
314
+ super_name
315
+ end
316
+
317
+ # `"Foo::Bar::Post"` → `["Foo::Bar", "Foo"]`, nearest first; a top-level `"Post"` → `[]`. Derived
318
+ # purely from the (already de-rooted) class-name String, so it matches regardless of whether the
319
+ # source nested the containing namespaces (`module Foo; module Bar` … / `class Foo; class Bar` …)
320
+ # or wrote one compactly (`module Foo::Bar`).
321
+ def namespace_ancestors(class_name)
322
+ segments = class_name.split("::")
323
+ (segments.length - 1).downto(1).map { |n| segments.first(n).join("::") }
324
+ end
325
+
326
+ # Whether `class_name`'s IMMEDIATE lexical parent is itself a non-abstract discovered model —
327
+ # `Post::Comment` where `Post < ApplicationRecord` and `Post` is not abstract. Rails'
328
+ # `compute_table_name` special-cases exactly this shape: it splices
329
+ # `"#{parent.table_name.singularize}_"` into the middle of the name, entirely separately from the
330
+ # `table_name_prefix` / `table_name_suffix` mechanism above (`ActiveRecord::ModelSchema::ClassMethods#compute_table_name`'s
331
+ # `module_parent < Base && !module_parent.abstract_class?` arm).
332
+ #
333
+ # Abstract parents (`self.abstract_class = true` or `primary_abstract_class`) do NOT trigger this
334
+ # containment rule in Rails — e.g. `Base::Comment` inflects to `"comments"` with normal live columns.
335
+ def nested_in_model_class?(class_name, models_by_name)
336
+ segments = class_name.split("::")
337
+ return false if segments.length < 2
116
338
 
117
- stripped = superclass_name.sub(/\A::/, "")
118
- model_names.key?(stripped) ? stripped : nil
339
+ parent_name = segments[0..-2].join("::")
340
+ parent = models_by_name[parent_name]
341
+ return false if parent.nil?
342
+
343
+ parent[:abstract_class] != true
344
+ end
345
+
346
+ # Resolves a superclass NAME against the set of known model class names. Both sides come out of
347
+ # {#declared_constant_name}, so neither carries a leading `::` and the match is by exact spelling.
348
+ # Returns the matched model class name, or nil.
349
+ def model_match(superclass_name, model_names)
350
+ model_names.key?(superclass_name) ? superclass_name : nil
119
351
  end
120
352
 
121
353
  def read_safely(path)
@@ -127,7 +359,8 @@ module Rigor
127
359
  def ruby_files_under(roots)
128
360
  roots.flat_map do |root|
129
361
  absolute = File.expand_path(root)
130
- next [] unless File.directory?(absolute)
362
+ # ADR-45 WD1b (#613) — boundary-probed: a root that appears later invalidates the warm run.
363
+ next [] unless @io_boundary.directory?(absolute)
131
364
 
132
365
  Dir.glob(File.join(absolute, "**", "*.rb"))
133
366
  end
@@ -141,11 +374,45 @@ module Rigor
141
374
  visit_class(node, lexical_path, &)
142
375
  when Prism::ModuleNode
143
376
  visit_module(node, lexical_path, &)
377
+ when Prism::CallNode
378
+ handle_top_level_call(node, lexical_path)
379
+ when Prism::DefNode
380
+ handle_top_level_def(node)
144
381
  else
145
382
  node.rigor_each_child { |child| walk_for_classes(child, lexical_path, &) }
146
383
  end
147
384
  end
148
385
 
386
+ def handle_top_level_call(node, lexical_path)
387
+ if node.name == :isolate_namespace
388
+ target = isolate_namespace_target(node, lexical_path.first)
389
+ if target
390
+ @namespace_table_name_decorators[target] ||= {}
391
+ @namespace_table_name_decorators[target][:prefix] = { computed: true }
392
+ end
393
+ elsif node.receiver && %i[table_name_prefix= table_name_suffix=].include?(node.name)
394
+ target = strip_root(constant_path_name(node.receiver))
395
+ if target
396
+ key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
397
+ @namespace_table_name_decorators[target] ||= {}
398
+ @namespace_table_name_decorators[target][key] = literal_assignment_value(node)
399
+ end
400
+ end
401
+ end
402
+
403
+ def handle_top_level_def(node)
404
+ return unless node.receiver
405
+
406
+ target = strip_root(constant_path_name(node.receiver))
407
+ return unless target
408
+
409
+ key = TABLE_NAME_DECORATOR_METHODS[node.name]
410
+ return unless key
411
+
412
+ @namespace_table_name_decorators[target] ||= {}
413
+ @namespace_table_name_decorators[target][key] = literal_def_value(node.body)
414
+ end
415
+
149
416
  # Captures EVERY class declaration as a candidate — the `resolve_models` fixpoint decides
150
417
  # afterwards which ones are models. The DSL metadata is extracted eagerly; for a non-model class
151
418
  # it is simply discarded when the candidate is dropped.
@@ -153,15 +420,22 @@ module Rigor
153
420
  class_local_name = constant_path_name(node.constant_path)
154
421
  return if class_local_name.nil?
155
422
 
156
- full_name = (lexical_path + [class_local_name]).join("::")
157
- superclass = constant_path_name(node.superclass) if node.superclass
423
+ full_name = declared_constant_name(class_local_name, lexical_path)
424
+ superclass = strip_root(constant_path_name(node.superclass)) if node.superclass
158
425
 
159
426
  collect_type_overrides(node.body)
427
+ # Rails' `full_table_name_prefix` walks `module_parents.detect { respond_to?(:table_name_prefix) }`
428
+ # — lexical nesting, which does not care whether the namespace container was written `module Blog`
429
+ # or `class Blog`. A `class` used purely as a namespace holder (or a model class that happens to
430
+ # ALSO declare a decorator for its own nested classes) must be scanned the same way a module is.
431
+ record_table_name_decorators(full_name, node.body, is_class: true)
160
432
 
161
433
  yield({
162
434
  class_name: full_name,
163
435
  superclass_name: superclass,
436
+ abstract_class: abstract_class?(node.body),
164
437
  table_name_override: lookup_table_name_override(node.body),
438
+ table_name_computed: table_name_computed?(node.body),
165
439
  associations: lookup_associations(node.body),
166
440
  enums: lookup_enums(node.body),
167
441
  scopes: lookup_scopes(node.body),
@@ -171,8 +445,7 @@ module Rigor
171
445
  })
172
446
 
173
447
  # Recurse into the body in case nested classes exist.
174
- inner_path = lexical_path + [class_local_name]
175
- walk_for_classes(node.body, inner_path, &) if node.body
448
+ walk_for_classes(node.body, [full_name], &) if node.body
176
449
  end
177
450
 
178
451
  def visit_module(node, lexical_path, &)
@@ -183,10 +456,28 @@ module Rigor
183
456
  # `included do … end` block; collect their overrides even though the module itself is not a model.
184
457
  collect_type_overrides(node.body)
185
458
 
186
- inner_path = lexical_path + [module_local_name]
459
+ full_name = declared_constant_name(module_local_name, lexical_path)
460
+ record_table_name_decorators(full_name, node.body, is_class: false)
461
+
462
+ inner_path = [full_name]
187
463
  walk_for_classes(node.body, inner_path, &) if node.body
188
464
  end
189
465
 
466
+ # The full constant name a `class` / `module` declaration defines, given the rendered local name
467
+ # and the enclosing lexical path. A ROOTED local name (`class ::User`, `module ::Admin`) names the
468
+ # top-level constant whatever the nesting, so the lexical path is dropped and the `::` with it;
469
+ # otherwise the name is appended to the path (`class User` inside `module Admin` → `Admin::User`).
470
+ def declared_constant_name(local_name, lexical_path)
471
+ return strip_root(local_name) if local_name.start_with?("::")
472
+
473
+ (lexical_path + [local_name]).join("::")
474
+ end
475
+
476
+ # `::User` → `User`; a name without the root marker is returned unchanged (nil stays nil).
477
+ def strip_root(name)
478
+ name&.delete_prefix("::")
479
+ end
480
+
190
481
  # Records the column name of every `serialize :col` / `mount_uploader(s) :col` / `attribute :col,
191
482
  # CustomType` in `body` (descending into `with_options` and concern `included do` blocks) into the
192
483
  # global {#type_override_columns} set. `attribute :col, :symbol_type` (a built-in scalar type) is
@@ -229,8 +520,9 @@ module Rigor
229
520
  type_arg.is_a?(Prism::ConstantReadNode) || type_arg.is_a?(Prism::ConstantPathNode)
230
521
  end
231
522
 
232
- # Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String. Returns nil for
233
- # shapes the discoverer chooses not to handle.
523
+ # Renders a constant-path node (`Admin::User`, `::ApplicationRecord`) as a String, keeping the
524
+ # leading `::` of a rooted path — {#declared_constant_name} reads it as "reset the lexical path"
525
+ # before the marker is dropped. Returns nil for shapes the discoverer chooses not to handle.
234
526
  def constant_path_name(node)
235
527
  return nil if node.nil?
236
528
 
@@ -268,6 +560,490 @@ module Rigor
268
560
  nil
269
561
  end
270
562
 
563
+ # Whether the class computes its own table name in a way this walker cannot read off the source:
564
+ # `def self.table_name`, a `table_name` def inside `class << self`, or `self.table_name =` with a
565
+ # non-literal RHS (`"#{tenant}_users"`).
566
+ #
567
+ # The resolved name is unaffected — it still falls back to the inflection, and a wrong inflection
568
+ # already degrades harmlessly (no matching table → no columns → the analyzer stays silent). What
569
+ # this flag protects is the {ModelIndex::Entry#table_name_exact?} claim, which licenses PINNING the
570
+ # name to a value. Without it, a computed override on a class whose inflected name happens to match
571
+ # some table in the schema would read as corroborated, and `Model.table_name == "..."` would then
572
+ # fold against a name the application does not use.
573
+ def table_name_computed?(body)
574
+ return false if body.nil?
575
+
576
+ body.rigor_each_child do |node|
577
+ return true if singleton_table_name_def?(node)
578
+ return true if singleton_class_defines_table_name?(node)
579
+ return true if non_literal_table_name_assignment?(node)
580
+ end
581
+ false
582
+ end
583
+
584
+ # `def self.table_name` — a DefNode with an explicit `self` receiver.
585
+ def singleton_table_name_def?(node)
586
+ node.is_a?(Prism::DefNode) && node.name == :table_name && node.receiver.is_a?(Prism::SelfNode)
587
+ end
588
+
589
+ # `class << self; def table_name; …; end; end` — the other spelling of the same override.
590
+ def singleton_class_defines_table_name?(node)
591
+ return false unless node.is_a?(Prism::SingletonClassNode)
592
+ return false unless node.body
593
+
594
+ node.body.rigor_each_child do |inner|
595
+ return true if inner.is_a?(Prism::DefNode) && inner.name == :table_name && inner.receiver.nil?
596
+ end
597
+ false
598
+ end
599
+
600
+ # `self.table_name = <anything but a String literal>`. The literal form is the one
601
+ # {#lookup_table_name_override} reads; everything else is a name only the running app knows.
602
+ def non_literal_table_name_assignment?(node)
603
+ return false unless node.is_a?(Prism::CallNode) && node.name == :table_name=
604
+ return false unless node.receiver.is_a?(Prism::SelfNode)
605
+
606
+ !node.arguments&.arguments&.first.is_a?(Prism::StringNode)
607
+ end
608
+
609
+ # Whether a class declares itself abstract via `self.abstract_class = true` (or `abstract_class = true`)
610
+ # or Rails 7+'s `primary_abstract_class` (#679). Abstract model classes are skipped by Rails' nested-model
611
+ # containment prefixing rule in `compute_table_name`.
612
+ def abstract_class?(body)
613
+ return false if body.nil?
614
+
615
+ body.rigor_each_child do |node|
616
+ case node
617
+ when Prism::CallNode
618
+ if (node.name == :abstract_class=) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
619
+ arg = node.arguments&.arguments&.first
620
+ return true if arg.is_a?(Prism::TrueNode)
621
+ elsif (node.name == :primary_abstract_class) && (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
622
+ return true
623
+ end
624
+ when Prism::SingletonClassNode
625
+ node.body&.rigor_each_child do |inner|
626
+ next unless inner.is_a?(Prism::CallNode)
627
+
628
+ if (inner.name == :abstract_class=) && (inner.receiver.nil? || inner.receiver.is_a?(Prism::SelfNode))
629
+ arg = inner.arguments&.arguments&.first
630
+ return true if arg.is_a?(Prism::TrueNode)
631
+ elsif (inner.name == :primary_abstract_class) && (inner.receiver.nil? || inner.receiver.is_a?(Prism::SelfNode))
632
+ return true
633
+ end
634
+ end
635
+ end
636
+ end
637
+ false
638
+ end
639
+
640
+ # `table_name_prefix` / `table_name_suffix`, keyed by the setter/reader name a namespace (module OR
641
+ # class) body can declare either one under (#623). Rails' own table-name computation
642
+ # (`ActiveRecord::ModelSchema::ClassMethods#full_table_name_prefix` / `#full_table_name_suffix`)
643
+ # walks a namespaced model's enclosing namespaces outward via `module_parents` and asks each
644
+ # `respond_to?(:table_name_prefix)` — a plain `Blog::Post` reads `posts` unless `Blog` answers that
645
+ # question itself, whether `Blog` was declared `module Blog` or `class Blog`.
646
+ TABLE_NAME_DECORATOR_METHODS = { table_name_prefix: :prefix, table_name_suffix: :suffix }.freeze
647
+ private_constant :TABLE_NAME_DECORATOR_METHODS
648
+
649
+ # The Rails/ActiveSupport macros that can declare a `table_name_prefix` / `table_name_suffix` READER
650
+ # without a hand-written `def self.…`. Rails' walk tests `respond_to?(:table_name_prefix)` — the
651
+ # READER — so this is every macro verified (against bundled `activesupport`) to define one:
652
+ # `mattr_accessor` / `mattr_reader`, their `cattr_*` literal aliases, the `thread_mattr_*` /
653
+ # `thread_cattr_*` thread-local family, and `class_attribute` (valid on a class namespace now that
654
+ # {#visit_class} scans class bodies too — it is defined on `Class`, not `Module`). The `*_writer`
655
+ # spellings (`mattr_writer`, `cattr_writer`, `thread_mattr_writer`, `thread_cattr_writer`) are
656
+ # deliberately EXCLUDED: each defines only `self.#{sym}=` (verified against ActiveSupport's
657
+ # `attribute_accessors.rb` / `attribute_accessors_per_thread.rb`), no reader, so a module declaring
658
+ # only one of those is invisible to `full_table_name_prefix`'s ancestor search exactly as if it had
659
+ # declared nothing.
660
+ ACCESSOR_DECLARATION_METHODS = %i[
661
+ mattr_accessor mattr_reader
662
+ cattr_accessor cattr_reader
663
+ thread_mattr_accessor thread_mattr_reader
664
+ thread_cattr_accessor thread_cattr_reader
665
+ class_attribute
666
+ ].freeze
667
+ private_constant :ACCESSOR_DECLARATION_METHODS
668
+
669
+ # Scans a module OR class BODY for `table_name_prefix` / `table_name_suffix` declarations and
670
+ # records what it finds into `@namespace_table_name_decorators`, merged with any earlier reopening
671
+ # of the same namespace.
672
+ def record_table_name_decorators(full_name, body, is_class: false)
673
+ found = table_name_decorators(body, is_class: is_class)
674
+ return if found.empty?
675
+
676
+ existing = @namespace_table_name_decorators[full_name]
677
+ @namespace_table_name_decorators[full_name] =
678
+ existing.nil? ? found : merge_table_name_decorators(existing, found)
679
+ end
680
+
681
+ # Walks the namespace body's TOP-LEVEL statements only — a decorator declared inside a nested
682
+ # `module`/`class` belongs to THAT namespace, not this one, and must not be attributed here.
683
+ # Returns `Hash<:prefix|:suffix => { literal: } | { computed: true }>`, only for the names this body
684
+ # actually mentions. A later statement overrides an earlier one within the SAME body — real Ruby
685
+ # assignment order, not a guess: `mattr_accessor :table_name_prefix` (no `default:`, so `computed:
686
+ # true`) followed two lines down by a real `self.table_name_prefix = "blog_"` folds to the literal.
687
+ #
688
+ # For a class (`is_class: true`), `ActiveRecord::Base` already defines reader accessors for
689
+ # `table_name_prefix` and `table_name_suffix` (#671), so a plain assignment `self.NAME = "…"` folds
690
+ # directly to the literal value without requiring an explicit reader declaration.
691
+ #
692
+ # For a module (`is_class: false`), plain assignment folds to the literal ONLY when a reader was
693
+ # established earlier in the same body. An unrecognized or missing reader folds to `computed: true`.
694
+ #
695
+ # Four exotic reader spellings are recognized without assignment (#679):
696
+ # 1. Singleton `attr_reader` (`class << self; attr_reader :table_name_prefix; end`) coupled with `@table_name_prefix = "…"`.
697
+ # 2. `define_singleton_method(:table_name_prefix) { "…" }`.
698
+ # 3. `module_function :table_name_prefix` or `module_function def table_name_prefix = "…"`.
699
+ # 4. `extend self` with `def table_name_prefix = "…"`.
700
+ def table_name_decorators(body, is_class: false)
701
+ return {} if body.nil?
702
+
703
+ decorators = {}
704
+ readers = Set.new
705
+ singleton_attr_readers = Set.new
706
+ ivars = {}
707
+ instance_methods = {}
708
+ extend_self = false
709
+ module_function_all = false
710
+
711
+ body.rigor_each_child do |node|
712
+ case node
713
+ when Prism::DefNode
714
+ if node.receiver.is_a?(Prism::SelfNode)
715
+ key = TABLE_NAME_DECORATOR_METHODS[node.name]
716
+ if key
717
+ readers << key
718
+ decorators[key] = literal_def_value(node.body)
719
+ end
720
+ elsif node.receiver.nil?
721
+ key = TABLE_NAME_DECORATOR_METHODS[node.name]
722
+ if key
723
+ val = literal_def_value(node.body)
724
+ instance_methods[key] = val
725
+ if extend_self || module_function_all
726
+ readers << key
727
+ decorators[key] = val
728
+ end
729
+ end
730
+ end
731
+ when Prism::InstanceVariableWriteNode
732
+ ivar_name = node.name.to_s.delete_prefix("@").to_sym
733
+ key = TABLE_NAME_DECORATOR_METHODS[ivar_name]
734
+ if key
735
+ val = node.value.is_a?(Prism::StringNode) ? { literal: node.value.unescaped } : { computed: true }
736
+ ivars[key] = val
737
+ decorators[key] = val if singleton_attr_readers.include?(key)
738
+ end
739
+ when Prism::CallNode
740
+ if node.receiver.is_a?(Prism::SelfNode) && node.name.to_s.end_with?("=")
741
+ key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
742
+ if key
743
+ decorators[key] = readers.include?(key) || is_class ? literal_assignment_value(node) : { computed: true }
744
+ end
745
+ elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
746
+ ACCESSOR_DECLARATION_METHODS.include?(node.name)
747
+ found = mattr_decorator_values(node)
748
+ readers.merge(found.keys)
749
+ decorators.merge!(found)
750
+ elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) &&
751
+ node.name == :define_singleton_method
752
+ arg0 = node.arguments&.arguments&.first
753
+ name = Rigor::Source::Literals.symbol_name(arg0) || (arg0.is_a?(Prism::StringNode) ? arg0.unescaped : nil)
754
+ key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
755
+ if key
756
+ readers << key
757
+ decorators[key] = node.block.is_a?(Prism::BlockNode) ? literal_def_value(node.block.body) : { computed: true }
758
+ end
759
+ elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) && node.name == :extend
760
+ arg = node.arguments&.arguments&.first
761
+ if arg.is_a?(Prism::SelfNode)
762
+ extend_self = true
763
+ instance_methods.each do |k, v|
764
+ readers << k
765
+ decorators[k] = v
766
+ end
767
+ end
768
+ elsif (node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)) && node.name == :module_function
769
+ args = node.arguments&.arguments || []
770
+ if args.empty?
771
+ module_function_all = true
772
+ instance_methods.each do |k, v|
773
+ readers << k
774
+ decorators[k] = v
775
+ end
776
+ else
777
+ args.each do |arg|
778
+ if arg.is_a?(Prism::DefNode)
779
+ k = TABLE_NAME_DECORATOR_METHODS[arg.name]
780
+ if k
781
+ readers << k
782
+ decorators[k] = literal_def_value(arg.body)
783
+ end
784
+ else
785
+ sym = Rigor::Source::Literals.symbol_name(arg) || (arg.is_a?(Prism::StringNode) ? arg.unescaped : nil)
786
+ k = TABLE_NAME_DECORATOR_METHODS[sym.to_sym] if sym
787
+ if k
788
+ readers << k
789
+ decorators[k] = instance_methods[k] || { computed: true }
790
+ end
791
+ end
792
+ end
793
+ end
794
+ end
795
+ when Prism::SingletonClassNode
796
+ found_decorators, found_readers, found_attr_readers = singleton_class_decorator_values(node, ivars)
797
+ readers.merge(found_readers)
798
+ singleton_attr_readers.merge(found_attr_readers)
799
+ decorators.merge!(found_decorators)
800
+ end
801
+ end
802
+ decorators
803
+ end
804
+
805
+ # `class << self; def table_name_prefix = "blog_"; end; end` or `class << self; attr_reader :table_name_prefix; end`
806
+ # (#623, #679).
807
+ def singleton_class_decorator_values(node, ivars = {})
808
+ return [{}, Set.new, Set.new] unless node.body
809
+
810
+ decorators = {}
811
+ readers = Set.new
812
+ attr_readers = Set.new
813
+
814
+ node.body.rigor_each_child do |inner|
815
+ case inner
816
+ when Prism::DefNode
817
+ next unless inner.receiver.nil?
818
+
819
+ key = TABLE_NAME_DECORATOR_METHODS[inner.name]
820
+ if key
821
+ readers << key
822
+ decorators[key] = literal_def_value(inner.body)
823
+ end
824
+ when Prism::CallNode
825
+ next unless inner.receiver.nil? && inner.name == :attr_reader
826
+
827
+ args = inner.arguments&.arguments || []
828
+ args.each do |arg|
829
+ name = Rigor::Source::Literals.symbol_name(arg) || (arg.is_a?(Prism::StringNode) ? arg.unescaped : nil)
830
+ key = TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
831
+ next unless key
832
+
833
+ readers << key
834
+ attr_readers << key
835
+ decorators[key] = ivars[key] || { computed: true }
836
+ end
837
+ when Prism::InstanceVariableWriteNode
838
+ ivar_name = inner.name.to_s.delete_prefix("@").to_sym
839
+ key = TABLE_NAME_DECORATOR_METHODS[ivar_name]
840
+ if key
841
+ val = inner.value.is_a?(Prism::StringNode) ? { literal: inner.value.unescaped } : { computed: true }
842
+ ivars[key] = val
843
+ decorators[key] = val if attr_readers.include?(key)
844
+ end
845
+ end
846
+ end
847
+
848
+ [decorators, readers, attr_readers]
849
+ end
850
+
851
+ # `def self.table_name_prefix = "blog_"` and the equivalent regular-method spelling both parse to a
852
+ # `DefNode` whose `body` is a one-statement `StatementsNode` — fold when that statement is a String
853
+ # literal. An empty body (`def self.table_name_prefix; end`), a multi-statement body, or a
854
+ # non-literal single statement (string interpolation, a method call) all decline: the module DOES
855
+ # declare the name, this walker just cannot read off its value.
856
+ def literal_def_value(def_body)
857
+ return { computed: true } if def_body.nil?
858
+
859
+ statements = def_body.body
860
+ return { computed: true } unless statements.size == 1 && statements.first.is_a?(Prism::StringNode)
861
+
862
+ { literal: statements.first.unescaped }
863
+ end
864
+
865
+ # `self.table_name_prefix = "blog_"` — the plain-assignment spelling (with or without a preceding
866
+ # `mattr_accessor` establishing the writer). Declines to `computed: true` for a non-literal RHS.
867
+ def literal_assignment_value(node)
868
+ arg = node.arguments&.arguments&.first
869
+ return { literal: arg.unescaped } if arg.is_a?(Prism::StringNode)
870
+
871
+ { computed: true }
872
+ end
873
+
874
+ # `mattr_accessor :table_name_prefix, :table_name_suffix, default: "blog_"` — every
875
+ # {ACCESSOR_DECLARATION_METHODS} macro shares this same call shape: one `default:` across every
876
+ # Symbol name the call declares. Returns a Hash keyed by the `:prefix` / `:suffix` names this call
877
+ # actually declares; a name with no literal `default:` folds to `{ computed: true }` rather than
878
+ # assuming the runtime default of `nil` (a LATER plain assignment in the same body can still upgrade
879
+ # it — see {#table_name_decorators}).
880
+ def mattr_decorator_values(node)
881
+ args = node.arguments&.arguments
882
+ return {} if args.nil?
883
+
884
+ keys = args.filter_map do |arg|
885
+ name = Rigor::Source::Literals.symbol_name(arg)
886
+ TABLE_NAME_DECORATOR_METHODS[name.to_sym] if name
887
+ end
888
+ return {} if keys.empty?
889
+
890
+ value = mattr_default_value(args)
891
+ keys.to_h { |key| [key, value] }
892
+ end
893
+
894
+ def mattr_default_value(args)
895
+ hash_arg = args.find { |arg| arg.is_a?(Prism::KeywordHashNode) }
896
+ return { computed: true } if hash_arg.nil?
897
+
898
+ pair = hash_arg.elements.find do |el|
899
+ el.is_a?(Prism::AssocNode) && Rigor::Source::Literals.symbol_named?(el.key, "default")
900
+ end
901
+ return { computed: true } if pair.nil? || !pair.value.is_a?(Prism::StringNode)
902
+
903
+ { literal: pair.value.unescaped }
904
+ end
905
+
906
+ # Merges the decorator findings of two reopenings of the SAME module. A key present on only one
907
+ # side wins outright; a key BOTH sides declare wins only when they agree (the same literal) — a
908
+ # cross-file "later wins" order is not derivable (glob order isn't load order, the same reasoning
909
+ # {#conflicting_table_names?} applies to `self.table_name =`), so a disagreement folds to
910
+ # `computed: true` rather than picking one arbitrarily.
911
+ def merge_table_name_decorators(base, addition)
912
+ (base.keys | addition.keys).to_h do |key|
913
+ a = base[key]
914
+ b = addition[key]
915
+ value =
916
+ if a.nil? then b
917
+ elsif b.nil? then a
918
+ elsif a == b then a
919
+ else { computed: true }
920
+ end
921
+ [key, value]
922
+ end
923
+ end
924
+
925
+ # Scans ruby files under project root outside model search paths for table_name_prefix /
926
+ # table_name_suffix / isolate_namespace (#678).
927
+ def scan_outside_decorators
928
+ outside_ruby_files.each do |path|
929
+ contents = read_safely(path)
930
+ next if contents.nil?
931
+ next unless outside_decorator_candidate?(contents)
932
+
933
+ parsed = Prism.parse(contents)
934
+ next if parsed.failure?
935
+
936
+ walk_for_outside_decorators(parsed.value, [])
937
+ end
938
+ end
939
+
940
+ def outside_ruby_files
941
+ model_files = ruby_files_under(@search_paths).to_set
942
+ root = File.expand_path(@project_root)
943
+ return [] unless @io_boundary.directory?(root)
944
+
945
+ outside_roots = OUTSIDE_SEARCH_DIRECTORIES.map { |dir| File.join(root, dir) }
946
+ ruby_files_under(outside_roots).reject { |path| model_files.include?(path) }
947
+ end
948
+
949
+ def outside_decorator_candidate?(contents)
950
+ contents.include?("table_name_prefix") ||
951
+ contents.include?("table_name_suffix") ||
952
+ contents.include?("isolate_namespace")
953
+ end
954
+
955
+ def walk_for_outside_decorators(node, lexical_path)
956
+ return if node.nil?
957
+
958
+ case node
959
+ when Prism::ClassNode
960
+ class_local_name = constant_path_name(node.constant_path)
961
+ if class_local_name
962
+ full_name = declared_constant_name(class_local_name, lexical_path)
963
+ record_outside_decorators(full_name, node.body, [full_name, *lexical_path], is_class: true)
964
+ node.body&.rigor_each_child { |child| walk_for_outside_decorators(child, [full_name, *lexical_path]) }
965
+ end
966
+ when Prism::ModuleNode
967
+ module_local_name = constant_path_name(node.constant_path)
968
+ if module_local_name
969
+ full_name = declared_constant_name(module_local_name, lexical_path)
970
+ record_outside_decorators(full_name, node.body, [full_name, *lexical_path], is_class: false)
971
+ node.body&.rigor_each_child { |child| walk_for_outside_decorators(child, [full_name, *lexical_path]) }
972
+ end
973
+ when Prism::CallNode
974
+ handle_outside_call(node, lexical_path)
975
+ when Prism::DefNode
976
+ handle_outside_def(node)
977
+ else
978
+ node.rigor_each_child { |child| walk_for_outside_decorators(child, lexical_path) }
979
+ end
980
+ end
981
+
982
+ def record_outside_decorators(full_name, body, lexical_path, is_class: false)
983
+ return if body.nil?
984
+
985
+ found = table_name_decorators(body, is_class: is_class)
986
+ if found.any?
987
+ @namespace_table_name_decorators[full_name] ||= {}
988
+ found.each_key do |key|
989
+ @namespace_table_name_decorators[full_name][key] = { computed: true }
990
+ end
991
+ end
992
+
993
+ body.rigor_each_child do |child|
994
+ if child.is_a?(Prism::CallNode) && child.name == :isolate_namespace
995
+ target = isolate_namespace_target(child, lexical_path.first)
996
+ if target
997
+ @namespace_table_name_decorators[target] ||= {}
998
+ @namespace_table_name_decorators[target][:prefix] = { computed: true }
999
+ end
1000
+ end
1001
+ end
1002
+ end
1003
+
1004
+ def handle_outside_call(node, lexical_path)
1005
+ if node.name == :isolate_namespace
1006
+ target = isolate_namespace_target(node, lexical_path.first)
1007
+ if target
1008
+ @namespace_table_name_decorators[target] ||= {}
1009
+ @namespace_table_name_decorators[target][:prefix] = { computed: true }
1010
+ end
1011
+ elsif node.receiver && %i[table_name_prefix= table_name_suffix=].include?(node.name)
1012
+ target = strip_root(constant_path_name(node.receiver))
1013
+ if target
1014
+ key = TABLE_NAME_DECORATOR_METHODS[node.name.to_s.delete_suffix("=").to_sym]
1015
+ if key
1016
+ @namespace_table_name_decorators[target] ||= {}
1017
+ @namespace_table_name_decorators[target][key] = { computed: true }
1018
+ end
1019
+ end
1020
+ end
1021
+ end
1022
+
1023
+ def handle_outside_def(node)
1024
+ return unless node.receiver
1025
+
1026
+ target = strip_root(constant_path_name(node.receiver))
1027
+ return unless target
1028
+
1029
+ key = TABLE_NAME_DECORATOR_METHODS[node.name]
1030
+ return unless key
1031
+
1032
+ @namespace_table_name_decorators[target] ||= {}
1033
+ @namespace_table_name_decorators[target][key] = { computed: true }
1034
+ end
1035
+
1036
+ def isolate_namespace_target(node, lexical_name)
1037
+ arg = node.arguments&.arguments&.first
1038
+ if arg
1039
+ name = constant_path_name(arg)
1040
+ strip_root(name) if name
1041
+ elsif lexical_name
1042
+ segments = lexical_name.to_s.split("::")
1043
+ segments.length > 1 ? segments[0..-2].join("::") : segments.first
1044
+ end
1045
+ end
1046
+
271
1047
  # Recognised single-instance and collection association DSL methods. The kind drives the eventual
272
1048
  # return-type contribution: singular associations narrow to `Nominal[Target] | nil`, plural ones
273
1049
  # narrow to `ActiveRecord::Relation[Target]`.