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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
8
|
+
# Walks `app/helpers/**/*.rb` (or any configured set of
|
|
9
|
+
# helper directories) and extracts every public
|
|
10
|
+
# module-level method name so the analyzer's
|
|
11
|
+
# `unknown-helper` rule does not false-fire on calls to
|
|
12
|
+
# project-defined `*_path` / `*_url` helpers (the
|
|
13
|
+
# canonical Rails pattern: a `UrlHelper` module exposing
|
|
14
|
+
# `full_asset_url`, `host_to_url`, `frontend_asset_url`,
|
|
15
|
+
# and so on).
|
|
16
|
+
#
|
|
17
|
+
# The discoverer is deliberately conservative:
|
|
18
|
+
#
|
|
19
|
+
# - Walks `def name(...)` declarations at the module /
|
|
20
|
+
# class top level. Nested `def` inside a method body or
|
|
21
|
+
# a `define_method` macro is NOT extracted (the same
|
|
22
|
+
# reason `rigor-actionpack` is not the canonical
|
|
23
|
+
# custom-helper source — metaprogrammed helpers are out
|
|
24
|
+
# of scope for a static scan).
|
|
25
|
+
# - **Visibility-agnostic.** A `private` / `protected`
|
|
26
|
+
# `def some_url` IS registered. The `unknown-helper`
|
|
27
|
+
# rule is a project-wide name-presence check, not a
|
|
28
|
+
# visibility check — if the user wrote a `_path` /
|
|
29
|
+
# `_url` method anywhere in `app/`, they very likely
|
|
30
|
+
# intend to call it (often Mastodon's pattern: a
|
|
31
|
+
# controller's `private def page_url(page)` invoked
|
|
32
|
+
# by a `link_to` in its own view). The core engine's
|
|
33
|
+
# `call.undefined-method` rule still catches the case
|
|
34
|
+
# where the receiver genuinely cannot see the method.
|
|
35
|
+
# - Ignores `def self.x` (singleton-method definitions) —
|
|
36
|
+
# Rails helpers are instance methods on the helper
|
|
37
|
+
# module; class-side `self.x` shapes are usually
|
|
38
|
+
# internal utilities, not view helpers.
|
|
39
|
+
# - Filters the resulting set to names ending in `_path`
|
|
40
|
+
# or `_url` (the dispatch surface the analyzer's rule
|
|
41
|
+
# actually cares about). A helper whose name does not
|
|
42
|
+
# end with either suffix is irrelevant to the rule's
|
|
43
|
+
# false-positive surface, so excluding it costs nothing
|
|
44
|
+
# and keeps the registered set focused.
|
|
45
|
+
#
|
|
46
|
+
# Out of scope (intentional):
|
|
47
|
+
#
|
|
48
|
+
# - Helpers defined via `define_method` or via macros like
|
|
49
|
+
# `inheritance_traversed_helpers`. These would need
|
|
50
|
+
# ADR-16 macro substrate or per-plugin custom recognizers.
|
|
51
|
+
# - Helpers inherited from gems (`Devise::Controllers::Helpers`,
|
|
52
|
+
# `ViteRails::TagHelpers`, etc.). Those need their own
|
|
53
|
+
# handling — Devise auto-routes are covered by the
|
|
54
|
+
# companion `DeviseRoutes` generator; other gem-injected
|
|
55
|
+
# helpers fall to ADR-25 plugin-contributed RBS or the
|
|
56
|
+
# ADR-10 `dependencies.source_inference:` path.
|
|
57
|
+
module HelperDiscoverer
|
|
58
|
+
HELPER_SUFFIXES = [/_path\z/, /_url\z/].freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# @param contents_per_path [Hash{String => String}]
|
|
63
|
+
# file path → source text. The caller is responsible
|
|
64
|
+
# for reading files (typically through the trusted
|
|
65
|
+
# `IoBoundary` so cache invalidation works).
|
|
66
|
+
# @return [Set<String>] method names suitable for
|
|
67
|
+
# inclusion in the `HelperTable`'s custom-helper set.
|
|
68
|
+
def discover(contents_per_path)
|
|
69
|
+
names = []
|
|
70
|
+
contents_per_path.each_value do |contents|
|
|
71
|
+
names.concat(extract_from_contents(contents))
|
|
72
|
+
rescue StandardError
|
|
73
|
+
# Skip any file whose AST walk explodes — discovery
|
|
74
|
+
# is best-effort and a parse failure in one helper
|
|
75
|
+
# file MUST NOT abort discovery for the rest. Other
|
|
76
|
+
# rules will still surface the parse failure on the
|
|
77
|
+
# affected file through the core pipeline.
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
names.to_set
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_from_contents(contents)
|
|
84
|
+
parse_result = Prism.parse(contents)
|
|
85
|
+
return [] unless parse_result.errors.empty?
|
|
86
|
+
|
|
87
|
+
walker = ModuleBodyWalker.new
|
|
88
|
+
walker.walk(parse_result.value)
|
|
89
|
+
walker.helper_names
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Per-file AST walker. Tracks the current visibility
|
|
93
|
+
# mode (`:public` by default; `private` / `protected`
|
|
94
|
+
# calls flip the bit) so a `def` declared after a bare
|
|
95
|
+
# `private` is excluded.
|
|
96
|
+
#
|
|
97
|
+
# The walker is recursive into module / class bodies so
|
|
98
|
+
# `module UrlHelpers; module Routing; def foo_url; end;
|
|
99
|
+
# end; end` registers `foo_url`. It does NOT recurse
|
|
100
|
+
# into method bodies (a `def inside def` would be
|
|
101
|
+
# `define_method`-shaped, which we skip per the
|
|
102
|
+
# docstring).
|
|
103
|
+
class ModuleBodyWalker
|
|
104
|
+
attr_reader :helper_names
|
|
105
|
+
|
|
106
|
+
def initialize
|
|
107
|
+
@helper_names = []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def walk(node)
|
|
111
|
+
visit_statements(node) if node.is_a?(Prism::ProgramNode)
|
|
112
|
+
visit_program_children(node)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def visit_program_children(node)
|
|
118
|
+
return unless node.respond_to?(:compact_child_nodes)
|
|
119
|
+
|
|
120
|
+
node.compact_child_nodes.each do |child|
|
|
121
|
+
case child
|
|
122
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
123
|
+
visit_module_or_class(child)
|
|
124
|
+
when Prism::ProgramNode, Prism::StatementsNode
|
|
125
|
+
visit_program_children(child)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def visit_module_or_class(node)
|
|
131
|
+
visit_statements(node.body)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def visit_statements(body)
|
|
135
|
+
return if body.nil?
|
|
136
|
+
|
|
137
|
+
# Visibility tracking is intentionally absent — see
|
|
138
|
+
# the "visibility-agnostic" point in the module
|
|
139
|
+
# docstring. A `private def page_url(page)` inside
|
|
140
|
+
# a controller IS registered so a caller in the
|
|
141
|
+
# paired view does not false-fire `unknown-helper`.
|
|
142
|
+
statements_of(body).each do |stmt|
|
|
143
|
+
case stmt
|
|
144
|
+
when Prism::DefNode
|
|
145
|
+
record_helper(stmt)
|
|
146
|
+
when Prism::ModuleNode, Prism::ClassNode
|
|
147
|
+
visit_module_or_class(stmt)
|
|
148
|
+
when Prism::StatementsNode
|
|
149
|
+
visit_statements(stmt)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def statements_of(node)
|
|
155
|
+
case node
|
|
156
|
+
when Prism::StatementsNode then node.body
|
|
157
|
+
when Prism::BeginNode then statements_of(node.statements)
|
|
158
|
+
else [node]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def record_helper(def_node)
|
|
163
|
+
# Skip singleton methods (`def self.x`).
|
|
164
|
+
return if def_node.receiver
|
|
165
|
+
|
|
166
|
+
name = def_node.name.to_s
|
|
167
|
+
return unless HELPER_SUFFIXES.any? { |suffix| name.match?(suffix) }
|
|
168
|
+
|
|
169
|
+
@helper_names << name
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "devise_routes"
|
|
4
|
+
require_relative "doorkeeper_routes"
|
|
5
|
+
|
|
3
6
|
module Rigor
|
|
4
7
|
module Plugin
|
|
5
8
|
class RailsRoutes < Rigor::Plugin::Base
|
|
@@ -30,11 +33,27 @@ module Rigor
|
|
|
30
33
|
class HelperTable
|
|
31
34
|
Entry = Data.define(:name, :arity, :path, :http_method, :action)
|
|
32
35
|
|
|
33
|
-
attr_reader :entries
|
|
36
|
+
attr_reader :entries, :custom_helpers, :devise_resources
|
|
34
37
|
|
|
35
38
|
# @param entries [Array<Entry>] freshly built; the
|
|
36
39
|
# factory below is the canonical construction path.
|
|
37
|
-
|
|
40
|
+
# @param custom_helpers [Enumerable<String>] names of
|
|
41
|
+
# project-defined helper methods (typically pulled
|
|
42
|
+
# from `app/helpers/**/*.rb` by
|
|
43
|
+
# {HelperDiscoverer}) that the analyzer should treat
|
|
44
|
+
# as known — they do NOT correspond to a route but
|
|
45
|
+
# their presence MUST NOT fire `unknown-helper`. No
|
|
46
|
+
# arity / path metadata is recorded; the analyzer
|
|
47
|
+
# skips the route-side checks for these names.
|
|
48
|
+
# @param devise_resources [Enumerable<String>] resource
|
|
49
|
+
# names declared via `devise_for :resource` (already
|
|
50
|
+
# singularised, e.g. `"user"`). Used to recognise the
|
|
51
|
+
# dynamic OmniAuth helper family
|
|
52
|
+
# (`<resource>_<provider>_omniauth_(authorize|callback)_(path|url)`)
|
|
53
|
+
# whose provider segment is supplied at runtime — no
|
|
54
|
+
# per-name entry is registered for them but they MUST
|
|
55
|
+
# NOT fire `unknown-helper` either.
|
|
56
|
+
def initialize(entries, custom_helpers: [], devise_resources: [])
|
|
38
57
|
@entries = entries.freeze
|
|
39
58
|
# Multimap: a single helper name can map to multiple
|
|
40
59
|
# entries when an uncountable-noun resource registers
|
|
@@ -43,6 +62,8 @@ module Rigor
|
|
|
43
62
|
# returns the first entry (preserving the previous
|
|
44
63
|
# API); `accepts_arity?` checks against every entry.
|
|
45
64
|
@by_name = entries.group_by(&:name).transform_values(&:freeze).freeze
|
|
65
|
+
@custom_helpers = custom_helpers.to_set.freeze
|
|
66
|
+
@devise_resources = devise_resources.to_set(&:to_s).freeze
|
|
46
67
|
freeze
|
|
47
68
|
end
|
|
48
69
|
|
|
@@ -59,10 +80,50 @@ module Rigor
|
|
|
59
80
|
@by_name.key?(helper_name.to_s)
|
|
60
81
|
end
|
|
61
82
|
|
|
83
|
+
# True when `helper_name` is either a registered route
|
|
84
|
+
# helper, a discovered project-defined custom helper,
|
|
85
|
+
# OR a dynamic OmniAuth-shaped helper for one of the
|
|
86
|
+
# declared `devise_for` resources. The
|
|
87
|
+
# `unknown-helper` rule consults this predicate to
|
|
88
|
+
# decide whether to fire — `known?` alone misses
|
|
89
|
+
# custom helpers and OmniAuth providers, which would
|
|
90
|
+
# then false-fire on canonical Rails-app patterns.
|
|
91
|
+
def recognised?(helper_name)
|
|
92
|
+
name = helper_name.to_s
|
|
93
|
+
return true if @by_name.key?(name)
|
|
94
|
+
return true if @custom_helpers.include?(name)
|
|
95
|
+
|
|
96
|
+
omniauth_match?(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `<resource>_<provider>_omniauth_(authorize|callback)_(path|url)` — when
|
|
100
|
+
# `<resource>` matches a declared `devise_for` resource
|
|
101
|
+
# the helper is dynamic-provider Devise OmniAuth. The
|
|
102
|
+
# provider segment is opaque to a static parser, so we
|
|
103
|
+
# accept any non-empty token between the resource and
|
|
104
|
+
# the omniauth suffix.
|
|
105
|
+
def omniauth_match?(name)
|
|
106
|
+
return false if @devise_resources.empty?
|
|
107
|
+
|
|
108
|
+
DeviseRoutes::OMNIAUTH_SUFFIXES.any? do |suffix|
|
|
109
|
+
next false unless name.end_with?(suffix)
|
|
110
|
+
|
|
111
|
+
stem = name.delete_suffix(suffix)
|
|
112
|
+
@devise_resources.any? do |resource|
|
|
113
|
+
stem.start_with?("#{resource}_") && stem.length > resource.length + 1
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
62
118
|
# @return [Boolean] true when any entry under this
|
|
63
119
|
# helper name accepts the given positional arity.
|
|
120
|
+
#
|
|
121
|
+
# Rails helpers have the signature `helper(*segments, options = {})`,
|
|
122
|
+
# so `expected + 1` is always valid — the extra argument is treated
|
|
123
|
+
# as a query-params/options hash (e.g. `users_path(page: 2)` or
|
|
124
|
+
# `user_path(@u, pagination_params(...))`).
|
|
64
125
|
def accepts_arity?(helper_name, arity)
|
|
65
|
-
(@by_name[helper_name.to_s] || []).any? { |entry| entry.arity == arity }
|
|
126
|
+
(@by_name[helper_name.to_s] || []).any? { |entry| entry.arity == arity || entry.arity + 1 == arity }
|
|
66
127
|
end
|
|
67
128
|
|
|
68
129
|
# @return [Array<Integer>] all accepted positional
|