rigortype 0.1.11 → 0.1.12

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  3. data/lib/rigor/analysis/runner.rb +6 -1
  4. data/lib/rigor/analysis/worker_session.rb +6 -1
  5. data/lib/rigor/cli/plugins_command.rb +308 -0
  6. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  7. data/lib/rigor/cli.rb +28 -0
  8. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  9. data/lib/rigor/inference/expression_typer.rb +69 -30
  10. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  11. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  12. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  13. data/lib/rigor/inference/mutation_widening.rb +285 -0
  14. data/lib/rigor/inference/narrowing.rb +72 -4
  15. data/lib/rigor/inference/scope_indexer.rb +409 -12
  16. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  17. data/lib/rigor/scope.rb +181 -4
  18. data/lib/rigor/version.rb +1 -1
  19. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
  20. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
  21. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
  22. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
  23. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
  24. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
  25. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
  26. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
  27. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
  28. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
  29. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
  30. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
  31. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
  32. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
  33. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
  34. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
  35. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
  36. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  37. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  38. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  39. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
  40. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
  41. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
  42. data/sig/rigor/scope.rbs +22 -0
  43. metadata +9 -1
@@ -28,7 +28,16 @@ module Rigor
28
28
  # names passed to `include X` calls inside the
29
29
  # class / module body (Strings). `parent_class_name` is
30
30
  # the immediate superclass (nil for plain modules).
31
- Entry = Data.define(:class_name, :defined_methods, :parent_class_name, :included_module_names)
31
+ # `enclosing_namespace` is the qualifier chain of the
32
+ # declaration's *lexical* enclosing scope (e.g.
33
+ # `["Admin"]` for an `Admin::Foo` declared via
34
+ # `module Admin; class Foo; end; end`). Used by the
35
+ # index's parent / module lookup to try lexically-scoped
36
+ # constant resolution before falling through to the
37
+ # top-level form — Ruby's constant lookup walks the
38
+ # enclosing scope chain before `Object`.
39
+ Entry = Data.define(:class_name, :defined_methods, :parent_class_name, :included_module_names,
40
+ :enclosing_namespace)
32
41
 
33
42
  attr_reader :entries
34
43
 
@@ -43,38 +52,105 @@ module Rigor
43
52
  end
44
53
 
45
54
  # Resolves the **effective** method set for a controller,
46
- # including methods inherited from its parent class
47
- # (one level) and methods contributed by every module the
48
- # controller / its parent transitively `include`s
49
- # (unbounded depth, cycle-safe via a visited set).
55
+ # including methods contributed by every module the
56
+ # controller / any ancestor transitively `include`s AND
57
+ # methods inherited from the ancestor chain (unbounded
58
+ # depth, cycle-safe via a visited set).
59
+ #
60
+ # Parent / module lookups are **lexically scoped** — a
61
+ # bare `BaseController` reference inside `module Admin`
62
+ # first tries `Admin::BaseController`, then falls
63
+ # through to the top-level `BaseController`. Matches
64
+ # Ruby's constant-resolution semantics. Without this an
65
+ # `Admin::Foo < BaseController` declaration would
66
+ # incorrectly walk the top-level `BaseController` even
67
+ # when an `Admin::BaseController` exists.
50
68
  def effective_methods_for(class_name)
51
- seen = {}
69
+ seen_classes = {}
70
+ seen_modules = {}
52
71
  methods = []
53
- collect_methods(class_name, seen, methods)
54
- if (parent = @entries[class_name]&.parent_class_name)
55
- collect_methods(parent, seen, methods)
72
+ current = class_name
73
+ while current && !seen_classes[current]
74
+ seen_classes[current] = true
75
+ entry = @entries[current]
76
+ break if entry.nil?
77
+
78
+ methods.concat(entry.defined_methods)
79
+ entry.included_module_names.each do |included|
80
+ resolved_include = resolve_constant_lexically(included, entry.enclosing_namespace)
81
+ collect_methods(resolved_include, seen_modules, methods)
82
+ end
83
+ next_parent = entry.parent_class_name
84
+ # Same self-reference guard as `unresolved_include?`:
85
+ # `class ActivityPub::ApplicationController <
86
+ # ::ApplicationController` lexically resolves
87
+ # `ApplicationController` to itself; fall back to
88
+ # the unprefixed top-level name in that case.
89
+ current = if next_parent
90
+ resolved = resolve_constant_lexically(next_parent, entry.enclosing_namespace)
91
+ resolved == current ? next_parent.sub(/\A::/, "") : resolved
92
+ end
56
93
  end
