rigortype 0.1.10 → 0.1.11

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/cli/baseline_command.rb +4 -3
  4. data/lib/rigor/cli.rb +16 -3
  5. data/lib/rigor/version.rb +1 -1
  6. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  7. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  8. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  9. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  10. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  11. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  12. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  13. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  14. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  15. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  16. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  17. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  18. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  19. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  20. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  21. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  22. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  23. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  24. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  25. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  26. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  31. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  32. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  33. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  34. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  35. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  36. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  37. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  38. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  39. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  40. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  41. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  42. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  43. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  44. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  45. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  46. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  47. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  48. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  49. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  50. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  51. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  52. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  53. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  54. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  55. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  56. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  57. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  58. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  59. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  60. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  61. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  62. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  63. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  64. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  65. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  66. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  67. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  68. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  69. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  70. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  71. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  72. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  73. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  74. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  75. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  76. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  77. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  78. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  79. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  80. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  81. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  82. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  83. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  84. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  85. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  86. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  87. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  88. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  89. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  90. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  91. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  92. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  93. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  94. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  95. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  96. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  97. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  98. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  99. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  100. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  102. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  103. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  104. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  105. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  106. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  107. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  108. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  109. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  110. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  111. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  112. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  113. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  114. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  115. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  116. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  117. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  118. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  119. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  120. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  121. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  122. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  123. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  124. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  125. metadata +149 -1
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Activerecord < Rigor::Plugin::Base
6
+ # Tiny inflection helper for the common `ClassName → snake_case_plural`
7
+ # mapping Rails uses to derive table names. Deliberately
8
+ # narrow — handles the regular cases (`User → users`,
9
+ # `BlogPost → blog_posts`, `Category → categories`,
10
+ # `Bus → buses`, `Wolf → wolves`). Irregular plurals
11
+ # (`Person → people`, `Mouse → mice`, `Datum → data`) are
12
+ # NOT handled; the user is expected to declare
13
+ # `self.table_name = "people"` for those.
14
+ #
15
+ # Avoids an `activesupport` runtime dependency. Rails apps
16
+ # that need richer inflection should set explicit table
17
+ # names on the affected models.
18
+ module Inflector
19
+ IRREGULAR_PLURALS = {
20
+ # Common ones we still want to handle without bringing
21
+ # in a full inflection table. Users get the configured
22
+ # explicit table_name route for anything else.
23
+ "person" => "people",
24
+ "child" => "children",
25
+ "datum" => "data"
26
+ }.freeze
27
+
28
+ module_function
29
+
30
+ # `BlogPost` → `blog_posts`. `User::Profile` → `user_profiles`
31
+ # (Rails-style namespacing flattens with underscore).
32
+ def tableize(class_name)
33
+ underscore = underscore(class_name.to_s.gsub("::", "/"))
34
+ # `user/profiles` → `user_profiles`
35
+ underscore = underscore.tr("/", "_")
36
+ pluralize(underscore)
37
+ end
38
+
39
+ # `BlogPost` → `blog_post`. Standard Rails-style underscore.
40
+ def underscore(camel_case_word)
41
+ word = camel_case_word.to_s.dup
42
+ word.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
+ word.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
44
+ word.tr!("-", "_")
45
+ word.downcase!
46
+ word
47
+ end
48
+
49
+ # `users` → `User`, `blog_posts` → `BlogPost`.
50
+ # Used by the association detector to map an
51
+ # association NAME (`has_many :posts` → `Post`) to the
52
+ # target class without requiring an explicit
53
+ # `class_name:` option. Singularises the word first,
54
+ # then camel-cases. Recognises the same irregular
55
+ # plurals as {.pluralize}.
56
+ def classify(word)
57
+ camelize(singularize(word.to_s))
58
+ end
59
+
60
+ # `posts` → `post`, `categories` → `category`,
61
+ # `wolves` → `wolf`, `buses` → `bus`. The inverse of
62
+ # {.pluralize} for the regular cases this module
63
+ # recognises. Irregular forms (`people` → `person`)
64
+ # round-trip via {IRREGULAR_PLURALS}.
65
+ def singularize(word)
66
+ IRREGULAR_PLURALS.each { |singular, plural| return singular if word == plural }
67
+
68
+ case word
69
+ when /(.*[bcdfghjklmnpqrstvwxz])ies\z/
70
+ "#{Regexp.last_match(1)}y"
71
+ when /(.*[sxz]|.*[cs]h)es\z/
72
+ Regexp.last_match(0)[0..-3]
73
+ when /(.*)ves\z/
74
+ "#{Regexp.last_match(1)}f"
75
+ when /(.+)s\z/
76
+ Regexp.last_match(1)
77
+ else
78
+ word
79
+ end
80
+ end
81
+
82
+ # `blog_post` → `BlogPost`. Camelizes around `_` and
83
+ # `/` separators; the latter promotes namespace boundaries
84
+ # to `::` (Rails-style).
85
+ def camelize(snake)
86
+ snake.to_s.split("/").map do |segment|
87
+ segment.split("_").map { |part| part.empty? ? part : part[0].upcase + part[1..] }.join
88
+ end.join("::")
89
+ end
90
+
91
+ # `user` → `users`, `category` → `categories`,
92
+ # `bus` → `buses`, `wolf` → `wolves`. Falls back to a
93
+ # plain `+ "s"` for unrecognised endings.
94
+ def pluralize(word)
95
+ return IRREGULAR_PLURALS[word] if IRREGULAR_PLURALS.key?(word)
96
+
97
+ case word
98
+ when /(.*[bcdfghjklmnpqrstvwxz])y\z/
99
+ # `category` → `categories`, `cherry` → `cherries`
100
+ "#{Regexp.last_match(1)}ies"
101
+ when /(.*[sxz]|.*[cs]h)\z/
102
+ # `bus` → `buses`, `box` → `boxes`, `dish` → `dishes`
103
+ "#{Regexp.last_match(0)}es"
104
+ when /(.*)fe?\z/
105
+ # `wolf` → `wolves`, `knife` → `knives`
106
+ "#{Regexp.last_match(1)}ves"
107
+ else
108
+ "#{word}s"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,561 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Activerecord < Rigor::Plugin::Base
8
+ # Walks the configured model search paths via the plugin's
9
+ # `IoBoundary`, parses each `.rb` file with Prism, and
10
+ # collects class declarations that resolve to ActiveRecord
11
+ # models.
12
+ #
13
+ # Discovery is a two-step pass. First every class declaration
14
+ # is captured as a *candidate* (its name, its superclass
15
+ # name, and its DSL metadata). Then a fixpoint marks a
16
+ # candidate as a model when its superclass is a configured
17
+ # base class OR (transitively) the class name of another
18
+ # model — this is what makes single-table-inheritance
19
+ # subclasses (`class Admin < User`) discoverable. Each STI
20
+ # child carries an `sti_parent:` pointer the {ModelIndex}
21
+ # uses to inherit the root model's table and DSL surface.
22
+ #
23
+ # Returns rows the {ModelIndex} consumes:
24
+ #
25
+ # { class_name: "User", table_name_override: nil, sti_parent: nil, ... }
26
+ # { class_name: "Admin", table_name_override: nil, sti_parent: "User", ... }
27
+ #
28
+ # Limitations (intentional for v0.1.0 of the plugin):
29
+ #
30
+ # - `self.table_name = "..."` recognised only when the RHS
31
+ # is a String literal. Computed names
32
+ # (`self.table_name = "#{tenant}_users"`) are skipped.
33
+ # - Modules (`class Admin::User < ApplicationRecord`) are
34
+ # recognised; the resulting class name is the lexical
35
+ # path (`Admin::User`).
36
+ # - The STI fixpoint matches a superclass name against model
37
+ # class names tolerating a leading `::`; richer constant
38
+ # resolution (relative namespacing) is not modelled.
39
+ class ModelDiscoverer
40
+ # @param io_boundary [Rigor::Plugin::IoBoundary]
41
+ # @param search_paths [Array<String>] absolute or
42
+ # project-relative paths.
43
+ # @param base_classes [Array<String>] superclass names that
44
+ # identify a class as an AR model.
45
+ def initialize(io_boundary:, search_paths:, base_classes:)
46
+ @io_boundary = io_boundary
47
+ @search_paths = search_paths
48
+ @base_classes = base_classes.to_set
49
+ end
50
+
51
+ # @return [Array<Hash>] rows of { class_name:, table_name_override:, sti_parent:, ... }
52
+ def discover
53
+ candidates = []
54
+ ruby_files_under(@search_paths).each do |path|
55
+ contents = read_safely(path)
56
+ next if contents.nil?
57
+
58
+ tree = Prism.parse(contents).value
59
+ walk_for_classes(tree, []) { |candidate| candidates << candidate }
60
+ end
61
+ resolve_models(candidates)
62
+ end
63
+
64
+ private
65
+
66
+ # Fixpoint over the captured class candidates: a candidate
67
+ # is a model when its superclass is a configured base
68
+ # class, or — transitively — the class name of an
69
+ # already-known model. The second arm is what discovers
70
+ # STI subclasses; the matched parent name is stamped onto
71
+ # the row as `sti_parent:` so the {ModelIndex} can inherit
72
+ # the root model's table and association surface.
73
+ #
74
+ # Non-model classes (POROs, service objects that happen to
75
+ # live under `app/models/`) never enter `model_names` and
76
+ # are dropped.
77
+ def resolve_models(candidates)
78
+ model_names = {}
79
+ sti_parent = {}
80
+
81
+ loop do
82
+ added = false
83
+ candidates.each do |candidate|
84
+ name = candidate[:class_name]
85
+ next if model_names.key?(name)
86
+
87
+ superclass = candidate[:superclass_name]
88
+ next if superclass.nil?
89
+
90
+ if @base_classes.include?(superclass)
91
+ model_names[name] = true
92
+ added = true
93
+ elsif (parent = model_match(superclass, model_names))
94
+ model_names[name] = true
95
+ sti_parent[name] = parent
96
+ added = true
97
+ end
98
+ end
99
+ break unless added
100
+ end
101
+
102
+ candidates.filter_map do |candidate|
103
+ name = candidate[:class_name]
104
+ next unless model_names.key?(name)
105
+
106
+ candidate.merge(sti_parent: sti_parent[name])
107
+ end
108
+ end
109
+
110
+ # Resolves a superclass NAME against the set of known
111
+ # model class names, tolerating a leading `::`. Returns
112
+ # the matched model class name, or nil.
113
+ def model_match(superclass_name, model_names)
114
+ return superclass_name if model_names.key?(superclass_name)
115
+
116
+ stripped = superclass_name.sub(/\A::/, "")
117
+ model_names.key?(stripped) ? stripped : nil
118
+ end
119
+
120
+ def read_safely(path)
121
+ @io_boundary.read_file(path)
122
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
123
+ nil
124
+ end
125
+
126
+ def ruby_files_under(roots)
127
+ roots.flat_map do |root|
128
+ absolute = File.expand_path(root)
129
+ next [] unless File.directory?(absolute)
130
+
131
+ Dir.glob(File.join(absolute, "**", "*.rb"))
132
+ end
133
+ end
134
+
135
+ def walk_for_classes(node, lexical_path, &)
136
+ return if node.nil?
137
+
138
+ case node
139
+ when Prism::ClassNode
140
+ visit_class(node, lexical_path, &)
141
+ when Prism::ModuleNode
142
+ visit_module(node, lexical_path, &)
143
+ else
144
+ node.compact_child_nodes.each { |child| walk_for_classes(child, lexical_path, &) }
145
+ end
146
+ end
147
+
148
+ # Captures EVERY class declaration as a candidate — the
149
+ # `resolve_models` fixpoint decides afterwards which ones
150
+ # are models. The DSL metadata is extracted eagerly; for a
151
+ # non-model class it is simply discarded when the candidate
152
+ # is dropped.
153
+ def visit_class(node, lexical_path, &)
154
+ class_local_name = constant_path_name(node.constant_path)
155
+ return if class_local_name.nil?
156
+
157
+ full_name = (lexical_path + [class_local_name]).join("::")
158
+ superclass = constant_path_name(node.superclass) if node.superclass
159
+
160
+ yield({
161
+ class_name: full_name,
162
+ superclass_name: superclass,
163
+ table_name_override: lookup_table_name_override(node.body),
164
+ associations: lookup_associations(node.body),
165
+ enums: lookup_enums(node.body),
166
+ scopes: lookup_scopes(node.body),
167
+ validations: lookup_validations(node.body),
168
+ callbacks: lookup_callbacks(node.body),
169
+ aliases: lookup_aliases(node.body)
170
+ })
171
+
172
+ # Recurse into the body in case nested classes exist.
173
+ inner_path = lexical_path + [class_local_name]
174
+ walk_for_classes(node.body, inner_path, &) if node.body
175
+ end
176
+
177
+ def visit_module(node, lexical_path, &)
178
+ module_local_name = constant_path_name(node.constant_path)
179
+ return if module_local_name.nil?
180
+
181
+ inner_path = lexical_path + [module_local_name]
182
+ walk_for_classes(node.body, inner_path, &) if node.body
183
+ end
184
+
185
+ # Renders a constant-path node (`Admin::User`,
186
+ # `::ApplicationRecord`) as a String. Returns nil for
187
+ # shapes the discoverer chooses not to handle.
188
+ def constant_path_name(node)
189
+ return nil if node.nil?
190
+
191
+ case node
192
+ when Prism::ConstantReadNode
193
+ node.name.to_s
194
+ when Prism::ConstantPathNode
195
+ parts = []
196
+ current = node
197
+ while current.is_a?(Prism::ConstantPathNode)
198
+ parts.unshift(current.name.to_s)
199
+ current = current.parent
200
+ end
201
+ case current
202
+ when nil
203
+ "::#{parts.join('::')}"
204
+ when Prism::ConstantReadNode
205
+ "#{current.name}::#{parts.join('::')}"
206
+ end
207
+ end
208
+ end
209
+
210
+ # Looks for `self.table_name = "..."` at the top level of
211
+ # the class body. Returns the literal String when found,
212
+ # nil otherwise.
213
+ def lookup_table_name_override(body)
214
+ return nil if body.nil?
215
+
216
+ body.compact_child_nodes.each do |node|
217
+ next unless node.is_a?(Prism::CallNode) && node.name == :table_name=
218
+ next unless node.receiver.is_a?(Prism::SelfNode)
219
+
220
+ arg = node.arguments&.arguments&.first
221
+ return arg.unescaped if arg.is_a?(Prism::StringNode)
222
+ end
223
+ nil
224
+ end
225
+
226
+ # Recognised single-instance and collection association
227
+ # DSL methods. The kind drives the eventual return-type
228
+ # contribution: singular associations narrow to
229
+ # `Nominal[Target] | nil`, plural ones currently degrade
230
+ # to the RBS envelope (relation types are a future track).
231
+ #
232
+ # `composed_of` value-object aggregations and
233
+ # `delegated_type` roles are folded in here too — both
234
+ # accept the association name as a `where` / `find_by`
235
+ # query key, so omitting them turns every such query into
236
+ # a false `unknown-column`. `composed_of` resolves to its
237
+ # value class (a real target); `delegated_type` is
238
+ # polymorphic (no single target).
239
+ ASSOCIATION_METHODS = {
240
+ belongs_to: :singular,
241
+ has_one: :singular,
242
+ has_many: :collection,
243
+ has_and_belongs_to_many: :collection,
244
+ composed_of: :singular,
245
+ delegated_type: :singular
246
+ }.freeze
247
+ private_constant :ASSOCIATION_METHODS
248
+
249
+ # Association DSL methods that are ALWAYS polymorphic —
250
+ # the accessor has no single static target class.
251
+ # `belongs_to` / `has_one` become polymorphic only with
252
+ # an explicit `polymorphic: true` option.
253
+ POLYMORPHIC_BY_DEFAULT = %i[delegated_type].freeze
254
+ private_constant :POLYMORPHIC_BY_DEFAULT
255
+
256
+ # Class-body declaration calls — the top-level `CallNode`s
257
+ # PLUS those nested inside a `with_options(...) do … end`
258
+ # block. `with_options` is Rails' idiom for sharing
259
+ # options across a group of `belongs_to` / `validates` /
260
+ # etc. declarations; without descending into it every
261
+ # association / enum / validation declared inside is
262
+ # invisible to the discoverer, turning `where(<assoc>:
263
+ # ...)` into a false `unknown-column`. Nested
264
+ # `with_options` blocks recurse.
265
+ #
266
+ # The options the `with_options` call itself carries (e.g.
267
+ # `with_options class_name: 'Account'`) are NOT merged into
268
+ # the nested calls — discovering the declaration name is
269
+ # what clears the false positive; the merged-option target
270
+ # precision is a separate refinement.
271
+ def declaration_calls(body)
272
+ return [] if body.nil?
273
+
274
+ body.compact_child_nodes.flat_map do |node|
275
+ next [] unless node.is_a?(Prism::CallNode)
276
+
277
+ if node.name == :with_options && node.block.is_a?(Prism::BlockNode)
278
+ declaration_calls(node.block.body)
279
+ else
280
+ [node]
281
+ end
282
+ end
283
+ end
284
+
285
+ # Walks the class body for association DSL calls and
286
+ # returns a list of rows shaped:
287
+ #
288
+ # { name: "user", kind: :singular, target: "User" }
289
+ #
290
+ # The `target` is resolved from an explicit
291
+ # `class_name: "Foo"` option when supplied, otherwise
292
+ # inferred from the association name via
293
+ # {Inflector.classify}. Calls whose first arg is not a
294
+ # Symbol literal (or whose `class_name:` is a non-literal
295
+ # expression) decline rather than guess.
296
+ def lookup_associations(body)
297
+ return [] if body.nil?
298
+
299
+ rows = []
300
+ declaration_calls(body).each do |node|
301
+ kind = ASSOCIATION_METHODS[node.name]
302
+ next if kind.nil?
303
+ next if node.receiver # skip `self.has_many` and similar
304
+
305
+ row = build_association_row(node, kind)
306
+ rows << row unless row.nil?
307
+ end
308
+ rows
309
+ end
310
+
311
+ def build_association_row(node, kind)
312
+ args = node.arguments&.arguments
313
+ return nil if args.nil? || args.empty?
314
+
315
+ name_node = args.first
316
+ return nil unless name_node.is_a?(Prism::SymbolNode)
317
+
318
+ name = name_node.unescaped
319
+ polymorphic = POLYMORPHIC_BY_DEFAULT.include?(node.name) ||
320
+ association_option(args, "polymorphic") == true
321
+
322
+ # A polymorphic association has no single static target
323
+ # class — `target` is nil and the flow contribution
324
+ # declines to narrow rather than inventing a wrong
325
+ # `Nominal[<classified-name>]`.
326
+ if polymorphic
327
+ target = nil
328
+ else
329
+ target = explicit_class_name(args) || Inflector.classify(name)
330
+ return nil if target.nil? || target.empty?
331
+ end
332
+
333
+ { name: name, kind: kind, target: target, polymorphic: polymorphic,
334
+ nullable: association_nullable?(node.name, args) }
335
+ end
336
+
337
+ # Whether a `:singular` association's accessor can return
338
+ # `nil`. `has_one` genuinely can (no associated record →
339
+ # `nil`). `belongs_to` is **required (non-`nil`) by default
340
+ # since Rails 5** (`belongs_to_required_by_default`); it
341
+ # becomes nullable only when the call passes `optional: true`
342
+ # or `required: false`. `composed_of` is non-nullable
343
+ # unless `allow_nil: true`. `delegated_type` roles are
344
+ # required. A non-literal option value declines to the
345
+ # default rather than guessing.
346
+ def association_nullable?(method_name, args)
347
+ case method_name
348
+ when :has_one
349
+ true
350
+ when :belongs_to
351
+ association_option(args, "optional") == true ||
352
+ association_option(args, "required") == false
353
+ when :composed_of
354
+ association_option(args, "allow_nil") == true
355
+ else
356
+ false
357
+ end
358
+ end
359
+
360
+ # Reads a literal boolean association option (`optional:` /
361
+ # `required:`). Returns `true` / `false` for a literal, or
362
+ # `nil` when the key is absent or its value is non-literal.
363
+ def association_option(args, key)
364
+ args.each do |arg|
365
+ next unless arg.is_a?(Prism::KeywordHashNode)
366
+
367
+ arg.elements.each do |pair|
368
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
369
+ next unless pair.key.unescaped == key
370
+
371
+ return true if pair.value.is_a?(Prism::TrueNode)
372
+ return false if pair.value.is_a?(Prism::FalseNode)
373
+ end
374
+ end
375
+ nil
376
+ end
377
+
378
+ def explicit_class_name(args)
379
+ args.each do |arg|
380
+ next unless arg.is_a?(Prism::KeywordHashNode)
381
+
382
+ arg.elements.each do |pair|
383
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
384
+ next unless pair.key.unescaped == "class_name"
385
+ next unless pair.value.is_a?(Prism::StringNode)
386
+
387
+ return pair.value.unescaped
388
+ end
389
+ end
390
+ nil
391
+ end
392
+
393
+ # `enum status: { active: 0, archived: 1 }` (Rails ≤6)
394
+ # and `enum :status, [:active, :archived]` (Rails 7+).
395
+ # Returns `Hash<column_name => Array<Symbol>>`.
396
+ # Non-literal forms decline rather than guess.
397
+ def lookup_enums(body)
398
+ return {} if body.nil?
399
+
400
+ enums = {}
401
+ declaration_calls(body).each do |node|
402
+ next unless node.name == :enum
403
+ next if node.receiver
404
+
405
+ row = parse_enum_call(node)
406
+ next if row.nil?
407
+
408
+ enums[row[:column]] = row[:values]
409
+ end
410
+ enums.freeze
411
+ end
412
+
413
+ def parse_enum_call(node)
414
+ args = node.arguments&.arguments
415
+ return nil if args.nil? || args.empty?
416
+
417
+ first = args.first
418
+ if first.is_a?(Prism::SymbolNode) && args.size >= 2
419
+ values = enum_values_from(args[1])
420
+ return nil if values.nil?
421
+
422
+ { column: first.unescaped, values: values }
423
+ elsif first.is_a?(Prism::KeywordHashNode)
424
+ entry = first.elements.find { |e| e.is_a?(Prism::AssocNode) && e.key.is_a?(Prism::SymbolNode) }
425
+ return nil if entry.nil?
426
+
427
+ values = enum_values_from(entry.value)
428
+ return nil if values.nil?
429
+
430
+ { column: entry.key.unescaped, values: values }
431
+ end
432
+ end
433
+
434
+ def enum_values_from(node)
435
+ case node
436
+ when Prism::ArrayNode
437
+ symbols = node.elements.filter_map { |e| e.unescaped if e.is_a?(Prism::SymbolNode) }
438
+ return nil if symbols.size != node.elements.size
439
+
440
+ symbols
441
+ when Prism::HashNode
442
+ node.elements.filter_map do |e|
443
+ next nil unless e.is_a?(Prism::AssocNode) && e.key.is_a?(Prism::SymbolNode)
444
+
445
+ e.key.unescaped
446
+ end
447
+ end
448
+ end
449
+
450
+ # `scope :active, -> { ... }`. Records the scope name
451
+ # only (the body is intentionally NOT introspected —
452
+ # scopes return ActiveRecord::Relation, which Rigor
453
+ # doesn't carry a precise type for yet).
454
+ def lookup_scopes(body)
455
+ return [] if body.nil?
456
+
457
+ scopes = []
458
+ declaration_calls(body).each do |node|
459
+ next unless node.name == :scope
460
+ next if node.receiver
461
+
462
+ args = node.arguments&.arguments
463
+ next if args.nil? || args.empty?
464
+
465
+ name_node = args.first
466
+ next unless name_node.is_a?(Prism::SymbolNode)
467
+
468
+ scopes << name_node.unescaped
469
+ end
470
+ scopes.freeze
471
+ end
472
+
473
+ # `validates :name, presence: true, length: { maximum: 100 }`.
474
+ # Records the attribute name (the validator option set
475
+ # is ignored — the value here is the diagnostic
476
+ # `validates :unknown_attr` surfacing when the attribute
477
+ # isn't a column on the table).
478
+ def lookup_validations(body)
479
+ return [] if body.nil?
480
+
481
+ attrs = []
482
+ declaration_calls(body).each do |node|
483
+ next unless %i[validates validates_presence_of validates_length_of
484
+ validates_format_of validates_uniqueness_of].include?(node.name)
485
+ next if node.receiver
486
+
487
+ attrs.concat(symbol_args(node))
488
+ end
489
+ attrs.uniq.freeze
490
+ end
491
+
492
+ # `before_save :foo`, `after_create :bar`, etc. Records
493
+ # the referenced method name (a Symbol literal). The
494
+ # diagnostic value is "did you forget to `def` this?".
495
+ # Block callbacks (`before_save { ... }`) decline.
496
+ CALLBACK_METHODS = %i[
497
+ before_validation after_validation
498
+ before_save after_save around_save
499
+ before_create after_create around_create
500
+ before_update after_update around_update
501
+ before_destroy after_destroy around_destroy
502
+ after_commit after_rollback
503
+ after_initialize after_find
504
+ ].freeze
505
+ private_constant :CALLBACK_METHODS
506
+
507
+ def lookup_callbacks(body)
508
+ return [] if body.nil?
509
+
510
+ targets = []
511
+ declaration_calls(body).each do |node|
512
+ next unless CALLBACK_METHODS.include?(node.name)
513
+ next if node.receiver
514
+
515
+ symbol_args(node).each do |name|
516
+ targets << { name: name, callback: node.name.to_s }
517
+ end
518
+ end
519
+ targets.freeze
520
+ end
521
+
522
+ # `alias_attribute :new_name, :old_name`. Records the
523
+ # mapping so the analyzer accepts the alias as a query
524
+ # key — without it every `where(<alias>: ...)` /
525
+ # `find_by(<alias>: ...)` call surfaces as a false
526
+ # `unknown-column`. Returns `Hash<alias => target>`;
527
+ # non-Symbol-literal forms decline rather than guess.
528
+ def lookup_aliases(body)
529
+ return {} if body.nil?
530
+
531
+ aliases = {}
532
+ declaration_calls(body).each do |node|
533
+ next unless node.name == :alias_attribute
534
+ next if node.receiver
535
+
536
+ args = node.arguments&.arguments
537
+ next if args.nil? || args.size < 2
538
+
539
+ new_name = args[0]
540
+ old_name = args[1]
541
+ next unless new_name.is_a?(Prism::SymbolNode) && old_name.is_a?(Prism::SymbolNode)
542
+
543
+ aliases[new_name.unescaped] = old_name.unescaped
544
+ end
545
+ aliases.freeze
546
+ end
547
+
548
+ # Collects every Symbol-literal positional argument
549
+ # from a CallNode. Used by both `lookup_validations`
550
+ # and `lookup_callbacks` to extract the attribute /
551
+ # method name list.
552
+ def symbol_args(node)
553
+ args = node.arguments&.arguments
554
+ return [] if args.nil?
555
+
556
+ args.filter_map { |arg| arg.unescaped if arg.is_a?(Prism::SymbolNode) }
557
+ end
558
+ end
559
+ end
560
+ end
561
+ end