rigortype 0.1.11 → 0.1.13
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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/check_rules.rb +96 -3
- data/lib/rigor/analysis/erb_template_detector.rb +38 -0
- data/lib/rigor/analysis/runner.rb +6 -1
- data/lib/rigor/analysis/worker_session.rb +6 -1
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli/skill_command.rb +170 -0
- data/lib/rigor/cli.rb +37 -1
- data/lib/rigor/configuration/severity_profile.rb +3 -0
- data/lib/rigor/inference/block_parameter_binder.rb +35 -0
- data/lib/rigor/inference/expression_typer.rb +69 -30
- data/lib/rigor/inference/indexed_narrowing.rb +187 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
- data/lib/rigor/inference/method_dispatcher.rb +23 -0
- data/lib/rigor/inference/mutation_widening.rb +285 -0
- data/lib/rigor/inference/narrowing.rb +72 -4
- data/lib/rigor/inference/scope_indexer.rb +409 -12
- data/lib/rigor/inference/statement_evaluator.rb +256 -4
- data/lib/rigor/scope.rb +195 -4
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +22 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +94 -6
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +11 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +7 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +135 -11
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +94 -43
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +138 -35
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +17 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +10 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +13 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +6 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +83 -7
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +4 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +16 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +81 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +11 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +194 -5
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +64 -3
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1107 -59
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +81 -4
- data/sig/rigor/scope.rbs +23 -0
- data/skills/rigor-baseline-reduce/SKILL.md +100 -0
- data/skills/rigor-baseline-reduce/references/01-classify.md +107 -0
- data/skills/rigor-baseline-reduce/references/02-fix-or-suppress.md +133 -0
- data/skills/rigor-plugin-author/SKILL.md +95 -0
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +195 -0
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +155 -0
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +163 -0
- data/skills/rigor-project-init/SKILL.md +129 -0
- data/skills/rigor-project-init/references/01-detect.md +101 -0
- data/skills/rigor-project-init/references/02-configure.md +185 -0
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +168 -0
- data/skills/rigor-project-init/references/04-sig-uplift.md +171 -0
- metadata +22 -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
|
-
|
|
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
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
-
|
|
69
|
+
seen_classes = {}
|
|
70
|
+
seen_modules = {}
|
|
52
71
|
methods = []
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
62
|
-
# a gem-shipped concern such as Devise's
|
|
63
|
-
# `Devise::Controllers::Helpers
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
179
|
+
resolved = resolve_constant_lexically(included, entry.enclosing_namespace)
|
|
180
|
+
collect_methods(resolved, seen, into)
|
|
102
181
|
end
|
|
103
182
|
end
|
|
104
183
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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(
|
|
151
|
-
|
|
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) ==
|
|
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
|
-
|
|
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 =
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
node
|
|
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('.
|
|
48
|
-
#
|
|
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
|
-
|
|
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,
|