57
94
  methods.uniq.freeze
58
95
  end
59
96
 
60
97
  # @return [Boolean] true when the class has at least one
61
- # include we couldn't resolve in the index (typically
62
- # a gem-shipped concern such as Devise's
63
- # `Devise::Controllers::Helpers`). Phase 2 uses this
64
- # to downgrade `unknown-filter-method` to silence —
65
- # the unresolved module may legitimately contribute
66
- # the filter, and there's no way for the static
67
- # analyzer to verify.
98
+ # include OR parent class we couldn't resolve in the
99
+ # index (typically a gem-shipped concern such as Devise's
100
+ # `Devise::Controllers::Helpers`, or a gem-shipped
101
+ # parent controller such as `Devise::ConfirmationsController`
102
+ # or `Doorkeeper::AuthorizedApplicationsController`).
103
+ # Phase 2 uses this to downgrade `unknown-filter-method`
104
+ # to silence — the unresolved module / parent may
105
+ # legitimately contribute the filter (either directly,
106
+ # or via its own ancestor chain which the static
107
+ # analyzer cannot follow), and there's no way to verify.
68
108
  def unresolved_include?(class_name)
69
109
  entry = @entries[class_name]
70
110
  return false if entry.nil?
71
111
 
72
- chain = [class_name]
73
- chain << entry.parent_class_name if entry.parent_class_name
74
- chain.any? do |c|
75
- walk_includes(c, {}) { |m| return true unless @entries.key?(m) }
76
- false
112
+ # Walk the full ancestor chain. `Admin::Foo <
113
+ # BaseController < ApplicationController` should report
114
+ # an unresolved include if ANY of the three references
115
+ # a gem-shipped concern OR a gem-shipped parent class
116
+ # we cannot index. The first iteration's `current_entry`
117
+ # is always resolved (caller verified `known?`); a nil
118
+ # `current_entry` on a subsequent iteration means the
119
+ # AST-side `< Parent` reached a gem-shipped class
120
+ # whose ancestor methods are invisible to us.
121
+ seen_classes = {}
122
+ current = class_name
123
+ first = true
124
+ while current && !seen_classes[current]
125
+ seen_classes[current] = true
126
+ current_entry = @entries[current]
127
+ if current_entry.nil?
128
+ return true unless first
129
+
130
+ break
131
+ end
132
+ first = false
133
+
134
+ current_entry.included_module_names.each do |included|
135
+ resolved = resolve_constant_lexically(included, current_entry.enclosing_namespace)
136
+ return true if resolved.nil? || !@entries.key?(resolved)
137
+ end
138
+ next_parent = current_entry.parent_class_name
139
+ # Avoid lexically resolving to ourselves. `class
140
+ # ActivityPub::ApplicationController < ::ApplicationController`
141
+ # would otherwise resolve `ApplicationController` (in
142
+ # the lexical scope `["ActivityPub"]`) to
143
+ # `ActivityPub::ApplicationController` and short-
144
+ # circuit the walk before reaching the top-level
145
+ # parent. When the lexical match is the current
146
+ # class itself, fall back to the unprefixed
147
+ # top-level name.
148
+ current = if next_parent
149
+ resolved = resolve_constant_lexically(next_parent, current_entry.enclosing_namespace)
150
+ resolved == current ? next_parent.sub(/\A::/, "") : resolved
151
+ end
77
152
  end
153
+ false
78
154
  end
79
155
 
80
156
  def empty?
@@ -92,30 +168,57 @@ module Rigor
92
168
  private
93
169
 
94
170
  def collect_methods(name, seen, into)
171
+ return if name.nil?
172
+
95
173
  entry = @entries[name]
