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,514 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ require_relative "activerecord/inflector"
6
+ require_relative "activerecord/schema_table"
7
+ require_relative "activerecord/schema_parser"
8
+ require_relative "activerecord/model_index"
9
+ require_relative "activerecord/model_discoverer"
10
+ require_relative "activerecord/analyzer"
11
+
12
+ module Rigor
13
+ module Plugin
14
+ # rigor-activerecord — types ActiveRecord finder + relation
15
+ # calls against the project's `db/schema.rb` and discovered
16
+ # AR model classes.
17
+ #
18
+ # ## Architecture
19
+ #
20
+ # Two cached producers per plugin run:
21
+ #
22
+ # 1. `:schema_table` reads `db/schema.rb` via the `IoBoundary`
23
+ # and parses it through {SchemaParser} into a
24
+ # {SchemaTable} mapping `table_name → { column_name →
25
+ # Column }`.
26
+ # 2. `:model_index` walks every `.rb` file under the
27
+ # configured `model_search_paths`, finds class declarations
28
+ # whose direct superclass is in `model_base_classes`, and
29
+ # composes them with the schema table into a {ModelIndex}.
30
+ #
31
+ # Both producers ride `Plugin::Base#cache_for`. The descriptor
32
+ # auto-includes the digests of every file the boundary read,
33
+ # so editing `db/schema.rb` or any model file invalidates
34
+ # exactly the right cache entry.
35
+ #
36
+ # The per-file `#diagnostics_for_file` hook delegates to
37
+ # {Analyzer}, which walks Prism and emits diagnostics for
38
+ # `Model.find` / `Model.find_by` / `Model.where` calls
39
+ # against the index.
40
+ #
41
+ # ## Configuration
42
+ #
43
+ # plugins:
44
+ # - gem: rigor-activerecord
45
+ # config:
46
+ # schema_file: "db/schema.rb"
47
+ # model_search_paths: ["app/models"]
48
+ # model_base_classes: ["ApplicationRecord", "ActiveRecord::Base"]
49
+ #
50
+ # All three keys default to the values shown above. The class
51
+ # name `Rigor::Plugin::Activerecord` (single capital R) is
52
+ # intentional — keeps the constant lookup distinct from
53
+ # `::ActiveRecord` even though the gem name is hyphenated.
54
+ #
55
+ # Note: this plugin is the seventh worked example. It does NOT
56
+ # require `active_record` at runtime — it only reads project
57
+ # source, the same way the other examples do. Rigor stays
58
+ # decoupled from Rails.
59
+ class Activerecord < Rigor::Plugin::Base
60
+ manifest(
61
+ id: "activerecord",
62
+ version: "0.1.0",
63
+ description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
64
+ config_schema: {
65
+ "schema_file" => :string,
66
+ "model_search_paths" => :array,
67
+ "model_base_classes" => :array
68
+ },
69
+ produces: [:model_index],
70
+ # ADR-25 — the bundled `ActiveRecord::Relation` RBS, the
71
+ # type `flow_contribution_for`'s relation-typed call sites
72
+ # (`has_many` accessors, `Model.where`, scopes) dispatch
73
+ # against.
74
+ signature_paths: ["sig"],
75
+ # ADR-26 — `ActiveRecord::Relation` is an "open" receiver:
76
+ # it delegates an unbounded set of user-defined scopes /
77
+ # class methods to its model, so `call.undefined-method`
78
+ # must not fire for it. `CheckRules` reads this manifest
79
+ # field and skips the rule for the class.
80
+ open_receivers: ["ActiveRecord::Relation"]
81
+ )
82
+
83
+ DEFAULT_SCHEMA_FILE = "db/schema.rb"
84
+ DEFAULT_MODEL_SEARCH_PATHS = ["app/models"].freeze
85
+ DEFAULT_MODEL_BASE_CLASSES = %w[ApplicationRecord ActiveRecord::Base].freeze
86
+
87
+ # The class the bundled `sig/active_record/relation.rbs`
88
+ # describes; `flow_contribution_for` contributes
89
+ # `ActiveRecord::Relation[Model]` for relation-returning
90
+ # call sites (`has_many` accessors, `Model.where`, scopes).
91
+ RELATION_CLASS_NAME = "ActiveRecord::Relation"
92
+
93
+ # Cached: parsed schema table. The producer reads `@schema_file`
94
+ # via `io_boundary.read_file` so the descriptor picks up the
95
+ # digest, then parses through {SchemaParser}.
96
+ producer :schema_table do |_params|
97
+ contents = io_boundary.read_file(@schema_file)
98
+ SchemaParser.parse(contents)
99
+ end
100
+
101
+ # Cached: model index. Walks every model file, then composes
102
+ # the rows with the cached schema table.
103
+ producer :model_index do |_params|
104
+ rows = ModelDiscoverer.new(
105
+ io_boundary: io_boundary,
106
+ search_paths: @model_search_paths,
107
+ base_classes: @model_base_classes
108
+ ).discover
109
+ ModelIndex.build(model_rows: rows, schema_table: schema_table_or_nil)
110
+ end
111
+
112
+ def init(_services)
113
+ @schema_file = config.fetch("schema_file", DEFAULT_SCHEMA_FILE)
114
+ @model_search_paths = Array(config.fetch("model_search_paths", DEFAULT_MODEL_SEARCH_PATHS)).map(&:to_s)
115
+ @model_base_classes = Array(config.fetch("model_base_classes", DEFAULT_MODEL_BASE_CLASSES)).map(&:to_s)
116
+ @schema_table = nil
117
+ @model_index = nil
118
+ @load_errors = []
119
+ end
120
+
121
+ # ADR-9 cross-plugin publication. Builds the model index
122
+ # eagerly during the per-run `prepare(services)` pass and
123
+ # publishes a flat Hash form to the shared fact store so
124
+ # downstream Tier-2 consumers (rigor-actionpack Phase 1
125
+ # strong-parameter validation, rigor-factorybot Phase 1
126
+ # (c) attribute → column cross-check, future plugins
127
+ # that need to know "what columns does class `User`
128
+ # expose?") can read it without coupling to this
129
+ # plugin's carrier classes.
130
+ #
131
+ # The published shape:
132
+ #
133
+ # {
134
+ # "User" => { table: "users", columns: ["id", "name", "email"] },
135
+ # "Post" => { table: "posts", columns: ["id", "title", "body"] },
136
+ # ...
137
+ # }
138
+ #
139
+ # Consumers do `services.fact_store.read(plugin_id:
140
+ # "activerecord", name: :model_index)` and look up by
141
+ # class name. Discovery failures (missing schema,
142
+ # unparseable models) leave the fact unpublished — the
143
+ # consumer's own degrade path runs (typically a no-op).
144
+ def prepare(services)
145
+ index = model_index
146
+ return if index.nil? || index.empty?
147
+
148
+ services.fact_store.publish(
149
+ plugin_id: manifest.id,
150
+ name: :model_index,
151
+ value: index_to_published_hash(index)
152
+ )
153
+ end
154
+
155
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
156
+ index = model_index
157
+ if index.nil?
158
+ # Project-global error (missing `db/schema.rb`, parse
159
+ # failure, etc.) — emit once per run rather than once
160
+ # per analyzed file. On a Redmine-shape project that
161
+ # uses migrations only (no `schema.rb`), the old path
162
+ # produced 346 identical load-errors; on a Solidus
163
+ # monorepo (no top-level `schema.rb`), 999.
164
+ return [] if @load_errors_emitted
165
+
166
+ @load_errors_emitted = true
167
+ return load_error_diagnostics(path)
168
+ end
169
+ return [] if index.empty?
170
+
171
+ Analyzer.new(path: path, model_index: index).analyze(root).diagnostics
172
+ end
173
+
174
+ # v0.1.2 — return-type contribution. `Model.find(id)`
175
+ # narrows the call site's return type to `Nominal[Model]`,
176
+ # so chained calls (`User.find(1).name`) resolve through
177
+ # the analyzer's normal dispatch instead of the RBS-level
178
+ # untyped fall-back. `Model.find_by(...)` narrows to
179
+ # `Nominal[Model] | nil` because Rails returns nil when no
180
+ # row matches. `where` / `find_or_*` are intentionally
181
+ # deferred — they return relations, and Rigor does not yet
182
+ # carry an Enumerable-backed relation shape that would be
183
+ # more precise than the RBS envelope.
184
+ def flow_contribution_for(call_node:, scope:)
185
+ return nil unless call_node.is_a?(Prism::CallNode)
186
+ return nil if call_node.receiver.nil?
187
+
188
+ index = model_index
189
+ return nil if index.nil? || index.empty?
190
+
191
+ return_type = class_call_return_type(call_node, index) ||
192
+ relation_call_return_type(call_node, scope, index) ||
193
+ instance_call_return_type(call_node, scope, index)
194
+ return nil if return_type.nil?
195
+
196
+ Rigor::FlowContribution.new(
197
+ return_type: return_type,
198
+ provenance: Rigor::FlowContribution::Provenance.new(
199
+ source_family: "plugin.#{manifest.id}",
200
+ plugin_id: manifest.id,
201
+ node: call_node,
202
+ descriptor: nil
203
+ )
204
+ )
205
+ end
206
+
207
+ private
208
+
209
+ def class_call_return_type(call_node, index)
210
+ model_name = constant_receiver_name(call_node.receiver)
211
+ return nil if model_name.nil?
212
+
213
+ entry = index.find(model_name) || index.find("::#{model_name}")
214
+ return nil if entry.nil?
215
+
216
+ finder_return_type(call_node, entry) ||
217
+ class_scope_return_type(call_node, entry)
218
+ end
219
+
220
+ # Class-side finders + the class-side relation entry points.
221
+ # `find` / `find_by!` return the model; `find_by` adds the
222
+ # `nil` arm; `where` / `all` / `order` / `limit` / `none`
223
+ # open a relation. The relation then carries its element
224
+ # type through any further chained query method via the
225
+ # bundled `ActiveRecord::Relation` RBS.
226
+ def finder_return_type(call_node, entry)
227
+ case call_node.name
228
+ when :find
229
+ return nil if call_argument_count(call_node).zero?
230
+
231
+ Rigor::Type::Combinator.nominal_of(entry.class_name)
232
+ when :find_by!
233
+ # The bang variant raises `RecordNotFound` instead of
234
+ # returning `nil`, so the result is non-nullable.
235
+ Rigor::Type::Combinator.nominal_of(entry.class_name)
236
+ when :find_by
237
+ Rigor::Type::Combinator.union(
238
+ Rigor::Type::Combinator.nominal_of(entry.class_name),
239
+ Rigor::Type::Combinator.constant_of(nil)
240
+ )
241
+ when :where, :all, :order, :limit, :none
242
+ relation_of(entry.class_name)
243
+ end
244
+ end
245
+
246
+ # `Post.published` / `Post.recent(5)` — a user-declared
247
+ # `scope` returns a relation of the model regardless of the
248
+ # arguments it takes.
249
+ def class_scope_return_type(call_node, entry)
250
+ return nil unless entry.scope?(call_node.name)
251
+
252
+ relation_of(entry.class_name)
253
+ end
254
+
255
+ # `ActiveRecord::Relation[Model]` — the type the bundled
256
+ # `sig/active_record/relation.rbs` describes. The class is
257
+ # declared `open_receivers` in the manifest, so a chained
258
+ # scope call the bundled RBS cannot enumerate does not
259
+ # surface as `call.undefined-method` (ADR-26).
260
+ def relation_of(model_class_name)
261
+ Rigor::Type::Combinator.nominal_of(
262
+ RELATION_CLASS_NAME,
263
+ type_args: [Rigor::Type::Combinator.nominal_of(model_class_name)]
264
+ )
265
+ end
266
+
267
+ # A scope invoked on an already-typed relation
268
+ # (`User.where(active: true).published`) keeps the relation
269
+ # type through the chain. The bundled `ActiveRecord::Relation`
270
+ # RBS cannot enumerate user-defined scopes, so without this
271
+ # the chain would lose its element type after the first
272
+ # scope call. Non-scope methods decline — the RBS tier
273
+ # resolves `where` / `order` / `each` / `first` precisely.
274
+ # Scopes may take arguments (`relation.recent(5)`), so —
275
+ # unlike `instance_call_return_type` — argument calls are
276
+ # not skipped.
277
+ #
278
+ # The cheap `scope_name?` pre-check is load-bearing: it
279
+ # gates the `scope.type_of(receiver)` call so the receiver
280
+ # type is computed ONLY when the method name could be a
281
+ # scope. `type_of` on a call receiver re-enters dispatch,
282
+ # and calling it for every call node in a long method chain
283
+ # is pathologically expensive — the pre-check keeps the
284
+ # cost off the hot path.
285
+ def relation_call_return_type(call_node, scope, index)
286
+ return nil if call_node.receiver.nil?
287
+ return nil unless scope_name?(call_node.name, index)
288
+
289
+ model_name = relation_element_class_name(scope.type_of(call_node.receiver))
290
+ return nil if model_name.nil?
291
+
292
+ entry = index.find(model_name) || index.find("::#{model_name}")
293
+ return nil if entry.nil?
294
+ return nil unless entry.scope?(call_node.name)
295
+
296
+ relation_of(model_name)
297
+ end
298
+
299
+ # Whether `name` is a declared `scope` on ANY model in the
300
+ # index. A run-lifetime memoised Set so the per-call check
301
+ # in `relation_call_return_type` stays O(1).
302
+ def scope_name?(name, index)
303
+ @all_scope_names ||= index.entries.each_value.flat_map(&:scopes).to_set
304
+ @all_scope_names.include?(name.to_s)
305
+ end
306
+
307
+ # When `type` is `ActiveRecord::Relation[Nominal[Model]]`,
308
+ # returns the model class name; nil for any other type.
309
+ def relation_element_class_name(type)
310
+ return nil unless type.is_a?(Rigor::Type::Nominal)
311
+ return nil unless type.class_name == RELATION_CLASS_NAME
312
+
313
+ element = type.type_args&.first
314
+ element.class_name if element.is_a?(Rigor::Type::Nominal)
315
+ end
316
+
317
+ # Instance-side navigation: when the call's receiver
318
+ # resolves to `Nominal[Model]` and the method name matches
319
+ # a discovered association OR a table column, the call site
320
+ # gets a precise return type. Calls with arguments are
321
+ # skipped — accessor / association calls take no args, and
322
+ # argument forms (`user.posts(limit: 10)`, `user.name = x`)
323
+ # route through Rails APIs this slice does not model.
324
+ def instance_call_return_type(call_node, scope, index)
325
+ return nil unless call_node.arguments.nil?
326
+
327
+ receiver_type = scope.type_of(call_node.receiver)
328
+ return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
329
+
330
+ entry = index.find(receiver_type.class_name) ||
331
+ index.find("::#{receiver_type.class_name}")
332
+ return nil if entry.nil?
333
+
334
+ association_return_type(entry, call_node.name) ||
335
+ column_return_type(entry, call_node.name)
336
+ end
337
+
338
+ # The return type for an association accessor. A `belongs_to`
339
+ # / `has_one` singular association narrows to the target
340
+ # model — `belongs_to` is required (non-`nil`) by default
341
+ # since Rails 5 so it is `Nominal[Target]`, while `has_one`
342
+ # (and an `optional: true` / `required: false` `belongs_to`)
343
+ # adds the `nil` arm. A `has_many` / `has_and_belongs_to_many`
344
+ # collection narrows to `ActiveRecord::Relation[Target]` so
345
+ # chained query / iteration calls resolve. A polymorphic
346
+ # association has no single static target and declines
347
+ # rather than inventing a wrong type.
348
+ def association_return_type(entry, method_name)
349
+ association = entry.association(method_name)
350
+ return nil if association.nil?
351
+ return nil if association[:target].nil?
352
+
353
+ case association[:kind]
354
+ when :collection
355
+ relation_of(association[:target])
356
+ when :singular
357
+ target = Rigor::Type::Combinator.nominal_of(association[:target])
358
+ return target unless association[:nullable]
359
+
360
+ Rigor::Type::Combinator.union(target, Rigor::Type::Combinator.constant_of(nil))
361
+ end
362
+ end
363
+
364
+ # Instance-side column access. `user.name` on a
365
+ # `Nominal[User]` receiver narrows to the column's value
366
+ # type; `user.name?` (the ActiveRecord-generated predicate)
367
+ # narrows to `bool`.
368
+ #
369
+ # The contributed type is deliberately NON-nullable even
370
+ # though the DB column may permit `NULL`: Rails code calls
371
+ # column accessors directly (`user.email.downcase`) as a
372
+ # matter of course, and contributing `T | nil` would light
373
+ # up that idiom with `possible-nil-receiver` across an
374
+ # entire codebase. Under-reporting a nil column is a false
375
+ # negative; over-reporting it is a false positive — and the
376
+ # project ranks the latter as the worse failure.
377
+ def column_return_type(entry, method_name)
378
+ name = method_name.to_s
379
+ predicate = name.end_with?("?")
380
+ column_name = predicate ? name[0..-2] : name
381
+
382
+ column = entry.column(column_name)
383
+ return nil if column.nil?
384
+ return bool_type if predicate
385
+
386
+ ruby_type_to_type(column.ruby_type)
387
+ end
388
+
389
+ # Maps a `SchemaTable::Column#ruby_type` string to a Rigor
390
+ # type. `"Object"` (json / jsonb / unrecognised column
391
+ # types) declines — `Nominal[Object]` would be NARROWER
392
+ # than the RBS-erased envelope and could surface false
393
+ # `call.undefined-method` on a value whose real shape the
394
+ # plugin cannot model.
395
+ def ruby_type_to_type(ruby_type)
396
+ case ruby_type
397
+ when "bool" then bool_type
398
+ when "Object", nil then nil
399
+ else Rigor::Type::Combinator.nominal_of(ruby_type)
400
+ end
401
+ end
402
+
403
+ # `true | false`, the structural shape RBS `bool` folds to.
404
+ def bool_type
405
+ @bool_type ||= Rigor::Type::Combinator.union(
406
+ Rigor::Type::Combinator.constant_of(true),
407
+ Rigor::Type::Combinator.constant_of(false)
408
+ )
409
+ end
410
+
411
+ def constant_receiver_name(node)
412
+ case node
413
+ when Prism::ConstantReadNode then node.name.to_s
414
+ when Prism::ConstantPathNode then constant_path_name(node)
415
+ end
416
+ end
417
+
418
+ def constant_path_name(node)
419
+ parts = []
420
+ current = node
421
+ while current.is_a?(Prism::ConstantPathNode)
422
+ parts.unshift(current.name.to_s)
423
+ current = current.parent
424
+ end
425
+ case current
426
+ when nil then "::#{parts.join('::')}"
427
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
428
+ end
429
+ end
430
+
431
+ def call_argument_count(node)
432
+ return 0 if node.arguments.nil?
433
+
434
+ node.arguments.arguments.size
435
+ end
436
+
437
+ # Marshal-clean Hash form for the cross-plugin fact
438
+ # store. Consumers (rigor-actionpack Phase 1,
439
+ # rigor-factorybot Phase 1 (c), ...) get a flat
440
+ # `class_name → { table:, columns: }` map without
441
+ # depending on this plugin's `ModelIndex` /
442
+ # `SchemaTable::Column` carrier classes.
443
+ def index_to_published_hash(index)
444
+ index.entries.transform_values do |entry|
445
+ {
446
+ table: entry.table_name,
447
+ columns: entry.columns.map(&:name).freeze,
448
+ associations: entry.associations,
449
+ enums: entry.enums,
450
+ scopes: entry.scopes,
451
+ validations: entry.validated_attributes,
452
+ callbacks: entry.callbacks,
453
+ aliases: entry.aliases
454
+ }.freeze
455
+ end.freeze
456
+ end
457
+
458
+ def model_index
459
+ return @model_index if @model_index
460
+
461
+ table = schema_table_or_nil
462
+ return nil if table.nil?
463
+
464
+ # Walk model files first so the IoBoundary's digest list
465
+ # captures them BEFORE `cache_for` snapshots the
466
+ # descriptor (the same "read first, cache_for second"
467
+ # pattern documented at the top of rigor-routes).
468
+ ModelDiscoverer.new(
469
+ io_boundary: io_boundary,
470
+ search_paths: @model_search_paths,
471
+ base_classes: @model_base_classes
472
+ ).discover
473
+
474
+ @model_index = cache_for(:model_index, params: {}).call
475
+ rescue StandardError => e
476
+ @load_errors << "model index build failed: #{e.class}: #{e.message}"
477
+ nil
478
+ end
479
+
480
+ def schema_table_or_nil
481
+ return @schema_table if @schema_table
482
+
483
+ # Same pattern: read schema file via boundary, then call
484
+ # cache_for so the descriptor includes the file digest.
485
+ io_boundary.read_file(@schema_file)
486
+ @schema_table = cache_for(:schema_table, params: {}).call
487
+ rescue Plugin::AccessDeniedError => e
488
+ @load_errors << "rigor-activerecord: #{e.message}"
489
+ nil
490
+ rescue Errno::ENOENT
491
+ @load_errors << "rigor-activerecord: schema file `#{@schema_file}` not found; AR call checks skipped"
492
+ nil
493
+ rescue StandardError => e
494
+ @load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"
495
+ nil
496
+ end
497
+
498
+ def load_error_diagnostics(path)
499
+ @load_errors.uniq.map do |message|
500
+ Rigor::Analysis::Diagnostic.new(
501
+ path: path,
502
+ line: 1,
503
+ column: 1,
504
+ message: message,
505
+ severity: :warning,
506
+ rule: "load-error"
507
+ )
508
+ end
509
+ end
510
+ end
511
+
512
+ Rigor::Plugin.register(Activerecord)
513
+ end
514
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-activerecord` under `plugins:`. The
5
+ # loader expects this `require` to side-effect a call to
6
+ # `Rigor::Plugin.register`, which the body of
7
+ # `lib/rigor/plugin/activerecord.rb` performs at load time.
8
+ require_relative "rigor/plugin/activerecord"