rigortype 0.1.15 → 0.1.17
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/README.md +4 -2
- data/exe/rigor +19 -0
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
- data/lib/rigor/analysis/check_rules.rb +174 -71
- data/lib/rigor/analysis/dependency_recorder.rb +122 -0
- data/lib/rigor/analysis/diagnostic.rb +58 -0
- data/lib/rigor/analysis/incremental.rb +162 -0
- data/lib/rigor/analysis/incremental_session.rb +337 -0
- data/lib/rigor/analysis/rule_catalog.rb +48 -0
- data/lib/rigor/analysis/runner.rb +485 -29
- data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
- data/lib/rigor/analysis/worker_session.rb +3 -2
- data/lib/rigor/builtins/static_return_refinements.rb +7 -1
- data/lib/rigor/cache/descriptor.rb +56 -51
- data/lib/rigor/cache/incremental_snapshot.rb +147 -0
- data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
- data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
- data/lib/rigor/cache/rbs_constant_table.rb +2 -8
- data/lib/rigor/cache/rbs_environment.rb +2 -8
- data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
- data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
- data/lib/rigor/cache/store.rb +99 -1
- data/lib/rigor/cli/annotate_command.rb +2 -7
- data/lib/rigor/cli/baseline_command.rb +2 -7
- data/lib/rigor/cli/command.rb +47 -0
- data/lib/rigor/cli/coverage_command.rb +3 -23
- data/lib/rigor/cli/coverage_renderer.rb +3 -8
- data/lib/rigor/cli/diff_command.rb +3 -7
- data/lib/rigor/cli/explain_command.rb +2 -7
- data/lib/rigor/cli/lsp_command.rb +3 -7
- data/lib/rigor/cli/mcp_command.rb +3 -7
- data/lib/rigor/cli/options.rb +57 -0
- data/lib/rigor/cli/plugin_command.rb +3 -7
- data/lib/rigor/cli/plugins_command.rb +52 -10
- data/lib/rigor/cli/plugins_renderer.rb +86 -1
- data/lib/rigor/cli/renderable.rb +26 -0
- data/lib/rigor/cli/sig_gen_command.rb +2 -7
- data/lib/rigor/cli/skill_command.rb +3 -7
- data/lib/rigor/cli/triage_command.rb +2 -7
- data/lib/rigor/cli/type_of_command.rb +5 -38
- data/lib/rigor/cli/type_of_renderer.rb +4 -9
- data/lib/rigor/cli/type_scan_command.rb +3 -23
- data/lib/rigor/cli/type_scan_renderer.rb +4 -9
- data/lib/rigor/cli.rb +260 -48
- data/lib/rigor/configuration/dependencies.rb +18 -1
- data/lib/rigor/configuration/severity_profile.rb +22 -3
- data/lib/rigor/configuration.rb +13 -3
- data/lib/rigor/environment/rbs_loader.rb +335 -4
- data/lib/rigor/environment.rb +8 -2
- data/lib/rigor/inference/block_parameter_binder.rb +1 -2
- data/lib/rigor/inference/budget_trace.rb +137 -0
- data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
- data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
- data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
- data/lib/rigor/inference/expression_typer.rb +149 -22
- data/lib/rigor/inference/hkt_reducer.rb +2 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
- data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
- data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
- data/lib/rigor/inference/method_dispatcher.rb +147 -60
- data/lib/rigor/inference/narrowing.rb +202 -5
- data/lib/rigor/inference/precision_scanner.rb +60 -1
- data/lib/rigor/inference/scope_indexer.rb +257 -11
- data/lib/rigor/inference/statement_evaluator.rb +110 -26
- data/lib/rigor/inference/synthetic_method_index.rb +23 -4
- data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
- data/lib/rigor/language_server/buffer_resolution.rb +33 -0
- data/lib/rigor/language_server/completion_provider.rb +4 -4
- data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
- data/lib/rigor/language_server/folding_range_provider.rb +4 -4
- data/lib/rigor/language_server/hover_provider.rb +4 -4
- data/lib/rigor/language_server/selection_range_provider.rb +4 -4
- data/lib/rigor/language_server/signature_help_provider.rb +4 -4
- data/lib/rigor/plugin/additional_initializer.rb +108 -0
- data/lib/rigor/plugin/base.rb +337 -2
- data/lib/rigor/plugin/box.rb +64 -0
- data/lib/rigor/plugin/inflector.rb +121 -0
- data/lib/rigor/plugin/isolation.rb +191 -0
- data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
- data/lib/rigor/plugin/macro.rb +1 -0
- data/lib/rigor/plugin/manifest.rb +120 -23
- data/lib/rigor/plugin/node_context.rb +62 -0
- data/lib/rigor/plugin/registry.rb +49 -1
- data/lib/rigor/plugin.rb +3 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
- data/lib/rigor/rbs_extended.rb +39 -0
- data/lib/rigor/scope.rb +123 -9
- data/lib/rigor/sig_gen/generator.rb +2 -3
- data/lib/rigor/sig_gen/observation_collector.rb +2 -2
- data/lib/rigor/source/literals.rb +118 -0
- data/lib/rigor/source/node_walker.rb +26 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/acceptance_router.rb +19 -0
- data/lib/rigor/type/accepts_result.rb +3 -10
- data/lib/rigor/type/app.rb +3 -7
- data/lib/rigor/type/bot.rb +2 -3
- data/lib/rigor/type/bound_method.rb +5 -12
- data/lib/rigor/type/combinator.rb +23 -1
- data/lib/rigor/type/constant.rb +2 -3
- data/lib/rigor/type/data_class.rb +80 -0
- data/lib/rigor/type/data_instance.rb +100 -0
- data/lib/rigor/type/difference.rb +5 -10
- data/lib/rigor/type/dynamic.rb +5 -10
- data/lib/rigor/type/hash_shape.rb +5 -15
- data/lib/rigor/type/integer_range.rb +5 -10
- data/lib/rigor/type/intersection.rb +5 -10
- data/lib/rigor/type/nominal.rb +5 -10
- data/lib/rigor/type/refined.rb +5 -10
- data/lib/rigor/type/singleton.rb +5 -10
- data/lib/rigor/type/top.rb +2 -3
- data/lib/rigor/type/tuple.rb +5 -10
- data/lib/rigor/type/union.rb +69 -10
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/value_semantics.rb +77 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +2 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
- data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
- data/sig/rigor/cache.rbs +19 -0
- data/sig/rigor/inference.rbs +22 -0
- data/sig/rigor/plugin/access_denied_error.rbs +3 -1
- data/sig/rigor/plugin/base.rbs +58 -3
- data/sig/rigor/plugin/io_boundary.rbs +3 -0
- data/sig/rigor/plugin/manifest.rbs +31 -1
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +5 -0
- data/sig/rigor/source.rbs +12 -0
- data/sig/rigor/type.rbs +58 -1
- data/sig/rigor.rbs +11 -1
- data/skills/rigor-plugin-author/SKILL.md +13 -9
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
- metadata +73 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require "did_you_mean"
|
|
4
4
|
require "prism"
|
|
5
|
+
require "rigor/source/literals"
|
|
5
6
|
|
|
6
7
|
module Rigor
|
|
7
8
|
module Plugin
|
|
8
9
|
class Actionpack < Rigor::Plugin::Base
|
|
9
|
-
# Per-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# Per-node validators for the Action Pack DSL inside controller
|
|
11
|
+
# files. ADR-37: the engine owns the single AST walk (the plugin
|
|
12
|
+
# declares `node_rule(Prism::CallNode)`), so each method here
|
|
13
|
+
# validates ONE call node and returns location-bearing
|
|
14
|
+
# {Violation}s — the `node_rule` block positions them via
|
|
15
|
+
# `Plugin::Base#diagnostic`. The enclosing-controller context the
|
|
16
|
+
# filter / render phases need is read from the node-rule
|
|
17
|
+
# `NodeContext` ancestors rather than re-derived by a forward walk.
|
|
13
18
|
#
|
|
14
19
|
# The recogniser keys on call-method-name suffix:
|
|
15
20
|
#
|
|
@@ -69,124 +74,204 @@ module Rigor
|
|
|
69
74
|
# `params.require(:symbol)`.
|
|
70
75
|
STRONG_PARAMS_RECEIVER_NAMES = %i[require permit_params strong_params].freeze
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
# One Action Pack observation. Carries no path — the caller
|
|
78
|
+
# (the `node_rule` block) positions it via
|
|
79
|
+
# `Plugin::Base#diagnostic`. `location` is the Prism location the
|
|
80
|
+
# diagnostic should point at (a call's `message_loc` for the call
|
|
81
|
+
# itself, or a sub-argument's location for `unknown-*-key`-style
|
|
82
|
+
# diagnostics that point at the offending Symbol).
|
|
83
|
+
Violation = Data.define(:location, :message, :severity, :rule)
|
|
73
84
|
|
|
74
85
|
module_function
|
|
75
86
|
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
# Phase 4 — route-helper consumption. The helper-existence /
|
|
88
|
+
# arity check for ONE call node. Only the **info-level** route
|
|
89
|
+
# resolution (`helper-call`) is the value this plugin adds — it
|
|
90
|
+
# surfaces the resolved HTTP method + path + action alongside the
|
|
91
|
+
# call site, which `rigor-rails-routes` does not. Unknown-helper
|
|
92
|
+
# and wrong-arity diagnostics are produced canonically by
|
|
93
|
+
# `rigor-rails-routes`'s own analyzer (same `:helper_table` source
|
|
94
|
+
# of truth), so emitting them here would double every call-site
|
|
95
|
+
# error. Real-world impact pre-fix: ~301 duplicates on Mastodon and
|
|
96
|
+
# ~119 on Redmine, exactly stacked with the rails-routes
|
|
97
|
+
# diagnostics. Returns `[]` for unknown / arity-mismatch shapes.
|
|
98
|
+
#
|
|
99
|
+
# @param call_node [Prism::Node]
|
|
100
|
+
# @param helper_table [Hash{String => Hash}] each entry carries
|
|
101
|
+
# `name`, `arity`, `acceptable_arities`, `path`, `http_method`,
|
|
102
|
+
# `action`.
|
|
103
|
+
# @return [Array<Violation>]
|
|
104
|
+
def helper_violations_for(call_node:, helper_table:)
|
|
105
|
+
return [] unless implicit_helper_call?(call_node)
|
|
94
106
|
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
entry = helper_table[call_node.name.to_s]
|
|
108
|
+
return [] if entry.nil?
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
actual_arity = positional_arg_count(call_node)
|
|
111
|
+
# The `:helper_table` fact entry carries both `:arity` (the
|
|
112
|
+
# first registered entry's arity) and `:acceptable_arities`
|
|
113
|
+
# (the full Array — populated for uncountable-noun resources
|
|
114
|
+
# like `resources :news` which share a helper name across
|
|
115
|
+
# index/show). Honour the full set; fall back to the single
|
|
116
|
+
# arity for consumers compiled against an older fact shape.
|
|
117
|
+
acceptable = entry[:acceptable_arities] || [entry[:arity]]
|
|
118
|
+
return [] unless acceptable.include?(actual_arity)
|
|
119
|
+
|
|
120
|
+
[helper_call_violation(call_node, entry)]
|
|
121
|
+
end
|
|
108
122
|
|
|
109
|
-
|
|
123
|
+
# Phase 2 — filter-chain validation for ONE filter-DSL call
|
|
124
|
+
# (`before_action :name`, …). The enclosing controller is read
|
|
125
|
+
# from the node-rule `NodeContext` ancestors, looked up in the
|
|
126
|
+
# controller index to get the effective method set (including one
|
|
127
|
+
# level of inheritance), and each filter name reference is
|
|
128
|
+
# validated against it. Calls outside a known controller
|
|
129
|
+
# contribute nothing.
|
|
130
|
+
#
|
|
131
|
+
# @param call_node [Prism::Node]
|
|
132
|
+
# @param ancestors [Array<Prism::Node>] the lexical ancestor chain
|
|
133
|
+
# @param controller_index [ControllerIndex]
|
|
134
|
+
# @return [Array<Violation>]
|
|
135
|
+
def filter_violations_for(call_node:, ancestors:, controller_index:)
|
|
136
|
+
return [] unless filter_call?(call_node)
|
|
137
|
+
|
|
138
|
+
class_name = enclosing_controller_name(ancestors)
|
|
110
139
|
return [] if class_name.nil?
|
|
111
140
|
return [] unless controller_index.known?(class_name)
|
|
112
141
|
|
|
113
142
|
methods = controller_index.effective_methods_for(class_name)
|
|
114
143
|
spell_checker = DidYouMean::SpellChecker.new(dictionary: methods.map(&:to_s))
|
|
115
|
-
# When the controller (or its parent) `include`s a
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
# gem-provided callback names.
|
|
144
|
+
# When the controller (or its parent) `include`s a module the
|
|
145
|
+
# discoverer couldn't resolve — typically a gem-shipped concern
|
|
146
|
+
# such as `Devise::Controllers::Helpers` or
|
|
147
|
+
# `Pundit::Authorization` — any `before_action :name` MIGHT be
|
|
148
|
+
# defined in that unresolved module. Suppress
|
|
149
|
+
# `unknown-filter-method` in that case rather than FPing on
|
|
150
|
+
# legitimate gem-provided callback names.
|
|
123
151
|
ambiguous_filters = controller_index.unresolved_include?(class_name)
|
|
124
152
|
|
|
125
|
-
|
|
126
|
-
|
|
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?
|
|
153
|
+
filter_name_args(call_node).filter_map do |arg_node|
|
|
154
|
+
filter_name = literal_symbol_or_string(arg_node)
|
|
155
|
+
next if filter_name.nil?
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
diagnostics << diag if diag
|
|
140
|
-
end
|
|
157
|
+
filter_lookup_violation(
|
|
158
|
+
call_node, arg_node, filter_name, methods, spell_checker,
|
|
159
|
+
ambiguous_filters: ambiguous_filters
|
|
160
|
+
)
|
|
141
161
|
end
|
|
142
|
-
diagnostics
|
|
143
162
|
end
|
|
144
163
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
# Phase 1 — strong-parameter validation for ONE `permit` call.
|
|
165
|
+
# Recognises the `params.require(:symbol).permit(:key, :key, ...)`
|
|
166
|
+
# chain and validates each `:key` against the AR model's column
|
|
167
|
+
# list (looked up via the model_index fact published by
|
|
168
|
+
# `rigor-activerecord`). Calls whose `:require` argument is a
|
|
169
|
+
# non-literal Symbol are passed through; namespaced models
|
|
170
|
+
# (`params.require(:admin_user)` → `Admin::User`) are deferred to a
|
|
171
|
+
# Phase 1.5 follow-up.
|
|
172
|
+
#
|
|
173
|
+
# @param call_node [Prism::Node]
|
|
174
|
+
# @param model_index [Hash{String => Hash}]
|
|
175
|
+
# @return [Array<Violation>]
|
|
176
|
+
def permit_violations_for(call_node:, model_index:)
|
|
177
|
+
return [] unless permit_chain?(call_node)
|
|
178
|
+
|
|
179
|
+
model_class = model_class_for_permit(call_node)
|
|
180
|
+
entry = model_index[model_class]
|
|
181
|
+
return [] if entry.nil? # unknown model — skip; the model lookup is best-effort.
|
|
182
|
+
|
|
183
|
+
columns = entry[:columns]
|
|
184
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
|
|
185
|
+
literal_permit_keys(call_node).map do |key_node, key_name|
|
|
186
|
+
if columns.include?(key_name)
|
|
187
|
+
permit_call_violation(call_node, model_class, key_name)
|
|
188
|
+
else
|
|
189
|
+
unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
|
|
190
|
+
end
|
|
155
191
|
end
|
|
156
192
|
end
|
|
157
193
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
194
|
+
# Phase 3 — render-target validation for ONE `render` call. Derive
|
|
195
|
+
# the candidate view template path(s) from the controller class
|
|
196
|
+
# name + the render argument shape, then check existence under the
|
|
197
|
+
# configured `view_search_paths` (default `["app/views"]`).
|
|
198
|
+
# Recognised call shapes:
|
|
199
|
+
#
|
|
200
|
+
# - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
|
|
201
|
+
# - `render "string/path"` — `<views>/<string_path>.html.erb`
|
|
202
|
+
# - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
|
|
203
|
+
# - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
|
|
204
|
+
#
|
|
205
|
+
# `render layout:`, `render plain:`, `render json:`, `render
|
|
206
|
+
# text:`, `render inline:`, `render :nothing => true`, etc. are
|
|
207
|
+
# pass-through (no template lookup). Implicit-render (a controller
|
|
208
|
+
# method that doesn't call `render`) is also skipped — Phase 3
|
|
209
|
+
# validates explicit renders only, since the implicit path would
|
|
210
|
+
# false-positive on `redirect_to` / `head` / early returns.
|
|
211
|
+
#
|
|
212
|
+
# @param call_node [Prism::Node]
|
|
213
|
+
# @param ancestors [Array<Prism::Node>] the lexical ancestor chain
|
|
214
|
+
# @param path [String] file being analysed
|
|
215
|
+
# @param view_search_roots [Array<String>]
|
|
216
|
+
# @param controller_index [ControllerIndex, nil]
|
|
217
|
+
# @return [Array<Violation>]
|
|
218
|
+
def render_violations_for(call_node:, ancestors:, path:, view_search_roots:, controller_index: nil)
|
|
219
|
+
return [] unless render_call?(call_node)
|
|
220
|
+
|
|
221
|
+
class_name = enclosing_controller_name(ancestors)
|
|
222
|
+
return [] if class_name.nil?
|
|
164
223
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
224
|
+
# Prefer the file-path-derived controller path when the source
|
|
225
|
+
# lives under `app/controllers/` — Rails autoload is the runtime
|
|
226
|
+
# authority on `Admin::Users::RolesController` vs
|
|
227
|
+
# `Users::RolesController` (a declaration like `module Admin;
|
|
228
|
+
# class Users::RolesController` resolves to whichever `Users`
|
|
229
|
+
# constant Ruby finds first, and the file path is the
|
|
230
|
+
# disambiguator Rails picks). Fall back to the AST-derived class
|
|
231
|
+
# name for files outside `app/controllers/` (libraries, test
|
|
232
|
+
# fixtures).
|
|
233
|
+
controller_path = controller_path_from_file(path) || controller_path_for(class_name)
|
|
234
|
+
return [] if controller_path.nil?
|
|
173
235
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
236
|
+
# Render checks silence when the controller (or its ancestor
|
|
237
|
+
# chain) inherits from a gem-shipped parent
|
|
238
|
+
# (Devise::ConfirmationsController,
|
|
239
|
+
# Doorkeeper::ApplicationsController, …). The gem ships its own
|
|
240
|
+
# views; the local subclass calling `render :show` resolves
|
|
241
|
+
# through the gem's view path, which our static analyser doesn't
|
|
242
|
+
# know about.
|
|
243
|
+
if controller_index&.known?(class_name) &&
|
|
244
|
+
controller_index.unresolved_include?(class_name)
|
|
245
|
+
return []
|
|
178
246
|
end
|
|
179
|
-
end
|
|
180
247
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
248
|
+
# Abstract base controllers: a `*BaseController` (`Admin::
|
|
249
|
+
# BaseController`, `Settings::Preferences::BaseController`, …)
|
|
250
|
+
# typically has no view of its own; its `render :show` bodies
|
|
251
|
+
# are resolved by Rails against the calling subclass's
|
|
252
|
+
# controller path at request time, NOT the base's. Same for
|
|
253
|
+
# parent controllers whose view directory exists but contains
|
|
254
|
+
# only subdirectories (Mastodon's `Admin::SettingsController`
|
|
255
|
+
# whose `app/views/admin/settings/` holds about/, appearance/,
|
|
256
|
+
# … but no top-level templates). Skip render checks for these —
|
|
257
|
+
# the diagnostic would be a false positive against intentional
|
|
258
|
+
# Rails abstract-base layouts.
|
|
259
|
+
return [] if abstract_base_controller?(class_name, controller_path, view_search_roots)
|
|
260
|
+
|
|
261
|
+
target = render_target_for(call_node, controller_path)
|
|
262
|
+
return [] if target.nil?
|
|
263
|
+
|
|
264
|
+
violation = render_violation(call_node, target, view_search_roots)
|
|
265
|
+
violation ? [violation] : []
|
|
184
266
|
end
|
|
185
267
|
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
# `
|
|
268
|
+
# Resolves the qualified name of the controller class the node
|
|
269
|
+
# sits in from its lexical ancestors (the node-rule
|
|
270
|
+
# `NodeContext#ancestors`, outermost first). The OUTERMOST
|
|
271
|
+
# `ClassNode` is the controller — matching the old forward walk,
|
|
272
|
+
# which returned the first `ClassNode` reachable from the file
|
|
273
|
+
# root and validated nested-class call sites against it. Enclosing
|
|
274
|
+
# `ModuleNode`s above it contribute the namespace, so the
|
|
190
275
|
# nested-module declaration shape
|
|
191
276
|
#
|
|
192
277
|
# module Admin
|
|
@@ -194,31 +279,22 @@ module Rigor
|
|
|
194
279
|
# end
|
|
195
280
|
# end
|
|
196
281
|
#
|
|
197
|
-
# is recovered as the
|
|
198
|
-
# `
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
namespace + namespace_segments_for(node)
|
|
214
|
-
else
|
|
215
|
-
namespace
|
|
216
|
-
end
|
|
217
|
-
node.compact_child_nodes.each do |child|
|
|
218
|
-
found, found_namespace = first_class_node_with_namespace(child, inner_namespace)
|
|
219
|
-
return [found, found_namespace] if found
|
|
220
|
-
end
|
|
221
|
-
[nil, namespace]
|
|
282
|
+
# is recovered as `Admin::DomainBlocksController` — the same name
|
|
283
|
+
# the `ControllerDiscoverer` registers under. Without this, a bare
|
|
284
|
+
# inner-class name would be used for index lookups +
|
|
285
|
+
# `controller_path_for`, silently skipping filter validation and
|
|
286
|
+
# pointing render template paths at the wrong directory
|
|
287
|
+
# (Mastodon's `admin/domain_blocks` rendered as bare
|
|
288
|
+
# `domain_blocks`). Returns nil when no enclosing class exists.
|
|
289
|
+
def enclosing_controller_name(ancestors)
|
|
290
|
+
class_index = ancestors.index { |n| n.is_a?(Prism::ClassNode) }
|
|
291
|
+
return nil if class_index.nil?
|
|
292
|
+
|
|
293
|
+
class_node = ancestors[class_index]
|
|
294
|
+
enclosing = ancestors[0...class_index]
|
|
295
|
+
.grep(Prism::ModuleNode)
|
|
296
|
+
.flat_map { |n| namespace_segments_for(n) }
|
|
297
|
+
qualified_name_with_enclosing(class_node.constant_path, enclosing)
|
|
222
298
|
end
|
|
223
299
|
|
|
224
300
|
def namespace_segments_for(declaration_node)
|
|
@@ -226,11 +302,10 @@ module Rigor
|
|
|
226
302
|
path ? path.split("::") : []
|
|
227
303
|
end
|
|
228
304
|
|
|
229
|
-
# Resolves a class-name AST node against an enclosing
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
# inside `module Admin`) is qualified against it.
|
|
305
|
+
# Resolves a class-name AST node against an enclosing namespace
|
|
306
|
+
# chain. A `ConstantPathNode` (e.g. `class Admin::Foo`) is already
|
|
307
|
+
# absolute and ignores the chain; a `ConstantReadNode` (bare
|
|
308
|
+
# `class Foo` inside `module Admin`) is qualified against it.
|
|
234
309
|
def qualified_name_with_enclosing(node, enclosing)
|
|
235
310
|
return nil unless node.is_a?(Prism::Node)
|
|
236
311
|
|
|
@@ -253,52 +328,42 @@ module Rigor
|
|
|
253
328
|
end
|
|
254
329
|
end
|
|
255
330
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
# chain and validates each `:key` against the AR
|
|
259
|
-
# model's column list (looked up via the model_index
|
|
260
|
-
# fact published by `rigor-activerecord`). Calls whose
|
|
261
|
-
# `:require` argument is a non-literal Symbol are
|
|
262
|
-
# passed through; namespaced models
|
|
263
|
-
# (`params.require(:admin_user)` →
|
|
264
|
-
# `Admin::User`) are deferred to a Phase 1.5 follow-up.
|
|
265
|
-
def diagnose_permits(path:, root:, model_index:)
|
|
266
|
-
diagnostics = []
|
|
267
|
-
walk_permit_calls(root) do |permit_call, model_class|
|
|
268
|
-
entry = model_index[model_class]
|
|
269
|
-
next if entry.nil? # unknown model — skip; the model lookup is best-effort.
|
|
270
|
-
|
|
271
|
-
columns = entry[:columns]
|
|
272
|
-
spell_checker = DidYouMean::SpellChecker.new(dictionary: columns)
|
|
273
|
-
literal_permit_keys(permit_call).each do |key_node, key_name|
|
|
274
|
-
diagnostics << if columns.include?(key_name)
|
|
275
|
-
permit_call_diagnostic(path, permit_call, model_class, key_name)
|
|
276
|
-
else
|
|
277
|
-
unknown_permit_key_diagnostic(path, key_node, model_class, key_name, spell_checker)
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
diagnostics
|
|
331
|
+
def implicit_helper_call?(node)
|
|
332
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && helper_suffix?(node.name)
|
|
282
333
|
end
|
|
283
334
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
335
|
+
def filter_call?(node)
|
|
336
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && FILTER_DSL_METHODS.include?(node.name)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def render_call?(node)
|
|
340
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Drops the trailing keyword hash (`only:` / `except:` / `if:` /
|
|
344
|
+
# `unless:`) so the modifier args don't get treated as filter
|
|
345
|
+
# names.
|
|
346
|
+
def filter_name_args(call_node)
|
|
347
|
+
args = call_node.arguments&.arguments || []
|
|
348
|
+
args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
|
|
349
|
+
args
|
|
350
|
+
end
|
|
299
351
|
|
|
300
|
-
|
|
301
|
-
|
|
352
|
+
def literal_symbol_or_string(node)
|
|
353
|
+
Rigor::Source::Literals.symbol_or_string_name(node)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def filter_lookup_violation(call_node, arg_node, filter_name, methods, spell_checker, ambiguous_filters:)
|
|
357
|
+
if methods.include?(filter_name.to_sym)
|
|
358
|
+
filter_call_violation(call_node, filter_name)
|
|
359
|
+
elsif ambiguous_filters
|
|
360
|
+
# An unresolved include shadows our judgment — emit the
|
|
361
|
+
# recognized-filter info anyway so the call site is still
|
|
362
|
+
# indexed, but skip the error.
|
|
363
|
+
filter_call_violation(call_node, filter_name)
|
|
364
|
+
else
|
|
365
|
+
unknown_filter_violation(arg_node, call_node, filter_name, spell_checker)
|
|
366
|
+
end
|
|
302
367
|
end
|
|
303
368
|
|
|
304
369
|
def permit_chain?(node)
|
|
@@ -314,12 +379,12 @@ module Rigor
|
|
|
314
379
|
def model_class_for_permit(permit_call)
|
|
315
380
|
require_call = permit_call.receiver
|
|
316
381
|
symbol_node = require_call.arguments.arguments.first
|
|
317
|
-
#
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
symbol_node.value.to_s
|
|
382
|
+
# `:user` → `User`, `:order_item` → `OrderItem`. ADR-39: the
|
|
383
|
+
# shared inflector camelizes through the real
|
|
384
|
+
# `ActiveSupport::Inflector` when present (so the multi-word
|
|
385
|
+
# case the former `split("_").map(&:capitalize)` only
|
|
386
|
+
# approximated is authoritative), falling back otherwise.
|
|
387
|
+
Rigor::Plugin::Inflector.camelize(symbol_node.value.to_s)
|
|
323
388
|
end
|
|
324
389
|
|
|
325
390
|
def literal_permit_keys(permit_call)
|
|
@@ -328,119 +393,47 @@ module Rigor
|
|
|
328
393
|
end
|
|
329
394
|
end
|
|
330
395
|
|
|
331
|
-
def
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
396
|
+
def permit_call_violation(permit_call, model_class, key_name)
|
|
397
|
+
Violation.new(
|
|
398
|
+
location: permit_call.message_loc || permit_call.location,
|
|
335
399
|
message: "Action Pack permit `#{key_name}` resolves to a column on `#{model_class}`.",
|
|
336
400
|
severity: :info, rule: "permit-call"
|
|
337
401
|
)
|
|
338
402
|
end
|
|
339
403
|
|
|
340
|
-
def
|
|
341
|
-
loc = key_node.location
|
|
404
|
+
def unknown_permit_key_violation(key_node, model_class, key_name, spell_checker)
|
|
342
405
|
base = "Action Pack permit `#{key_name}` is not a column on `#{model_class}`."
|
|
343
406
|
suggestion = spell_checker.correct(key_name).first
|
|
344
407
|
message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
|
|
345
|
-
|
|
346
|
-
|
|
408
|
+
Violation.new(
|
|
409
|
+
location: key_node.location,
|
|
347
410
|
message: message, severity: :error, rule: "unknown-permit-key"
|
|
348
411
|
)
|
|
349
412
|
end
|
|
350
413
|
|
|
351
|
-
# Phase 3 — render-target validation. For each
|
|
352
|
-
# explicit `render` call inside a controller method,
|
|
353
|
-
# derive the candidate view template path(s) from the
|
|
354
|
-
# controller class name + the render argument shape,
|
|
355
|
-
# then check existence under the configured
|
|
356
|
-
# `view_search_paths` (default `["app/views"]`). Recognised
|
|
357
|
-
# call shapes:
|
|
358
|
-
#
|
|
359
|
-
# - `render :symbol` — `<views>/<controller_path>/<symbol>.html.erb`
|
|
360
|
-
# - `render "string/path"` — `<views>/<string_path>.html.erb`
|
|
361
|
-
# - `render partial: "name"` — `<views>/<controller_path>/_<name>.html.erb`
|
|
362
|
-
# - `render partial: "string/path"` — `<views>/<string_path with _ prefix>.html.erb`
|
|
363
|
-
#
|
|
364
|
-
# `render layout:`, `render plain:`, `render json:`,
|
|
365
|
-
# `render text:`, `render inline:`, `render :nothing
|
|
366
|
-
# => true`, etc. are pass-through (no template
|
|
367
|
-
# lookup). Implicit-render (a controller method that
|
|
368
|
-
# doesn't call `render`) is also skipped — Phase 3
|
|
369
|
-
# validates explicit renders only, since the implicit
|
|
370
|
-
# path would false-positive on `redirect_to` / `head`
|
|
371
|
-
# / early returns.
|
|
372
|
-
def diagnose_renders(path:, root:, view_search_roots:, controller_index: nil)
|
|
373
|
-
class_node, enclosing = first_class_node_with_namespace(root)
|
|
374
|
-
return [] if class_node.nil?
|
|
375
|
-
|
|
376
|
-
class_name = qualified_name_with_enclosing(class_node.constant_path, enclosing)
|
|
377
|
-
return [] if class_name.nil?
|
|
378
|
-
|
|
379
|
-
# Prefer the file-path-derived controller path when the
|
|
380
|
-
# source lives under `app/controllers/` — Rails autoload
|
|
381
|
-
# is the runtime authority on `Admin::Users::RolesController`
|
|
382
|
-
# vs `Users::RolesController` (a declaration like
|
|
383
|
-
# `module Admin; class Users::RolesController` resolves
|
|
384
|
-
# to whichever `Users` constant Ruby finds first, and
|
|
385
|
-
# the file path is the disambiguator Rails picks). Fall
|
|
386
|
-
# back to the AST-derived class name for files outside
|
|
387
|
-
# `app/controllers/` (libraries, test fixtures).
|
|
388
|
-
controller_path = controller_path_from_file(path) || controller_path_for(class_name)
|
|
389
|
-
return [] if controller_path.nil?
|
|
390
|
-
|
|
391
|
-
# Render checks silence when the controller (or its
|
|
392
|
-
# ancestor chain) inherits from a gem-shipped parent
|
|
393
|
-
# (Devise::ConfirmationsController,
|
|
394
|
-
# Doorkeeper::ApplicationsController, …). The gem
|
|
395
|
-
# ships its own views; the local subclass calling
|
|
396
|
-
# `render :show` resolves through the gem's view path,
|
|
397
|
-
# which our static analyser doesn't know about.
|
|
398
|
-
if controller_index&.known?(class_name) &&
|
|
399
|
-
controller_index.unresolved_include?(class_name)
|
|
400
|
-
return []
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
# Abstract base controllers: a `*BaseController` (`Admin::
|
|
404
|
-
# BaseController`, `Settings::Preferences::BaseController`,
|
|
405
|
-
# …) typically has no view of its own; its `render :show`
|
|
406
|
-
# bodies are resolved by Rails against the calling
|
|
407
|
-
# subclass's controller path at request time, NOT the
|
|
408
|
-
# base's. Same for parent controllers whose view
|
|
409
|
-
# directory exists but contains only subdirectories
|
|
410
|
-
# (Mastodon's `Admin::SettingsController` whose
|
|
411
|
-
# `app/views/admin/settings/` holds about/, appearance/,
|
|
412
|
-
# … but no top-level templates). Skip render checks for
|
|
413
|
-
# these — the diagnostic would be a false positive
|
|
414
|
-
# against intentional Rails abstract-base layouts.
|
|
415
|
-
return [] if abstract_base_controller?(class_name, controller_path, view_search_roots)
|
|
416
|
-
|
|
417
|
-
collect_render_diagnostics(path, class_node.body, controller_path, view_search_roots)
|
|
418
|
-
end
|
|
419
|
-
|
|
420
414
|
# Convert `<root>/app/controllers/admin/users/roles_controller.rb`
|
|
421
|
-
# to `admin/users/roles`. Returns nil if the path is not
|
|
422
|
-
#
|
|
415
|
+
# to `admin/users/roles`. Returns nil if the path is not under an
|
|
416
|
+
# `app/controllers/` segment.
|
|
423
417
|
def controller_path_from_file(path)
|
|
424
418
|
match = path.match(%r{(?:\A|/)app/controllers/(.+)_controller\.rb\z})
|
|
425
419
|
match&.[](1)
|
|
426
420
|
end
|
|
427
421
|
|
|
428
|
-
# An abstract base controller — its `render` bodies don't
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
#
|
|
422
|
+
# An abstract base controller — its `render` bodies don't validate
|
|
423
|
+
# against its own controller_path because Rails resolves at
|
|
424
|
+
# request time against the actual subclass. Two conservative
|
|
425
|
+
# heuristics:
|
|
432
426
|
# (1) The class name ends with `BaseController`. Strong
|
|
433
427
|
# Rails-convention signal (Settings::Preferences::
|
|
434
428
|
# BaseController, Admin::BaseController, …).
|
|
435
|
-
# (2) The controller's view directory exists but contains
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
# Deliberately NOT triggered by "no view directory at
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
# case).
|
|
429
|
+
# (2) The controller's view directory exists but contains ONLY
|
|
430
|
+
# subdirectories (no template files at its top). Mastodon's
|
|
431
|
+
# Admin::SettingsController whose app/views/admin/settings/
|
|
432
|
+
# holds only about/, appearance/, … — the parent of nested
|
|
433
|
+
# controllers.
|
|
434
|
+
# Deliberately NOT triggered by "no view directory at all" because
|
|
435
|
+
# that fires the diagnostic we DO want for genuinely-missing views
|
|
436
|
+
# (the typo / forgot-to-create case).
|
|
444
437
|
def abstract_base_controller?(class_name, controller_path, view_search_roots)
|
|
445
438
|
return true if class_name.end_with?("BaseController")
|
|
446
439
|
|
|
@@ -453,32 +446,12 @@ module Rigor
|
|
|
453
446
|
end
|
|
454
447
|
end
|
|
455
448
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
diag = render_diagnostic(path, call_node, target, view_search_roots)
|
|
463
|
-
diagnostics << diag if diag
|
|
464
|
-
end
|
|
465
|
-
diagnostics
|
|
466
|
-
end
|
|
467
|
-
|
|
468
|
-
def walk_render_calls(node, &)
|
|
469
|
-
return unless node.is_a?(Prism::Node)
|
|
470
|
-
|
|
471
|
-
yield node if node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :render
|
|
472
|
-
node.compact_child_nodes.each { |child| walk_render_calls(child, &) }
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Returns `[kind, view_relative_path]` where kind is
|
|
476
|
-
# `:template` or `:partial`, and view_relative_path is
|
|
477
|
-
# the path under view_search_roots WITHOUT extension
|
|
478
|
-
# (the extension family is appended at lookup time).
|
|
479
|
-
# Returns nil for shapes Phase 3 doesn't validate
|
|
480
|
-
# (`layout:` / `plain:` / `json:` / `text:` / `inline:`
|
|
481
|
-
# / `:nothing` / no parseable target).
|
|
449
|
+
# Returns `[kind, view_relative_path]` where kind is `:template`
|
|
450
|
+
# or `:partial`, and view_relative_path is the path under
|
|
451
|
+
# view_search_roots WITHOUT extension (the extension family is
|
|
452
|
+
# appended at lookup time). Returns nil for shapes Phase 3 doesn't
|
|
453
|
+
# validate (`layout:` / `plain:` / `json:` / `text:` / `inline:` /
|
|
454
|
+
# `:nothing` / no parseable target).
|
|
482
455
|
def render_target_for(call_node, controller_path)
|
|
483
456
|
args = call_node.arguments&.arguments || []
|
|
484
457
|
return nil if args.empty?
|
|
@@ -487,10 +460,9 @@ module Rigor
|
|
|
487
460
|
# `render partial: "..."` — the keyword form.
|
|
488
461
|
return partial_target_from_kwargs(first, controller_path) if first.is_a?(Prism::KeywordHashNode)
|
|
489
462
|
|
|
490
|
-
# `render :symbol` / `render "path"`. A trailing
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
# template name.
|
|
463
|
+
# `render :symbol` / `render "path"`. A trailing KeywordHashNode
|
|
464
|
+
# is allowed (e.g. `render :show, status: :ok`); the leading
|
|
465
|
+
# positional carries the template name.
|
|
494
466
|
template_target_from_positional(first, controller_path)
|
|
495
467
|
end
|
|
496
468
|
|
|
@@ -522,33 +494,26 @@ module Rigor
|
|
|
522
494
|
|
|
523
495
|
# `UsersController` → "users".
|
|
524
496
|
# `Admin::WidgetsController` → "admin/widgets".
|
|
525
|
-
# Returns nil for class names that don't end with the
|
|
526
|
-
#
|
|
497
|
+
# Returns nil for class names that don't end with the `Controller`
|
|
498
|
+
# suffix.
|
|
527
499
|
def controller_path_for(class_name)
|
|
528
500
|
return nil unless class_name.end_with?("Controller")
|
|
529
501
|
|
|
530
502
|
stripped = class_name.delete_suffix("Controller")
|
|
531
|
-
|
|
503
|
+
# ADR-39: the shared inflector flattens `::` → `/` and applies
|
|
504
|
+
# Rails' real underscore (the per-segment hand-rolled version
|
|
505
|
+
# produced the same result for the common case but missed
|
|
506
|
+
# acronym handling). `Admin::Widgets` → `admin/widgets`.
|
|
507
|
+
Rigor::Plugin::Inflector.underscore(stripped)
|
|
532
508
|
end
|
|
533
509
|
|
|
534
|
-
|
|
535
|
-
# `WordWord` → `word_word` mapping. Doesn't try to
|
|
536
|
-
# handle acronyms (`HTTPController` would inflect to
|
|
537
|
-
# `h_t_t_p`); users with that need can ship a
|
|
538
|
-
# configured override in a follow-up slice.
|
|
539
|
-
def underscore(camel)
|
|
540
|
-
camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
541
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
542
|
-
.downcase
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
def render_diagnostic(path, call_node, target, view_search_roots)
|
|
510
|
+
def render_violation(call_node, target, view_search_roots)
|
|
546
511
|
kind, relative = target
|
|
547
512
|
existing = locate_template(relative, view_search_roots)
|
|
548
513
|
if existing
|
|
549
|
-
|
|
514
|
+
render_target_violation(call_node, kind, relative, existing)
|
|
550
515
|
else
|
|
551
|
-
|
|
516
|
+
missing_template_violation(call_node, kind, relative, view_search_roots)
|
|
552
517
|
end
|
|
553
518
|
end
|
|
554
519
|
|
|
@@ -562,114 +527,55 @@ module Rigor
|
|
|
562
527
|
nil
|
|
563
528
|
end
|
|
564
529
|
|
|
565
|
-
def
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
530
|
+
def render_target_violation(call_node, kind, relative, located)
|
|
531
|
+
Violation.new(
|
|
532
|
+
location: location(call_node),
|
|
569
533
|
message: "Action Pack render #{kind} `#{relative}` resolved to `#{located}`.",
|
|
570
534
|
severity: :info, rule: "render-target"
|
|
571
535
|
)
|
|
572
536
|
end
|
|
573
537
|
|
|
574
|
-
def
|
|
575
|
-
loc = call_node.message_loc || call_node.location
|
|
538
|
+
def missing_template_violation(call_node, kind, relative, view_search_roots)
|
|
576
539
|
tried = RENDER_TEMPLATE_EXTENSIONS.map { |ext| "#{relative}#{ext}" }.join(", ")
|
|
577
540
|
roots = view_search_roots.join(", ")
|
|
578
|
-
|
|
579
|
-
|
|
541
|
+
Violation.new(
|
|
542
|
+
location: location(call_node),
|
|
580
543
|
message: "Action Pack render #{kind} `#{relative}` not found under #{roots} " \
|
|
581
544
|
"(tried #{tried}).",
|
|
582
545
|
severity: :error, rule: "missing-template"
|
|
583
546
|
)
|
|
584
547
|
end
|
|
585
548
|
|
|
586
|
-
def
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
549
|
+
def filter_call_violation(call_node, filter_name)
|
|
550
|
+
Violation.new(
|
|
551
|
+
location: location(call_node),
|
|
590
552
|
message: "Action Pack filter `#{call_node.name} :#{filter_name}` resolves to a defined method.",
|
|
591
553
|
severity: :info, rule: "filter-call"
|
|
592
554
|
)
|
|
593
555
|
end
|
|
594
556
|
|
|
595
|
-
def
|
|
596
|
-
loc = arg_node.location
|
|
557
|
+
def unknown_filter_violation(arg_node, call_node, filter_name, spell_checker)
|
|
597
558
|
base = "Action Pack filter `#{call_node.name} :#{filter_name}` references no method " \
|
|
598
559
|
"defined on this controller (or its parent)."
|
|
599
560
|
suggestion = spell_checker.correct(filter_name.to_s).first
|
|
600
561
|
message = suggestion ? "#{base} Did you mean `:#{suggestion}`?" : base
|
|
601
|
-
|
|
602
|
-
|
|
562
|
+
Violation.new(
|
|
563
|
+
location: arg_node.location,
|
|
603
564
|
message: message, severity: :error, rule: "unknown-filter-method"
|
|
604
565
|
)
|
|
605
566
|
end
|
|
606
567
|
|
|
607
|
-
# Walk the AST yielding only call nodes whose method
|
|
608
|
-
# name ends in `_path` / `_url` and whose receiver is
|
|
609
|
-
# implicit-self (no explicit receiver). Constants are
|
|
610
|
-
# skipped — `Rails.application.routes.url_helpers` is
|
|
611
|
-
# not what Phase 4 validates.
|
|
612
|
-
def walk(node, &)
|
|
613
|
-
return unless node.is_a?(Prism::Node)
|
|
614
|
-
|
|
615
|
-
yield node if node.is_a?(Prism::CallNode) && helper_suffix?(node.name) && node.receiver.nil?
|
|
616
|
-
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
617
|
-
end
|
|
618
|
-
|
|
619
568
|
def helper_suffix?(name)
|
|
620
569
|
name_str = name.to_s
|
|
621
570
|
SUFFIXES.any? { |suffix| name_str.end_with?(suffix) && name_str.length > suffix.length }
|
|
622
571
|
end
|
|
623
572
|
|
|
624
|
-
# Returns `[entry, suggestion]`:
|
|
625
|
-
#
|
|
626
|
-
# - `[entry, nil]` — known helper.
|
|
627
|
-
# - `[nil, nil]` — unknown helper, no spell-checker match.
|
|
628
|
-
# - `[nil, "user_path"]` — unknown helper, did-you-mean
|
|
629
|
-
# suggestion to surface in the diagnostic.
|
|
630
|
-
def lookup(call_node, helper_table, spell_checker)
|
|
631
|
-
name = call_node.name.to_s
|
|
632
|
-
entry = helper_table[name]
|
|
633
|
-
return [entry, nil] if entry
|
|
634
|
-
|
|
635
|
-
[nil, spell_checker.correct(name).first]
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Builds the diagnostic. Only the **info-level** route
|
|
639
|
-
# resolution (`helper-call`) is the value this plugin
|
|
640
|
-
# adds — it surfaces the resolved HTTP method + path +
|
|
641
|
-
# action alongside the call site, which `rigor-rails-routes`
|
|
642
|
-
# does not. Unknown-helper and wrong-arity diagnostics are
|
|
643
|
-
# produced canonically by `rigor-rails-routes`'s own analyzer
|
|
644
|
-
# (same `:helper_table` source of truth), so emitting them
|
|
645
|
-
# here would double every call-site error. Real-world
|
|
646
|
-
# impact pre-fix: ~301 duplicates on Mastodon and ~119 on
|
|
647
|
-
# Redmine, exactly stacked with the rails-routes diagnostics.
|
|
648
|
-
# Return `nil` for unknown / arity-mismatch shapes so the
|
|
649
|
-
# caller filters them out.
|
|
650
|
-
def diagnostic_for(path, call_node, entry, _suggestion)
|
|
651
|
-
return nil if entry.nil?
|
|
652
|
-
|
|
653
|
-
actual_arity = positional_arg_count(call_node)
|
|
654
|
-
# The `:helper_table` fact entry carries both
|
|
655
|
-
# `:arity` (the first registered entry's arity) and
|
|
656
|
-
# `:acceptable_arities` (the full Array — populated
|
|
657
|
-
# for uncountable-noun resources like `resources :news`
|
|
658
|
-
# which share a helper name across index/show). Honour
|
|
659
|
-
# the full set; fall back to the single arity for
|
|
660
|
-
# consumers compiled against an older fact shape.
|
|
661
|
-
acceptable = entry[:acceptable_arities] || [entry[:arity]]
|
|
662
|
-
return nil unless acceptable.include?(actual_arity)
|
|
663
|
-
|
|
664
|
-
helper_call_diagnostic(path, call_node, entry)
|
|
665
|
-
end
|
|
666
|
-
|
|
667
573
|
def positional_arg_count(call_node)
|
|
668
574
|
args = call_node.arguments&.arguments || []
|
|
669
|
-
# Drop a trailing `KeywordHashNode` so call sites that
|
|
670
|
-
#
|
|
671
|
-
#
|
|
672
|
-
#
|
|
575
|
+
# Drop a trailing `KeywordHashNode` so call sites that pass
|
|
576
|
+
# `users_path(format: :json)` don't get counted as arity 1. Same
|
|
577
|
+
# convention rigor-rails-routes' helper-table arity uses
|
|
578
|
+
# (positional only).
|
|
673
579
|
args = args[0..-2] if args.last.is_a?(Prism::KeywordHashNode)
|
|
674
580
|
args.size
|
|
675
581
|
end
|
|
@@ -678,35 +584,14 @@ module Rigor
|
|
|
678
584
|
call_node.message_loc || call_node.location
|
|
679
585
|
end
|
|
680
586
|
|
|
681
|
-
def
|
|
682
|
-
loc = location(call_node)
|
|
587
|
+
def helper_call_violation(call_node, entry)
|
|
683
588
|
method = entry[:http_method] ? entry[:http_method].to_s.upcase : "(any)"
|
|
684
|
-
|
|
685
|
-
|
|
589
|
+
Violation.new(
|
|
590
|
+
location: location(call_node),
|
|
686
591
|
message: "Action Pack helper `#{call_node.name}` → #{method} #{entry[:path]} (action: #{entry[:action]}).",
|
|
687
592
|
severity: :info, rule: "helper-call"
|
|
688
593
|
)
|
|
689
594
|
end
|
|
690
|
-
|
|
691
|
-
def unknown_helper_diagnostic(path, call_node, suggestion)
|
|
692
|
-
loc = location(call_node)
|
|
693
|
-
base = "Unknown route helper `#{call_node.name}` — not registered in `config/routes.rb`."
|
|
694
|
-
message = suggestion ? "#{base} Did you mean `#{suggestion}`?" : base
|
|
695
|
-
Diagnostic.new(
|
|
696
|
-
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
697
|
-
message: message, severity: :error, rule: "unknown-helper"
|
|
698
|
-
)
|
|
699
|
-
end
|
|
700
|
-
|
|
701
|
-
def wrong_arity_diagnostic(path, call_node, entry, actual_arity)
|
|
702
|
-
loc = location(call_node)
|
|
703
|
-
Diagnostic.new(
|
|
704
|
-
path: path, line: loc.start_line, column: loc.start_column + 1,
|
|
705
|
-
message: "Route helper `#{call_node.name}` expects #{entry[:arity]} positional " \
|
|
706
|
-
"argument(s) but the call passes #{actual_arity}.",
|
|
707
|
-
severity: :error, rule: "wrong-helper-arity"
|
|
708
|
-
)
|
|
709
|
-
end
|
|
710
595
|
end
|
|
711
596
|
end
|
|
712
597
|
end
|