96
174
  return if entry.nil? || seen[name]
97
175
 
98
176
  seen[name] = true
99
177
  into.concat(entry.defined_methods)
100
178
  entry.included_module_names.each do |included|
101
- collect_methods(included, seen, into)
179
+ resolved = resolve_constant_lexically(included, entry.enclosing_namespace)
180
+ collect_methods(resolved, seen, into)
102
181
  end
103
182
  end
104
183
 
105
- # Yields each transitively-included module name (whether
106
- # we have an entry for it or not). Returns nil; callers
107
- # use it for visit-and-classify, not to collect.
108
- def walk_includes(name, seen, &)
109
- return if seen[name]
110
-
111
- seen[name] = true
112
- entry = @entries[name]
113
- return unless entry
114
-
115
- entry.included_module_names.each do |included|
116
- yield included
117
- walk_includes(included, seen, &)
184
+ # Ruby's constant-lookup walk: a bare constant name
185
+ # inside `module Admin` first tries `Admin::Const`,
186
+ # then walks outward, and finally falls through to
187
+ # top-level `Const`. We approximate that by trying
188
+ # the candidate `enclosing + name` chains from
189
+ # deepest to shallowest, and returning the first
190
+ # candidate that has an entry in the index. When
191
+ # nothing resolves, return the original name
192
+ # unchanged — the caller treats unresolved entries as
193
+ # "gem-shipped concerns we cannot see" (the
194
+ # `unresolved_include?` predicate).
195
+ #
196
+ # `name` may already be qualified (`Foo::Bar`); we
197
+ # only try lexical prefixing when the unqualified
198
+ # first segment doesn't match a top-level entry.
199
+ def resolve_constant_lexically(name, enclosing)
200
+ return nil if name.nil?
201
+
202
+ # A leading `::` denotes the top-level constant
203
+ # explicitly (`< ::ApplicationController`). Strip it
204
+ # for index lookup — the discoverer registers entries
205
+ # under their unprefixed name. Without this strip a
206
+ # `class ActivityPub::ApplicationController <
207
+ # ::ApplicationController` parent never resolved.
208
+ name = name.sub(/\A::/, "")
209
+
210
+ # Constant already absolute or no enclosing scope.
211
+ return name if enclosing.nil? || enclosing.empty?
212
+
213
+ # Try the deepest enclosing scope first, walking
214
+ # outward. `enclosing = ["A", "B"]` produces
215
+ # candidates `["A::B::name", "A::name", "name"]`.
216
+ enclosing.length.downto(0).each do |depth|
217
+ prefix = enclosing[0, depth]
218
+ candidate = prefix.empty? ? name : "#{prefix.join('::')}::#{name}"
219
+ return candidate if @entries.key?(candidate)
118
220
  end
221
+ name
119
222
  end
120
223
  end
121
224
  end
@@ -68,7 +68,18 @@ module Rigor
68
68
  class Actionpack < Rigor::Plugin::Base
