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.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- metadata +149 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
class Actionpack < Rigor::Plugin::Base
|
|
9
|
+
# Per-file walker — the controller's parsed AST is searched
|
|
10
|
+
# for `*_path` / `*_url` calls and each is validated against
|
|
11
|
+
# the helper table the upstream `rigor-rails-routes` plugin
|
|
12
|
+
# publishes via `services.fact_store`.
|
|
13
|
+
#
|
|
14
|
+
# The recogniser keys on call-method-name suffix:
|
|
15
|
+
#
|
|
16
|
+
# - `users_path`, `edit_user_path(@user)` → `_path` family.
|
|
17
|
+
# - `users_url`, `edit_user_url(@user)` → `_url` family.
|
|
18
|
+
#
|
|
19
|
+
# Any call whose name doesn't end in `_path` / `_url` is
|
|
20
|
+
# silently passed through. Calls with an explicit non-self
|
|
21
|
+
# receiver (`other_helper.users_path`) are also skipped —
|
|
22
|
+
# the helper is implicit-self in real controllers, and a
|
|
23
|
+
# custom-receiver call is almost certainly someone's own
|
|
24
|
+
# method that happens to share the suffix.
|
|
25
|
+
module Analyzer
|
|
26
|
+
SUFFIXES = %w[_path _url].freeze
|
|
27
|
+
|
|
28
|
+
# Phase 2 — filter-chain DSL methods. Each takes a
|
|
29
|
+
# variadic list of filter names (Symbols / Strings) plus
|
|
30
|
+
# optional `only:` / `except:` / `if:` / `unless:`
|
|
31
|
+
# modifiers. The validation key is the filter NAMES; the
|
|
32
|
+
# modifiers are accepted but their action-name argument
|
|
33
|
+
# is not yet validated (Phase 2.5).
|
|
34
|
+
FILTER_DSL_METHODS = %i[
|
|
35
|
+
before_action after_action around_action
|
|
36
|
+
skip_before_action skip_after_action skip_around_action
|
|
37
|
+
prepend_before_action prepend_after_action prepend_around_action
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# Phase 3 — render-target template extensions checked in
|
|
41
|
+
# priority order. The first six cover the templating
|
|
42
|
+
# engines used by the projects this plugin is regularly
|
|
43
|
+
# exercised against: ERB (Rails default — `.html.erb`,
|
|
44
|
+
# `.text.erb`), HAML (Mastodon, Solidus admin —
|
|
45
|
+
# `.html.haml`), Slim, and JSON (`.json.jbuilder` plus a
|
|
46
|
+
# raw `.json.erb` for hand-rolled API responses). When a
|
|
47
|
+
# template exists under any of these extensions, the
|
|
48
|
+
# missing-template diagnostic stays silent.
|
|
49
|
+
# Configurable extension list is queued — see the
|
|
50
|
+
# `external-author plugin SKILL` track (v0.2.0). For now
|
|
51
|
+
# this set is wide enough to cover the surveyed real-world
|
|
52
|
+
# projects without leaking FPs.
|
|
53
|
+
RENDER_TEMPLATE_EXTENSIONS = %w[
|
|
54
|
+
.html.erb
|
|
55
|
+
.text.erb
|
|
56
|
+
.html.haml
|
|
57
|
+
.text.haml
|
|
58
|
+
.html.slim
|
|
59
|
+
.json.jbuilder
|
|
60
|
+
.json.erb
|
|
61
|
+
.xml.builder
|
|
62
|
+
.xml.erb
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
# Phase 1 — strong-parameter call shapes that begin a
|
|
66
|
+
# validatable chain. The walker matches the
|
|
67
|
+
# `params.require(:user).permit(:name, :email)` chain
|
|
68
|
+
# by looking for `:permit` call sites whose receiver is
|
|
69
|
+
# `params.require(:symbol)`.
|
|
70
|
+
STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
|
|
71
|
+
|
|
72
|
+
Diagnostic = Data.define(:path, :line, :column, :message, :severity, :rule)
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# @param path [String] absolute path to the file being
|
|
77
|
+
# analysed (used for diagnostic locations).
|
|
78
|
+
# @param root [Prism::Node] the parsed AST root.
|
|
79
|
+
# @param helper_table [Hash{String => Hash}] the value
|
|
80
|
+
# `services.fact_store.read(plugin_id: "rails-routes",
|
|
81
|
+
# name: :helper_table)` returns. Each entry carries
|
|
82
|
+
# `name`, `arity`, `path`, `http_method`, `action`.
|
|
83
|
+
# @return [Array<Diagnostic>]
|
|
84
|
+
def diagnose(path:, root:, helper_table:)
|
|
85
|
+
diagnostics = []
|
|
86
|
+
known_names = helper_table.keys.freeze
|
|
87
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: known_names)
|
|
88
|
+
|
|
89
|
+
walk(root) do |call_node|
|
|
90
|
+
entry, suggestion = lookup(call_node, helper_table, spell_checker)
|
|
91
|
+
diagnostic = diagnostic_for(path, call_node, entry, suggestion)
|
|
92
|
+
diagnostics << diagnostic if diagnostic
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
diagnostics
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Phase 2 — filter-chain validation. Walks the file's
|
|
99
|
+
# top-level class node, looks it up in the controller
|
|
100
|
+
# index to get the effective method set (including
|
|
101
|
+
# one level of inheritance), and validates that every
|
|
102
|
+
# `before_action :name` reference resolves to a defined
|
|
103
|
+
# method. Files that don't contain a known controller
|
|
104
|
+
# contribute no diagnostics.
|
|
105
|
+
def diagnose_filters(path:, root:, controller_index:)
|
|
106
|
+
class_node = first_class_node(root)
|
|
107
|
+
return [] if class_node.nil?
|
|
108
|
+
|
|
109
|
+
class_name = qualified_name_for(class_node.constant_path)
|
|
110
|
+
return [] if class_name.nil?
|
|
111
|
+
return [] unless controller_index.known?(class_name)
|
|
112
|
+
|
|
113
|
+
methods = controller_index.effective_methods_for(class_name)
|
|
114
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: methods.map(&:to_s))
|
|
115
|
+
# When the controller (or its parent) `include`s a
|
|
116
|
+
# module the discoverer couldn't resolve — typically a
|
|
117
|
+
# gem-shipped concern such as `Devise::Controllers::
|
|
118
|
+
# Helpers` or `Pundit::Authorization` — any
|
|
119
|
+
# `before_action :name` MIGHT be defined in that
|
|
120
|
+
# unresolved module. Suppress `unknown-filter-method`
|
|
121
|
+
# in that case rather than FPing on legitimate
|
|
122
|
+
# gem-provided callback names.
|
|
123
|
+
ambiguous_filters = controller_index.unresolved_include?(class_name)
|
|
124
|
+
|
|
125
|
+
collect_filter_diagnostics(path, class_node.body, methods, spell_checker, ambiguous_filters: ambiguous_filters)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def collect_filter_diagnostics(path, body, methods, spell_checker, ambiguous_filters:)
|
|
129
|
+
diagnostics = []
|
|
130
|
+
walk_filter_calls(body) do |call_node|
|
|
131
|
+
filter_name_args(call_node).each do |arg_node|
|
|
132
|
+
filter_name = literal_symbol_or_string(arg_node)
|
|
133
|
+
next if filter_name.nil?
|
|
134
|
+
|
|
135
|
+
diag = filter_lookup_diagnostic(
|
|
136
|
+
path, call_node, arg_node, filter_name, methods, spell_checker,
|
|
137
|
+
ambiguous_filters: ambiguous_filters
|
|
138
|
+
)
|
|
139
|
+
diagnostics << diag if diag
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
diagnostics
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def filter_lookup_diagnostic(path, call_node, arg_node, filter_name, methods, spell_checker, ambiguous_filters:)
|
|
146
|
+
if methods.include?(filter_name.to_sym)
|
|
147
|
+
filter_call_diagnostic(path, call_node, filter_name)
|
|
148
|
+
elsif ambiguous_filters
|
|
149
|
+
# An unresolved include shadows our judgment — emit
|
|
150
|
+
# the recognized-filter info anyway so the call site
|
|
151
|
+
# is still indexed, but skip the error.
|
|
152
|
+
filter_call_diagnostic(path, call_node, filter_name)
|
|
153
|
+
else
|
|
154
|
+
unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def walk_filter_calls(node, &)
|
|
159
|
+
return unless node.is_a?(Prism::Node)
|
|
160
|
+
|
|
161
|
+
yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && FILTER_DSL_METHODS.include?(node.name)
|
|
162
|
+
node.compact_child_nodes.each { |child| walk_filter_calls(child, &) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Drops the trailing keyword hash (`only:` / `except:` /
|
|
166
|
+
# `if:` / `unless:`) so the modifier args don't get
|
|
167
|
+
# treated as filter names.
|
|
168
|
+
def filter_name_args(call_node)
|
|
169
|
+
args = call_node.arguments&.arguments || []
|
|
170
|
+
args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
|
|
171
|
+
args
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def literal_symbol_or_string(node)
|
|
175
|
+
case node
|
|
176
|
+
when Prism::SymbolNode then node.value
|
|
177
|
+
when Prism::StringNode then node.unescaped
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def first_class_node(node)
|
|
182
|
+
return nil unless node.is_a?(Prism::Node)
|
|
183
|
+
return node if node.is_a?(Prism::ClassNode)
|
|
184
|
+
|
|
185
|
+
node.compact_child_nodes.each do |child|
|
|
186
|
+
found = first_class_node(child)
|
|
187
|
+
return found if found
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def qualified_name_for(node)
|
|
193
|
+
case node
|
|
194
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
195
|
+
when Prism::ConstantPathNode
|
|
196
|
+
parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
|
|
197
|
+
return nil if !node.parent.nil? && parent.nil?
|
|
198
|
+
|
|
199
|
+
parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Phase 1 — strong-parameter validation. Recognises
|
|
204
|
+
# the `params.require(:symbol).permit(:key, :key, ...)`
|
|
205
|
+
# chain and validates each `:key` against the AR
|
|
206
|
+
# model's column list (looked up via the model_index
|
|
207
|
+
# fact published by `rigor-activerecord`). Calls whose
|
|
208
|
+
# `:require` argument is a non-literal Symbol are
|
|
209
|
+
# passed through; namespaced models
|
|
210
|
+
# (`params.require(:admin_user)` →
|
|
211
|
+
# `Admin::User`) are deferred to a Phase 1.5 follow-up.
|
|
212
|
+
def diagnose_permits(path:, root:, model_index:)
|
|
213
|
+
diagnostics = []
|
|
214
|
+
walk_permit_calls(root) do |permit_call, model_class|
|
|
215
|
+
entry = model_index[model_class]
|
|
216
|
+
next if entry.nil? # unknown model — skip; the model lookup is best-effort.
|
|
217
|
+
|
|
218
|
+
columns = entry[:columns]
|
|
219
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
|
|
220
|
+
literal_permit_keys(permit_call).each do |key_node, key_name|
|
|
221
|
+
diagnostics << if columns.include?(key_name)
|
|
222
|
+
permit_call_diagnostic(path, permit_call, model_class, key_name)
|
|
223
|
+
else
|
|
224
|
+
unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
diagnostics
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Walks the AST yielding `[permit_call, model_class]`
|
|
232
|
+
# pairs for every `params.require(:symbol).permit(...)`
|
|
233
|
+
# chain. The match keys on:
|
|
234
|
+
#
|
|
235
|
+
# - method name `:permit` (with any positional args).
|
|
236
|
+
# - receiver shape: a `:require` call whose first
|
|
237
|
+
# positional arg is a literal `Prism::SymbolNode`.
|
|
238
|
+
#
|
|
239
|
+
# The literal symbol's `to_s.capitalize` is the
|
|
240
|
+
# candidate model class name. Namespaced models
|
|
241
|
+
# (`:admin_user` → `Admin::User`) are deferred — the
|
|
242
|
+
# mapping for them needs the inflector and a
|
|
243
|
+
# convention call we don't ship in Phase 1.
|
|
244
|
+
def walk_permit_calls(node, &)
|
|
245
|
+
return unless node.is_a?(Prism::Node)
|
|
246
|
+
|
|
247
|
+
yield node, model_class_for_permit(node) if permit_chain?(node)
|
|
248
|
+
node.compact_child_nodes.each { |child| walk_permit_calls(child, &) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def permit_chain?(node)
|
|
252
|
+
return false unless node.is_a?(Prism::CallNode) && node.name == :permit
|
|
253
|
+
|
|
254
|
+
require_call = node.receiver
|
|
255
|
+
return false unless require_call.is_a?(Prism::CallNode) && require_call.name == :require
|
|
256
|
+
|
|
257
|
+
first_arg = require_call.arguments&.arguments&.first
|
|
258
|
+
first_arg.is_a?(Prism::SymbolNode)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def model_class_for_permit(permit_call)
|
|
262
|
+
require_call = permit_call.receiver
|
|
263
|
+
symbol_node = require_call.arguments.arguments.first
|
|
264
|
+
# Phase 1 convention: `:user` → `User`; namespaced
|
|
265
|
+
# mapping deferred. The capitalize call is sufficient
|
|
266
|
+
# for the typical single-word model names; users with
|
|
267
|
+
# multi-word camelcase shape (`:order_item` →
|
|
268
|
+
# `OrderItem`) need the inflector follow-up.
|
|
269
|
+
symbol_node.value.to_s.split("_").map(&:capitalize).join
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def literal_permit_keys(permit_call)
|
|
273
|
+
(permit_call.arguments&.arguments || []).filter_map do |arg|
|
|
274
|
+
next [arg, arg.value] if arg.is_a?(Prism::SymbolNode)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def permit_call_diagnostic(path, permit_call, model_class, key_name)
|
|
279
|
+
loc = permit_call.message_loc || permit_call.location
|
|
280
|
+
Diagnostic.new(
|
|
281
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
282
|
+
message: "Action Pack permit `#{key_name}` resolves to a column on `#{model_class}`.",
|
|
283
|
+
severity: :info, rule: "permit-call"
|
|
284
|
+
)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
|
|
288
|
+
loc = key_node.location
|
|
289
|
+
base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
|
|
290
|
+
suggestion = spell_checker.correct(key_name).first
|
|
291
|
+
message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
|
|
292
|
+
Diagnostic.new(
|
|
293
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
294
|
+
message: message, severity: :error, rule: "unknown-permit-key"
|
|
295
|
+
)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Phase 3 — render-target validation. For each
|
|
299
|
+
# explicit `render` call inside a controller method,
|
|
300
|
+
# derive the candidate view template path(s) from the
|
|
301
|
+
# controller class name + the render argument shape,
|
|
302
|
+
# then check existence under the configured
|
|
303
|
+
# `view_search_paths` (default `["app/views"]`). Recognised
|
|
304
|
+
# call shapes:
|
|
305
|
+
#
|
|
306
|
+
# - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
|
|
307
|
+
# - `render "string/path"` — `<views>/<string_path>.html.erb`
|
|
308
|
+
# - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
|
|
309
|
+
# - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
|
|
310
|
+
#
|
|
311
|
+
# `render layout:`, `render plain:`, `render json:`,
|
|
312
|
+
# `render text:`, `render inline:`, `render :nothing
|
|
313
|
+
# => true`, etc. are pass-through (no template
|
|
314
|
+
# lookup). Implicit-render (a controller method that
|
|
315
|
+
# doesn't call `render`) is also skipped — Phase 3
|
|
316
|
+
# validates explicit renders only, since the implicit
|
|
317
|
+
# path would false-positive on `redirect_to` / `head`
|
|
318
|
+
# / early returns.
|
|
319
|
+
def diagnose_renders(path:, root:, view_search_roots:)
|
|
320
|
+
class_node = first_class_node(root)
|
|
321
|
+
return [] if class_node.nil?
|
|
322
|
+
|
|
323
|
+
class_name = qualified_name_for(class_node.constant_path)
|
|
324
|
+
return [] if class_name.nil?
|
|
325
|
+
|
|
326
|
+
controller_path = controller_path_for(class_name)
|
|
327
|
+
return [] if controller_path.nil?
|
|
328
|
+
|
|
329
|
+
collect_render_diagnostics(path, class_node.body, controller_path, view_search_roots)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def collect_render_diagnostics(path, body, controller_path, view_search_roots)
|
|
333
|
+
diagnostics = []
|
|
334
|
+
walk_render_calls(body) do |call_node|
|
|
335
|
+
target = render_target_for(call_node, controller_path)
|
|
336
|
+
next if target.nil?
|
|
337
|
+
|
|
338
|
+
diag = render_diagnostic(path, call_node, target, view_search_roots)
|
|
339
|
+
diagnostics << diag if diag
|
|
340
|
+
end
|
|
341
|
+
diagnostics
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def walk_render_calls(node, &)
|
|
345
|
+
return unless node.is_a?(Prism::Node)
|
|
346
|
+
|
|
347
|
+
yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
|
|
348
|
+
node.compact_child_nodes.each { |child| walk_render_calls(child, &) }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Returns `[kind, view_relative_path]` where kind is
|
|
352
|
+
# `:template` or `:partial`, and view_relative_path is
|
|
353
|
+
# the path under view_search_roots WITHOUT extension
|
|
354
|
+
# (the extension family is appended at lookup time).
|
|
355
|
+
# Returns nil for shapes Phase 3 doesn't validate
|
|
356
|
+
# (`layout:` / `plain:` / `json:` / `text:` / `inline:`
|
|
357
|
+
# / `:nothing` / no parseable target).
|
|
358
|
+
def render_target_for(call_node, controller_path)
|
|
359
|
+
args = call_node.arguments&.arguments || []
|
|
360
|
+
return nil if args.empty?
|
|
361
|
+
|
|
362
|
+
first = args.first
|
|
363
|
+
# `render partial: "..."` — the keyword form.
|
|
364
|
+
return partial_target_from_kwargs(first, controller_path) if first.is_a?(Prism::KeywordHashNode)
|
|
365
|
+
|
|
366
|
+
# `render :symbol` / `render "path"`. A trailing
|
|
367
|
+
# KeywordHashNode is allowed (e.g. `render :show,
|
|
368
|
+
# status: :ok`); the leading positional carries the
|
|
369
|
+
# template name.
|
|
370
|
+
template_target_from_positional(first, controller_path)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def template_target_from_positional(node, controller_path)
|
|
374
|
+
case node
|
|
375
|
+
when Prism::SymbolNode then [:template, "#{controller_path}/#{node.value}"]
|
|
376
|
+
when Prism::StringNode
|
|
377
|
+
stripped = node.unescaped
|
|
378
|
+
stripped.include?("/") ? [:template, stripped] : [:template, "#{controller_path}/#{stripped}"]
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def partial_target_from_kwargs(hash_node, controller_path)
|
|
383
|
+
partial_value = hash_node.elements.find do |elem|
|
|
384
|
+
elem.is_a?(Prism::AssocNode) &&
|
|
385
|
+
elem.key.is_a?(Prism::SymbolNode) &&
|
|
386
|
+
elem.key.value == "partial"
|
|
387
|
+
end&.value
|
|
388
|
+
return nil unless partial_value.is_a?(Prism::StringNode)
|
|
389
|
+
|
|
390
|
+
stripped = partial_value.unescaped
|
|
391
|
+
if stripped.include?("/")
|
|
392
|
+
dir, base = File.split(stripped)
|
|
393
|
+
[:partial, "#{dir}/_#{base}"]
|
|
394
|
+
else
|
|
395
|
+
[:partial, "#{controller_path}/_#{stripped}"]
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# `UsersController` → "users".
|
|
400
|
+
# `Admin::WidgetsController` → "admin/widgets".
|
|
401
|
+
# Returns nil for class names that don't end with the
|
|
402
|
+
# `Controller` suffix.
|
|
403
|
+
def controller_path_for(class_name)
|
|
404
|
+
return nil unless class_name.end_with?("Controller")
|
|
405
|
+
|
|
406
|
+
stripped = class_name.delete_suffix("Controller")
|
|
407
|
+
stripped.split("::").map { |segment| underscore(segment) }.join("/")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Tiny inflector — sufficient for the typical
|
|
411
|
+
# `WordWord` → `word_word` mapping. Doesn't try to
|
|
412
|
+
# handle acronyms (`HTTPController` would inflect to
|
|
413
|
+
# `h_t_t_p`); users with that need can ship a
|
|
414
|
+
# configured override in a follow-up slice.
|
|
415
|
+
def underscore(camel)
|
|
416
|
+
camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
417
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
418
|
+
.downcase
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def render_diagnostic(path, call_node, target, view_search_roots)
|
|
422
|
+
kind, relative = target
|
|
423
|
+
existing = locate_template(relative, view_search_roots)
|
|
424
|
+
if existing
|
|
425
|
+
render_target_diagnostic(path, call_node, kind, relative, existing)
|
|
426
|
+
else
|
|
427
|
+
missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def locate_template(relative, view_search_roots)
|
|
432
|
+
view_search_roots.each do |root|
|
|
433
|
+
RENDER_TEMPLATE_EXTENSIONS.each do |ext|
|
|
434
|
+
candidate = File.join(root, "#{relative}#{ext}")
|
|
435
|
+
return candidate if File.file?(candidate)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
nil
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def render_target_diagnostic(path, call_node, kind, relative, located)
|
|
442
|
+
loc = call_node.message_loc || call_node.location
|
|
443
|
+
Diagnostic.new(
|
|
444
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
445
|
+
message: "Action Pack render #{kind} `#{relative}` resolved to `#{located}`.",
|
|
446
|
+
severity: :info, rule: "render-target"
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def missing_template_diagnostic(path, call_node, kind, relative, view_search_roots)
|
|
451
|
+
loc = call_node.message_loc || call_node.location
|
|
452
|
+
tried = RENDER_TEMPLATE_EXTENSIONS.map { |ext| "#{relative}#{ext}" }.join(", ")
|
|
453
|
+
roots = view_search_roots.join(", ")
|
|
454
|
+
Diagnostic.new(
|
|
455
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
456
|
+
message: "Action Pack render #{kind} `#{relative}` not found under #{roots} " \
|
|
457
|
+
"(tried #{tried}).",
|
|
458
|
+
severity: :error, rule: "missing-template"
|
|
459
|
+
)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def filter_call_diagnostic(path, call_node, filter_name)
|
|
463
|
+
loc = call_node.message_loc || call_node.location
|
|
464
|
+
Diagnostic.new(
|
|
465
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
466
|
+
message: "Action Pack filter `#{call_node.name} :#{filter_name}` resolves to a defined method.",
|
|
467
|
+
severity: :info, rule: "filter-call"
|
|
468
|
+
)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def unknown_filter_diagnostic(path, arg_node, call_node, filter_name, spell_checker)
|
|
472
|
+
loc = arg_node.location
|
|
473
|
+
base = "Action Pack filter `#{call_node.name} :#{filter_name}` references no method " \
|
|
474
|
+
"defined on this controller (or its parent)."
|
|
475
|
+
suggestion = spell_checker.correct(filter_name.to_s).first
|
|
476
|
+
message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
|
|
477
|
+
Diagnostic.new(
|
|
478
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
479
|
+
message: message, severity: :error, rule: "unknown-filter-method"
|
|
480
|
+
)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Walk the AST yielding only call nodes whose method
|
|
484
|
+
# name ends in `_path` / `_url` and whose receiver is
|
|
485
|
+
# implicit-self (no explicit receiver). Constants are
|
|
486
|
+
# skipped — `Rails.application.routes.url_helpers` is
|
|
487
|
+
# not what Phase 4 validates.
|
|
488
|
+
def walk(node, &)
|
|
489
|
+
return unless node.is_a?(Prism::Node)
|
|
490
|
+
|
|
491
|
+
yield node if node.is_a?(Prism::CallNode) && helper_suffix?(node.name) && node.receiver.nil?
|
|
492
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def helper_suffix?(name)
|
|
496
|
+
name_str = name.to_s
|
|
497
|
+
SUFFIXES.any? { |suffix| name_str.end_with?(suffix) && name_str.length > suffix.length }
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Returns `[entry, suggestion]`:
|
|
501
|
+
#
|
|
502
|
+
# - `[entry, nil]` — known helper.
|
|
503
|
+
# - `[nil, nil]` — unknown helper, no spell-checker match.
|
|
504
|
+
# - `[nil, "user_path"]` — unknown helper, did-you-mean
|
|
505
|
+
# suggestion to surface in the diagnostic.
|
|
506
|
+
def lookup(call_node, helper_table, spell_checker)
|
|
507
|
+
name = call_node.name.to_s
|
|
508
|
+
entry = helper_table[name]
|
|
509
|
+
return [entry, nil] if entry
|
|
510
|
+
|
|
511
|
+
[nil, spell_checker.correct(name).first]
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Builds the diagnostic. Only the **info-level** route
|
|
515
|
+
# resolution (`helper-call`) is the value this plugin
|
|
516
|
+
# adds — it surfaces the resolved HTTP method + path +
|
|
517
|
+
# action alongside the call site, which `rigor-rails-routes`
|
|
518
|
+
# does not. Unknown-helper and wrong-arity diagnostics are
|
|
519
|
+
# produced canonically by `rigor-rails-routes`'s own analyzer
|
|
520
|
+
# (same `:helper_table` source of truth), so emitting them
|
|
521
|
+
# here would double every call-site error. Real-world
|
|
522
|
+
# impact pre-fix: ~301 duplicates on Mastodon and ~119 on
|
|
523
|
+
# Redmine, exactly stacked with the rails-routes diagnostics.
|
|
524
|
+
# Return `nil` for unknown / arity-mismatch shapes so the
|
|
525
|
+
# caller filters them out.
|
|
526
|
+
def diagnostic_for(path, call_node, entry, _suggestion)
|
|
527
|
+
return nil if entry.nil?
|
|
528
|
+
|
|
529
|
+
actual_arity = positional_arg_count(call_node)
|
|
530
|
+
# The `:helper_table` fact entry carries both
|
|
531
|
+
# `:arity` (the first registered entry's arity) and
|
|
532
|
+
# `:acceptable_arities` (the full Array — populated
|
|
533
|
+
# for uncountable-noun resources like `resources :news`
|
|
534
|
+
# which share a helper name across index/show). Honour
|
|
535
|
+
# the full set; fall back to the single arity for
|
|
536
|
+
# consumers compiled against an older fact shape.
|
|
537
|
+
acceptable = entry[:acceptable_arities] || [entry[:arity]]
|
|
538
|
+
return nil unless acceptable.include?(actual_arity)
|
|
539
|
+
|
|
540
|
+
helper_call_diagnostic(path, call_node, entry)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def positional_arg_count(call_node)
|
|
544
|
+
args = call_node.arguments&.arguments || []
|
|
545
|
+
# Drop a trailing `KeywordHashNode` so call sites that
|
|
546
|
+
# pass `users_path(format: :json)` don't get counted as
|
|
547
|
+
# arity 1. Same convention rigor-rails-routes' helper-
|
|
548
|
+
# table arity uses (positional only).
|
|
549
|
+
args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
|
|
550
|
+
args.size
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def location(call_node)
|
|
554
|
+
call_node.message_loc || call_node.location
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def helper_call_diagnostic(path, call_node, entry)
|
|
558
|
+
loc = location(call_node)
|
|
559
|
+
method = entry[:http_method] ? entry[:http_method].to_s.upcase : "(any)"
|
|
560
|
+
Diagnostic.new(
|
|
561
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
562
|
+
message: "Action Pack helper `#{call_node.name}` → #{method} #{entry[:path]} (action: #{entry[:action]}).",
|
|
563
|
+
severity: :info, rule: "helper-call"
|
|
564
|
+
)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def unknown_helper_diagnostic(path, call_node, suggestion)
|
|
568
|
+
loc = location(call_node)
|
|
569
|
+
base = "Unknown route helper `#{call_node.name}` — not registered in `config/routes.rb`."
|
|
570
|
+
message = suggestion ? "#{base} Did you mean `#{suggestion}`?" : base
|
|
571
|
+
Diagnostic.new(
|
|
572
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
573
|
+
message: message, severity: :error, rule: "unknown-helper"
|
|
574
|
+
)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def wrong_arity_diagnostic(path, call_node, entry, actual_arity)
|
|
578
|
+
loc = location(call_node)
|
|
579
|
+
Diagnostic.new(
|
|
580
|
+
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
581
|
+
message: "Route helper `#{call_node.name}` expects #{entry[:arity]} positional " \
|
|
582
|
+
"argument(s) but the call passes #{actual_arity}.",
|
|
583
|
+
severity: :error, rule: "wrong-helper-arity"
|
|
584
|
+
)
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
end
|