69
69
  manifest(
70
70
  id: "actionpack",
71
- version: "0.1.0",
71
+ # Bumped 2026-05-27 — analyzer-side nested-module
72
+ # qualification slice. `diagnose_filters` and
73
+ # `diagnose_renders` now thread the enclosing
74
+ # namespace through the AST walk so a
75
+ # `module Admin; class DomainBlocksController; end`
76
+ # file resolves as `Admin::DomainBlocksController`
77
+ # — matching the qualification the
78
+ # `ControllerDiscoverer` already records. Fixes render
79
+ # paths (`admin/domain_blocks/new` not bare
80
+ # `domain_blocks/new`) and filter-chain validation
81
+ # silently skipping nested controllers.
82
+ version: "0.7.0",
72
83
  description: "Validates Action Pack route-helper calls and filter chains inside controllers.",
73
84
  config_schema: {
74
85
  "controller_search_paths" => :array,
@@ -147,8 +158,11 @@ module Rigor
147
158
  # render shapes are recognised purely from the call site
148
159
  # + class name, no per-controller pre-discovery needed.
149
160
  def render_diagnostics(path, root)
150
- Analyzer.diagnose_renders(path: path, root: root, view_search_roots: @view_search_paths)
151
- .map { |diag| build_diagnostic(diag) }
161
+ Analyzer.diagnose_renders(
162
+ path: path, root: root,
163
+ view_search_roots: @view_search_paths,
164
+ controller_index: controller_index_or_nil
165
+ ).map { |diag| build_diagnostic(diag) }
152
166
  end
153
167
 
154
168
  # Phase 1 — strong-parameter validation. Reads the
@@ -90,6 +90,16 @@ module Rigor
90
90
  keyword_pairs = keyword_argument_pairs(node)
91
91
  return push_recognised(node, entry) if keyword_pairs.empty?
92
92
 
93
+ # Models with no schema-side columns are virtual — backed
94
+ # by a database VIEW (Mastodon's `Instance` model wraps a
95
+ # SQL view that isn't in `db/schema.rb`), seeded from an
96
+ # external source, or otherwise opaque to our schema
97
+ # parser. Without a column set we cannot meaningfully
98
+ # check query keys; surface the call as recognised and
99
+ # skip column validation entirely rather than firing a
100
+ # false `unknown-column` against every key.
101
+ return push_recognised(node, entry, keyword_pairs.map { |p| p[:key] }) if entry.column_names.empty?
102
+
93
103
  unknown = keyword_pairs.reject { |pair| valid_query_key?(entry, pair[:key]) }
94
104
  if unknown.empty?
95
105
  keyword_pairs.each { |pair| validate_enum_value(node, entry, pair) }
@@ -157,7 +157,8 @@ module Rigor
157
157
  SchemaTable::Column.new(
158
158
  name: name,
159
159
  type: type,
160
- ruby_type: SchemaTable.ruby_type_for(type)
160
+ ruby_type: SchemaTable.ruby_type_for(type),
161
+ array: keyword_true?(call_node, :array)
161
162
  )
162
163
  end
163
164
 
@@ -180,6 +181,14 @@ module Rigor
180
181
  end
181
182
 
182
183
  def references_polymorphic?(call_node)
184
+ keyword_true?(call_node, :polymorphic)
185
+ end
186
+
187
+ # Returns true iff `call_node` has a `name: true` keyword
188
+ # argument. Used to detect schema modifiers like
189
+ # `t.bigint "status_ids", array: true` (Postgres array
190
+ # column) and `t.references "x", polymorphic: true`.
191
+ def keyword_true?(call_node, name)
183
192
  return false if call_node.arguments.nil?
184
193
 
185
194
  call_node.arguments.arguments.each do |arg|
@@ -187,7 +196,7 @@ module Rigor
187
196
 
188
197
  arg.elements.each do |pair|
189
198
  next unless pair.is_a?(Prism::AssocNode)
190
- next unless symbol_key(pair.key) == :polymorphic
199
+ next unless symbol_key(pair.key) == name
191
200
 
192
201
  return pair.value.is_a?(Prism::TrueNode)
193
202
  end
@@ -207,7 +216,8 @@ module Rigor
207
216
  SchemaTable::Column.new(
208
217
  name: name,
209
218
  type: type_sym,
210
- ruby_type: SchemaTable.ruby_type_for(type_sym)
219
+ ruby_type: SchemaTable.ruby_type_for(type_sym),
220
+ array: keyword_true?(call_node, :array)
211
221
  )
212
222
  end
213
223
 
@@ -16,8 +16,12 @@ module Rigor
16
16
  # ltree, hstore, custom) fall back to `Object` so the
17
17
  # plugin stays silent rather than guessing.
18
18
  class SchemaTable
19
- Column = Struct.new(:name, :type, :ruby_type, keyword_init: true) do
20
- def to_h = { name: name, type: type, ruby_type: ruby_type }
19
+ Column = Struct.new(:name, :type, :ruby_type, :array, keyword_init: true) do
20
+ def to_h = { name: name, type: type, ruby_type: ruby_type, array: array }
21
+
22
+ def array?
23
+ array == true
24
+ end
21
25
  end
22
26
 
23
27
  # Map ActiveRecord column types → Ruby class names.
@@ -59,7 +59,14 @@ module Rigor
59
59
  class Activerecord < Rigor::Plugin::Base
60
60
  manifest(
61
61
  id: "activerecord",
62
- version: "0.1.0",
62
+ # Bumped 2026-05-28 — implicit-self class-side AR call
63
+ # resolution: `select(:uri).group(:uri)` inside a scope
64
+ # lambda body / class-method body now contributes
65
+ # `Relation[Model]` via `scope.self_type` instead of
66
+ # falling through to `Kernel#select` (the IO multiplexer,
67
+ # `Array[String]` return). Plus `:select` added to the
68
+ # relation-entry-point list.
69
+ version: "0.5.0",
63
70
  description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
64
71
  config_schema: {
65
72
  "schema_file" => :string,
@@ -167,10 +174,33 @@ module Rigor
167
174
  return load_error_diagnostics(path)
168
175
  end
169
176
  return [] if index.empty?
177
+ return [] if migration_path?(path)
170
178
 
171
179
  Analyzer.new(path: path, model_index: index).analyze(root).diagnostics
172
180
  end
173
181
 
182
+ # Rails migration files (`db/migrate/<timestamp>_*.rb`)
183
+ # and post-migration files (`db/post_migrate/`) reference
184
+ # the EVOLVING schema at the time the migration was
185
+ # written — `User.where(admin: ...)` is valid when the
186
+ # migration ran on a schema that still had the `admin`
187
+ # column, even though the current `db/schema.rb` no
188
+ # longer carries it. Validating these files against the
189
+ # CURRENT schema is a category error; the column
190
+ # diagnostics MUST stay silent.
191
+ MIGRATION_PATH_PATTERNS = [
192
+ %r{(\A|/)db/migrate/},
193
+ %r{(\A|/)db/post_migrate/}
194
+ ].freeze
195
+ private_constant :MIGRATION_PATH_PATTERNS
196
+
197
+ def migration_path?(path)
198
+ return false if path.nil?
199
+
200
+ path_s = path.to_s
201
+ MIGRATION_PATH_PATTERNS.any? { |pattern| path_s.match?(pattern) }
202
+ end
203
+
174
204
  # v0.1.2 — return-type contribution. `Model.find(id)`
175
205
  # narrows the call site's return type to `Nominal[Model]`,
176
206
  # so chained calls (`User.find(1).name`) resolve through
@@ -183,14 +213,18 @@ module Rigor
183
213
  # more precise than the RBS envelope.
184
214
  def flow_contribution_for(call_node:, scope:)
185
215
  return nil unless call_node.is_a?(Prism::CallNode)
186
- return nil if call_node.receiver.nil?
187
216
 
188
217
  index = model_index
189
218
  return nil if index.nil? || index.empty?
190
219
 
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)
220
+ return_type =
221
+ if call_node.receiver
222
+ class_call_return_type(call_node, index) ||
223
+ relation_call_return_type(call_node, scope, index) ||
224
+ instance_call_return_type(call_node, scope, index)
225
+ else
226
+ implicit_self_class_call_return_type(call_node, scope, index)
227
+ end
194
228
  return nil if return_type.nil?
195
229
 
196
230
  Rigor::FlowContribution.new(
@@ -217,6 +251,37 @@ module Rigor
217
251
  class_scope_return_type(call_node, entry)
218
252
  end
219
253
 
254
+ # Implicit-self class-side call: `select(:uri)` /
255
+ # `where(active: true)` inside a `def self.<method>` body,
256
+ # a class body, or a scope lambda body (`scope :x, -> { ... }`).
257
+ # The surrounding `self_type` is `Singleton[Model]` in all
258
+ # three cases, so the same finder / scope / relation entry-
259
+ # point resolution that handles `Model.where(...)` applies.
260
+ #
261
+ # Without this, `select(:uri)` inside a class body falls
262
+ # through to RBS dispatch on `Singleton[Account]`, which
263
+ # finds `Kernel#select` (the IO multiplexer) at
264
+ # `core/kernel.rbs` — its `Array[String]` return masks the
265
+ # AR class-side `select`'s relation return type, so the
266
+ # canonical scope-body idiom
267
+ #
268
+ # scope :duplicate_uris, -> { select(:uri).group(:uri) }
269
+ #
270
+ # types `select(:uri)` as `Array[String]` and the chained
271
+ # `.group` as `undefined-method`.
272
+ def implicit_self_class_call_return_type(call_node, scope, index)
273
+ return nil if scope.nil?
274
+
275
+ self_type = scope.self_type
276
+ return nil unless self_type.is_a?(Rigor::Type::Singleton)
277
+
278
+ entry = index.find(self_type.class_name) || index.find("::#{self_type.class_name}")
279
+ return nil if entry.nil?
280
+
281
+ finder_return_type(call_node, entry) ||
282
+ class_scope_return_type(call_node, entry)
283
+ end
284
+
220
285
  # Class-side finders + the class-side relation entry points.
221
286
  # `find` / `find_by!` return the model; `find_by` adds the
222
287
  # `nil` arm; `where` / `all` / `order` / `limit` / `none`
@@ -238,7 +303,15 @@ module Rigor
238
303
  Rigor::Type::Combinator.nominal_of(entry.class_name),
239
304
  Rigor::Type::Combinator.constant_of(nil)
240
305
  )
241
- when :where, :all, :order, :limit, :none
306
+ when :where, :all, :order, :limit, :none, :select
307
+ # `:select` was added to close Mastodon's
308
+ # `scope :duplicate_uris, -> { select(:uri).group(:uri).having(...) }`
309
+ # shape: the implicit-self `select(:uri)` inside the
310
+ # scope lambda body had been resolving to `Kernel#select`
311
+ # (IO multiplexer, return `Array[String]`), masking the
312
+ # AR class-side relation entry point. The rest of the
313
+ # query DSL chains through the bundled `ActiveRecord::Relation`
314
+ # RBS once a relation is open.
242
315
  relation_of(entry.class_name)
243
316
  end
244
317
  end
@@ -383,7 +456,10 @@ module Rigor
383
456
  return nil if column.nil?
384
457
  return bool_type if predicate
385
458
 
386
- ruby_type_to_type(column.ruby_type)
459
+ inner = ruby_type_to_type(column.ruby_type)
460
+ return nil if inner.nil?
461
+
462
+ column.array? ? Rigor::Type::Combinator.nominal_of("Array", type_args: [inner]) : inner
387
463
  end
388
464
 
389
465
  # Maps a `SchemaTable::Column#ruby_type` string to a Rigor
@@ -23,7 +23,10 @@ module Rigor
23
23
  class ActivesupportCoreExt < Rigor::Plugin::Base
24
24
  manifest(
25
25
  id: "activesupport-core-ext",
26
- version: "0.1.0",
26
+ # Bumped 2026-05-28 — added Date#midnight /
27
+ # at_midnight / beginning_of_day / end_of_day (Rails
28
+ # aliases that return Time, not Date).
29
+ version: "0.2.0",
27
30
  description: "RBS bundle for the most-frequently-flagged ActiveSupport core_ext extensions.",
28
31
  signature_paths: ["sig"]
29
32
  )
@@ -302,6 +302,18 @@ class Date
302
302
  def ago: (Numeric seconds) -> Time
303
303
  def since: (Numeric seconds) -> Time
304
304
  def acts_like_date?: () -> true
305
+
306
+ # `core_ext/date/calculations` — `Date#midnight` /
307
+ # `Date#at_midnight` are aliases for `beginning_of_day`.
308
+ # Rails' `beginning_of_day` on Date returns a Time at
309
+ # midnight of that day (not a Date). Mastodon's
310
+ # `Date.current.at_midnight` shape relies on this.
311
+ def beginning_of_day: () -> Time
312
+ def midnight: () -> Time
313
+ def at_midnight: () -> Time
314
+ def at_beginning_of_day: () -> Time
315
+ def end_of_day: () -> Time
316
+ def at_end_of_day: () -> Time
305
317
  end
306
318
 
307
319
  # ---------------------------------------------------------------
@@ -401,8 +413,11 @@ class Hash[unchecked out K, unchecked out V]
401
413
  | (Hash[K, V]) { (K, V, V) -> V } -> self
402
414
 
403
415
  # `core_ext/hash/except` — `Hash#except` is in core RBS as of
404
- # Ruby 3.0+; `except!` is ActiveSupport-only.
416
+ # Ruby 3.0+; `except!` is ActiveSupport-only. ActiveSupport
417
+ # also aliases `Hash#without` to `Hash#except`, used by
418
+ # `Mastodon`-shaped `options.without('type').merge(...)` chains.
405
419
  def except!: (*K) -> self
420
+ def without: (*K) -> Hash[K, V]
406
421
 
407
422
  # `core_ext/hash/conversions`
408
423
  def to_query: (?String namespace) -> String
@@ -36,6 +36,12 @@ module Rigor
36
36
  # `::I18n.t`).
37
37
  I18N_RECEIVER_NAMES = %w[I18n ::I18n].freeze
38
38
 
39
+ # Matches controller file paths so lazy keys (`.key`)
40
+ # can be expanded to `<controller_scope>.<action>.<key>`.
41
+ # Captures the path segment between `controllers/` and
42
+ # `_controller.rb` (e.g. `users`, `admin/users`).
43
+ CONTROLLER_PATH_RE = %r{(?:^|/)controllers/(.+)_controller\.rb$}
44
+
39
45
  # Reserved option keys — these are recognised by I18n
40
46
  # itself and not treated as interpolation variables.
41
47
  RESERVED_OPTION_KEYS = %i[
@@ -43,19 +49,54 @@ module Rigor
43
49
  fallback_in_progress separator deep_interpolation
44
50
  ].to_set.freeze
45
51
 
52
+ # Key prefixes Rails / `rails-i18n` ship in every
53
+ # locale by default. Projects whose own locale files
54
+ # don't redeclare them still get them at runtime (via
55
+ # the `rails-i18n` gem's bundled locale catalogues).
56
+ # `t('date.order')` is the canonical Mastodon case —
57
+ # used by `Settings::Date::Order` and date-of-birth
58
+ # selects, never authored project-side. Skip
59
+ # `unknown-key` for these; downstream interpolation
60
+ # checks have nothing to validate without a leaf entry
61
+ # so they decline silently too.
62
+ RAILS_SHIPPED_KEY_PREFIXES = %w[
63
+ date.
64
+ time.
65
+ datetime.
66
+ support.array.
67
+ errors.format
68
+ errors.messages.
69
+ number.
70
+ helpers.select.
71
+ helpers.submit.
72
+ helpers.label.
73
+ i18n.transliterate.
74
+ activerecord.errors.messages.
75
+ activerecord.errors.models.
76
+ ].freeze
77
+
46
78
  Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
47
79
 
48
80
  module_function
49
81
 
82
+ def rails_shipped_key?(literal_key)
83
+ key_str = literal_key.to_s
84
+ RAILS_SHIPPED_KEY_PREFIXES.any? { |prefix| key_str.start_with?(prefix) }
85
+ end
86
+
50
87
  # @param path [String]
51
88
  # @param root [Prism::Node]
52
89
  # @param locale_index [LocaleIndex]
53
90
  # @param configured_locales [Array<String>]
54
91
  # @return [Array<Diagnostic>]
55
92
  def diagnose(path:, root:, locale_index:, configured_locales:)
93
+ controller_scope = controller_scope_from_path(path)
56
94
  diagnostics = []
57
- walk(root) do |call_node|
58
- literal_key = literal_key_for(call_node)
95
+ walk(root) do |call_node, action|
96
+ raw_key = literal_key_for(call_node)
97
+ next if raw_key.nil?
98
+
99
+ literal_key = expand_key(raw_key, controller_scope: controller_scope, action: action)
59
100
  next if literal_key.nil?
60
101
 
61
102
  options = options_hash(call_node)
@@ -70,6 +111,14 @@ module Rigor
70
111
  # from).
71
112
  next if locale_index.pluralization_namespace?(literal_key)
72
113
 
114
+ # Rails / rails-i18n ship `date.order`, `time.am`,
115
+ # `support.array.words_connector`, etc. in every
116
+ # locale at runtime even when the project's own
117
+ # locale files don't repeat them. Accept silently
118
+ # — no leaf entry → no downstream interpolation
119
+ # check to run.
120
+ next if rails_shipped_key?(literal_key)
121
+
73
122
  diagnostics << unknown_key_diagnostic(path, call_node, literal_key, locale_index)
74
123
  next
75
124
  end
@@ -85,11 +134,38 @@ module Rigor
85
134
  diagnostics
86
135
  end
87
136
 
88
- def walk(node, &)
137
+ # Walks the AST yielding `[call_node, action]` pairs where
138
+ # `action` is the name of the innermost enclosing `def`
139
+ # method (or `nil` when the call is at the top level).
140
+ def walk(node, action: nil, &)
89
141
  return unless node.is_a?(Prism::Node)
90
142
 
91
- yield node if node.is_a?(Prism::CallNode) && translate_call_candidate?(node)
92
- node.compact_child_nodes.each { |child| walk(child, &) }
143
+ current_action = node.is_a?(Prism::DefNode) ? node.name.to_s : action
144
+ yield node, current_action if node.is_a?(Prism::CallNode) && translate_call_candidate?(node)
145
+ node.compact_child_nodes.each { |child| walk(child, action: current_action, &) }
146
+ end
147
+
148
+ # Derives the Rails controller scope from the file path,
149
+ # e.g. `app/controllers/admin/users_controller.rb` → `admin.users`.
150
+ # Returns nil for non-controller paths.
151
+ def controller_scope_from_path(path)
152
+ m = CONTROLLER_PATH_RE.match(path.to_s)
153
+ return nil unless m
154
+
155
+ m[1].tr("/", ".")
156
+ end
157
+
158
+ # Expands a lazy key (starting with `.`) to its full
159
+ # dotted path using the controller scope and action name.
160
+ # Returns the raw key unchanged for absolute keys.
161
+ # Returns nil for lazy keys outside a controller context
162
+ # or without an enclosing action — these are silently
163
+ # skipped to avoid false positives.
164
+ def expand_key(raw_key, controller_scope:, action:)
165
+ return raw_key unless raw_key.start_with?(".")
166
+ return nil unless controller_scope && action
167
+
168
+ "#{controller_scope}.#{action}#{raw_key}"
93
169
  end
94
170
 
95
171
  def translate_call_candidate?(node)
@@ -44,8 +44,13 @@ module Rigor
44
44
  #
45
45
  # - Only literal-string keys are validated. `t(key)` with
46
46
  # a variable receiver is silently passed through.
47
- # - Lazy lookup (`t('.title')` resolved against the
48
- # rendered controller / view path) is out of scope.
47
+ # - Lazy lookup (`t('.key')`) is supported for controller
48
+ # files (`app/controllers/**/*_controller.rb`): the key
49
+ # is expanded to `<controller_scope>.<action>.<key>`
50
+ # using the file path and the innermost enclosing `def`.
51
+ # Lazy keys in non-controller `.rb` files (models, helpers,
52
+ # mailers, …) are silently skipped — the controller/action
53
+ # scope cannot be statically determined there.
49
54
  # - Pluralization (`t('errors.messages.too_short',
50
55
  # count: n)`) is recognised at the call site but the
51
56
  # `count` key is not used to validate the locale's
@@ -56,7 +61,10 @@ module Rigor
56
61
  class RailsI18n < Rigor::Plugin::Base
57
62
  manifest(
58
63
  id: "rails-i18n",
59
- version: "0.1.0",
64
+ # Bumped 2026-05-28 — skip `unknown-key` on Rails / rails-
65
+ # i18n shipped defaults (`date.order`, `time.am`,
66
+ # `support.array.*`, `errors.format`, …).
67
+ version: "0.2.0",
60
68
  description: "Validates I18n `t(key)` calls against `config/locales/*.yml`.",
61
69
  config_schema: {
62
70
  "locale_search_paths" => :array,