rigortype 0.1.9 → 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/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- 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
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +180 -1
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "helper_table"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
10
|
+
# Statically interprets `config/routes.rb`'s DSL via
|
|
11
|
+
# Prism — never executes the file. The interpreter is
|
|
12
|
+
# deliberately narrow; it covers the subset documented
|
|
13
|
+
# in the plugin's README and degrades silently on
|
|
14
|
+
# constructs it doesn't recognise.
|
|
15
|
+
#
|
|
16
|
+
# Recognised DSL surface (per the Rails-plugins
|
|
17
|
+
# roadmap):
|
|
18
|
+
#
|
|
19
|
+
# - `Rails.application.routes.draw do ... end` (entry
|
|
20
|
+
# block; the body is interpreted)
|
|
21
|
+
# - `resources :name [, only: [...] | except: [...]]`
|
|
22
|
+
# - `resource :name`
|
|
23
|
+
# - `get/post/patch/put/delete "path", to:, as:`
|
|
24
|
+
# - `root to: "..."` / `root "..."`
|
|
25
|
+
# - One level of `namespace :foo do ... end`
|
|
26
|
+
# - One level of nested `resources` (`resources :users
|
|
27
|
+
# do; resources :posts; end`)
|
|
28
|
+
# - `member do ... end` / `collection do ... end`
|
|
29
|
+
# inside `resources`
|
|
30
|
+
#
|
|
31
|
+
# Out of scope for v0.1.0 (silent skips):
|
|
32
|
+
#
|
|
33
|
+
# - `scope :path:` / `scope :module:` / `scope :as:`
|
|
34
|
+
# - Constraints (`constraints: { id: /\d+/ }`)
|
|
35
|
+
# - `mount` / engine routes
|
|
36
|
+
# - `direct(:name) { |obj| ... }`
|
|
37
|
+
# - Format restrictions
|
|
38
|
+
module RoutesParser
|
|
39
|
+
# Standard resource actions Rails generates by default.
|
|
40
|
+
DEFAULT_RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze
|
|
41
|
+
# Default actions for `resource` (singular) — no index,
|
|
42
|
+
# no `:id` segment.
|
|
43
|
+
DEFAULT_SINGULAR_ACTIONS = %i[show new create edit update destroy].freeze
|
|
44
|
+
|
|
45
|
+
# Helper-name conventions per action. `:show` and
|
|
46
|
+
# `:update` / `:destroy` share the singular-form
|
|
47
|
+
# helper (Rails dedupes).
|
|
48
|
+
ACTION_HTTP_METHODS = {
|
|
49
|
+
index: :get,
|
|
50
|
+
show: :get,
|
|
51
|
+
new: :get,
|
|
52
|
+
create: :post,
|
|
53
|
+
edit: :get,
|
|
54
|
+
update: :patch, # also :put
|
|
55
|
+
destroy: :delete
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
# @param contents [String] raw `config/routes.rb` source
|
|
61
|
+
# @return [HelperTable]
|
|
62
|
+
def parse(contents)
|
|
63
|
+
parse_result = Prism.parse(contents)
|
|
64
|
+
return HelperTable.new([]) unless parse_result.errors.empty?
|
|
65
|
+
|
|
66
|
+
context = Context.new
|
|
67
|
+
interpret(parse_result.value, context)
|
|
68
|
+
|
|
69
|
+
# Each helper has both `_path` and `_url` forms.
|
|
70
|
+
paired = context.entries.flat_map do |entry|
|
|
71
|
+
[
|
|
72
|
+
entry,
|
|
73
|
+
HelperTable::Entry.new(
|
|
74
|
+
name: entry.name.sub(/_path\z/, "_url"),
|
|
75
|
+
arity: entry.arity,
|
|
76
|
+
path: entry.path,
|
|
77
|
+
http_method: entry.http_method,
|
|
78
|
+
action: entry.action
|
|
79
|
+
)
|
|
80
|
+
]
|
|
81
|
+
end
|
|
82
|
+
HelperTable.new(paired)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Per-parse mutable accumulator. Tracks the current
|
|
86
|
+
# nesting prefix (namespaces + parent resource) and the
|
|
87
|
+
# entries collected so far.
|
|
88
|
+
class Context
|
|
89
|
+
attr_reader :entries
|
|
90
|
+
|
|
91
|
+
def initialize
|
|
92
|
+
@entries = []
|
|
93
|
+
# Stack of prefix segments. Each entry is one of:
|
|
94
|
+
# - `{ kind: :namespace, name: "admin" }`
|
|
95
|
+
# - `{ kind: :scope, parent: "user", arity_segments: [":user_id"] }`
|
|
96
|
+
@stack = []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def push_namespace(name)
|
|
100
|
+
@stack.push(kind: :namespace, name: name.to_s)
|
|
101
|
+
yield
|
|
102
|
+
ensure
|
|
103
|
+
@stack.pop
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def push_resource(parent_name)
|
|
107
|
+
singular = singularize(parent_name.to_s)
|
|
108
|
+
@stack.push(kind: :scope, parent: singular, arity_segments: [":#{singular}_id"])
|
|
109
|
+
yield
|
|
110
|
+
ensure
|
|
111
|
+
@stack.pop
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Helper-name prefix from namespaces (`admin_`,
|
|
115
|
+
# `admin_users_`, …).
|
|
116
|
+
def helper_prefix
|
|
117
|
+
segments = @stack.filter_map { |frame| frame_helper_segment(frame) }
|
|
118
|
+
segments.map { |segment| "#{segment}_" }.join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Path prefix — including the parent's `:user_id`
|
|
122
|
+
# segments for nested resources and the namespace
|
|
123
|
+
# path prefix.
|
|
124
|
+
def path_prefix
|
|
125
|
+
parts = @stack.flat_map { |frame| frame_path_segments(frame) }
|
|
126
|
+
parts.join
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Number of dynamic segments (`:user_id`-style)
|
|
130
|
+
# captured by the parent scope chain. Used to
|
|
131
|
+
# compute helper arity for nested resources.
|
|
132
|
+
def parent_segment_count
|
|
133
|
+
@stack.count { |frame| frame[:kind] == :scope }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def frame_helper_segment(frame)
|
|
139
|
+
case frame[:kind]
|
|
140
|
+
when :namespace then frame[:name]
|
|
141
|
+
when :scope then frame[:parent]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def frame_path_segments(frame)
|
|
146
|
+
case frame[:kind]
|
|
147
|
+
when :namespace then ["/#{frame[:name]}"]
|
|
148
|
+
when :scope then ["/#{pluralize(frame[:parent])}/:#{frame[:parent]}_id"]
|
|
149
|
+
else []
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Tiny English inflector. Sufficient for the standard
|
|
154
|
+
# `posts` ↔ `post`, `users` ↔ `user` rename Rails
|
|
155
|
+
# generates by default; users with custom
|
|
156
|
+
# inflections need to author RBS by hand for the
|
|
157
|
+
# affected helpers (out of scope for v0.1.0).
|
|
158
|
+
#
|
|
159
|
+
# The canonical English uncountable noun set from
|
|
160
|
+
# ActiveSupport::Inflector::Inflections (Rails 8.x).
|
|
161
|
+
# `singularize("news")` returns `"news"` rather than
|
|
162
|
+
# `"new"`. Pre-fix the parser stripped the trailing
|
|
163
|
+
# 's' from `news`, so `resources :news` registered
|
|
164
|
+
# `new_path` / `news_path` / `new_news_path` (broken
|
|
165
|
+
# — Rails actually generates `news_path` for both
|
|
166
|
+
# index and show, with the show form taking `:id`).
|
|
167
|
+
# Redmine hit this 81× across `news_path(id)` calls.
|
|
168
|
+
UNCOUNTABLE = %w[
|
|
169
|
+
equipment information rice money species series fish
|
|
170
|
+
sheep jeans police news media settings
|
|
171
|
+
].to_set.freeze
|
|
172
|
+
private_constant :UNCOUNTABLE
|
|
173
|
+
|
|
174
|
+
def singularize(word)
|
|
175
|
+
return word if UNCOUNTABLE.include?(word)
|
|
176
|
+
return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
|
|
177
|
+
return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
|
|
178
|
+
return word.chomp("s") if word.end_with?("s")
|
|
179
|
+
|
|
180
|
+
word
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def pluralize(word)
|
|
184
|
+
return word if UNCOUNTABLE.include?(word)
|
|
185
|
+
return word if word.end_with?("s")
|
|
186
|
+
return "#{word.chomp('y')}ies" if word.end_with?("y") && word.length > 1
|
|
187
|
+
|
|
188
|
+
"#{word}s"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def interpret(node, context)
|
|
193
|
+
return unless node.is_a?(Prism::Node)
|
|
194
|
+
|
|
195
|
+
case node
|
|
196
|
+
when Prism::CallNode
|
|
197
|
+
interpret_call(node, context)
|
|
198
|
+
else
|
|
199
|
+
node.compact_child_nodes.each { |child| interpret(child, context) }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def interpret_call(node, context)
|
|
204
|
+
case node.name
|
|
205
|
+
when :draw
|
|
206
|
+
# `Rails.application.routes.draw do ... end` —
|
|
207
|
+
# interpret the block body.
|
|
208
|
+
interpret_block_body(node, context)
|
|
209
|
+
when :namespace
|
|
210
|
+
handle_namespace(node, context)
|
|
211
|
+
when :resources
|
|
212
|
+
handle_resources(node, context)
|
|
213
|
+
when :resource
|
|
214
|
+
handle_resource(node, context)
|
|
215
|
+
when :root
|
|
216
|
+
handle_root(node, context)
|
|
217
|
+
when :get, :post, :patch, :put, :delete
|
|
218
|
+
handle_explicit_route(node, context)
|
|
219
|
+
when :member, :collection
|
|
220
|
+
# Inside a `resources` block, `member do ... end`
|
|
221
|
+
# / `collection do ... end` introduces extra
|
|
222
|
+
# routes. Interpreted only when we have a parent
|
|
223
|
+
# scope (otherwise the call is meaningless).
|
|
224
|
+
handle_member_or_collection(node, context)
|
|
225
|
+
else
|
|
226
|
+
interpret_block_body(node, context)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def interpret_block_body(node, context)
|
|
231
|
+
body = node.block&.body
|
|
232
|
+
return if body.nil?
|
|
233
|
+
|
|
234
|
+
body.compact_child_nodes.each { |child| interpret(child, context) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def handle_namespace(node, context)
|
|
238
|
+
name = symbol_argument(node, 0)
|
|
239
|
+
return interpret_block_body(node, context) if name.nil?
|
|
240
|
+
|
|
241
|
+
context.push_namespace(name) { interpret_block_body(node, context) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_resources(node, context)
|
|
245
|
+
name = symbol_argument(node, 0)
|
|
246
|
+
return interpret_block_body(node, context) if name.nil?
|
|
247
|
+
|
|
248
|
+
actions = restrict_actions(node, DEFAULT_RESOURCE_ACTIONS)
|
|
249
|
+
base_arity = context.parent_segment_count
|
|
250
|
+
|
|
251
|
+
register_resourceful_helpers(name, actions, base_arity, context, plural: true)
|
|
252
|
+
|
|
253
|
+
context.push_resource(name) do
|
|
254
|
+
interpret_block_body(node, context)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def handle_resource(node, context)
|
|
259
|
+
name = symbol_argument(node, 0)
|
|
260
|
+
return interpret_block_body(node, context) if name.nil?
|
|
261
|
+
|
|
262
|
+
actions = restrict_actions(node, DEFAULT_SINGULAR_ACTIONS)
|
|
263
|
+
base_arity = context.parent_segment_count
|
|
264
|
+
|
|
265
|
+
# Singular resource — no `:id` segment, no `:index`
|
|
266
|
+
# / pluralised helper. The "show" helper is
|
|
267
|
+
# `<name>_path` (singular).
|
|
268
|
+
register_resourceful_helpers(name, actions, base_arity, context, plural: false)
|
|
269
|
+
|
|
270
|
+
# Nested `resources :things` inside `resource :profile`
|
|
271
|
+
# is rare; we still descend so the inner declarations
|
|
272
|
+
# collect their own helpers.
|
|
273
|
+
interpret_block_body(node, context)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def handle_root(node, context)
|
|
277
|
+
# `root to: "..."` / `root "..."` — single helper
|
|
278
|
+
# `root_path`, arity 0, GET. Real-world Rails apps also
|
|
279
|
+
# use `root :to => 'welcome#index', :as => 'home'` (the
|
|
280
|
+
# canonical Redmine idiom across 230+ call sites), which
|
|
281
|
+
# registers an additional `home_path` / `home_url` alias
|
|
282
|
+
# for the same path. Mastodon and Solidus also use the
|
|
283
|
+
# `as:` form occasionally for analytics-friendly URL
|
|
284
|
+
# naming.
|
|
285
|
+
path = context.path_prefix.empty? ? "/" : context.path_prefix
|
|
286
|
+
context.entries << HelperTable::Entry.new(
|
|
287
|
+
name: "#{context.helper_prefix}root_path",
|
|
288
|
+
arity: 0, path: path, http_method: :get, action: :root
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
alias_name = keyword_symbol(node, :as)
|
|
292
|
+
return if alias_name.nil?
|
|
293
|
+
|
|
294
|
+
context.entries << HelperTable::Entry.new(
|
|
295
|
+
name: "#{context.helper_prefix}#{alias_name}_path",
|
|
296
|
+
arity: 0, path: path, http_method: :get, action: :root
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def handle_explicit_route(node, context)
|
|
301
|
+
# `get "/about", to: "static#about", as: :about`
|
|
302
|
+
path = string_argument(node, 0)
|
|
303
|
+
as_name = keyword_symbol(node, :as)
|
|
304
|
+
return if as_name.nil? && path.nil?
|
|
305
|
+
|
|
306
|
+
# When `as:` is omitted, Rails generates a helper
|
|
307
|
+
# name from the path. For our static analysis
|
|
308
|
+
# we only register helpers when we can name them
|
|
309
|
+
# confidently — i.e. when `as:` is present.
|
|
310
|
+
return if as_name.nil?
|
|
311
|
+
|
|
312
|
+
name = "#{context.helper_prefix}#{as_name}_path"
|
|
313
|
+
arity = context.parent_segment_count + count_path_placeholders(path)
|
|
314
|
+
context.entries << HelperTable::Entry.new(
|
|
315
|
+
name: name, arity: arity,
|
|
316
|
+
path: "#{context.path_prefix}#{path || ''}",
|
|
317
|
+
http_method: node.name, action: :custom
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def handle_member_or_collection(node, context)
|
|
322
|
+
# Only meaningful when we're inside a `resources` /
|
|
323
|
+
# `resource` block. The Context's stack tells us.
|
|
324
|
+
return unless context.parent_segment_count.positive? || in_singular_resource?(context)
|
|
325
|
+
|
|
326
|
+
# The Context doesn't currently distinguish
|
|
327
|
+
# "inside resources" from "inside resource" — for
|
|
328
|
+
# v0.1.0 we treat both the same way and let the
|
|
329
|
+
# explicit `as:` in member/collection do the
|
|
330
|
+
# naming work.
|
|
331
|
+
interpret_block_body(node, context)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def in_singular_resource?(*)
|
|
335
|
+
# Slice 1 doesn't model the singular-resource frame
|
|
336
|
+
# separately; placeholder so member / collection
|
|
337
|
+
# blocks at least descend.
|
|
338
|
+
true
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Generate the standard helpers for a resource(s).
|
|
342
|
+
# `plural: true` for `resources :users`, `false` for
|
|
343
|
+
# `resource :profile`.
|
|
344
|
+
def register_resourceful_helpers(name, actions, base_arity, context, plural:)
|
|
345
|
+
singular = singularize_word(name.to_s)
|
|
346
|
+
plural_form = plural ? name.to_s : singular # `resource :foo` uses singular path
|
|
347
|
+
path_base = "#{context.path_prefix}/#{plural_form}"
|
|
348
|
+
|
|
349
|
+
actions.each do |action|
|
|
350
|
+
entry = entry_for_action(
|
|
351
|
+
action,
|
|
352
|
+
name: name, singular: singular, base_arity: base_arity,
|
|
353
|
+
path_base: path_base, helper_prefix: context.helper_prefix, plural: plural
|
|
354
|
+
)
|
|
355
|
+
context.entries << entry if entry
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# `:create` / `:update` / `:destroy` don't generate
|
|
360
|
+
# `*_path` helpers separate from the show / index
|
|
361
|
+
# helper Rails reuses for their forms; the case
|
|
362
|
+
# statement returns nil for those and the caller
|
|
363
|
+
# skips them.
|
|
364
|
+
def entry_for_action(action, name:, singular:, base_arity:, path_base:, helper_prefix:, plural:)
|
|
365
|
+
case action
|
|
366
|
+
when :index then index_entry(plural, helper_prefix, name, base_arity, path_base)
|
|
367
|
+
when :show then show_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
368
|
+
when :new
|
|
369
|
+
HelperTable::Entry.new(
|
|
370
|
+
name: "#{helper_prefix}new_#{singular}_path",
|
|
371
|
+
arity: base_arity, path: "#{path_base}/new",
|
|
372
|
+
http_method: :get, action: :new
|
|
373
|
+
)
|
|
374
|
+
when :edit then edit_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def index_entry(plural, helper_prefix, name, base_arity, path_base)
|
|
379
|
+
return nil unless plural
|
|
380
|
+
|
|
381
|
+
HelperTable::Entry.new(
|
|
382
|
+
name: "#{helper_prefix}#{name}_path",
|
|
383
|
+
arity: base_arity, path: path_base,
|
|
384
|
+
http_method: :get, action: :index
|
|
385
|
+
)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def show_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
389
|
+
show_path = plural ? "#{path_base}/:id" : path_base
|
|
390
|
+
show_arity = plural ? base_arity + 1 : base_arity
|
|
391
|
+
HelperTable::Entry.new(
|
|
392
|
+
name: "#{helper_prefix}#{singular}_path",
|
|
393
|
+
arity: show_arity, path: show_path,
|
|
394
|
+
http_method: :get, action: :show
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def edit_entry(plural, helper_prefix, singular, base_arity, path_base)
|
|
399
|
+
edit_path = plural ? "#{path_base}/:id/edit" : "#{path_base}/edit"
|
|
400
|
+
edit_arity = plural ? base_arity + 1 : base_arity
|
|
401
|
+
HelperTable::Entry.new(
|
|
402
|
+
name: "#{helper_prefix}edit_#{singular}_path",
|
|
403
|
+
arity: edit_arity, path: edit_path,
|
|
404
|
+
http_method: :get, action: :edit
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def restrict_actions(node, default)
|
|
409
|
+
options = options_hash(node)
|
|
410
|
+
# `resources :foo, only: :show` is the same as
|
|
411
|
+
# `only: [:show]` in Rails; `options_hash` preserves the
|
|
412
|
+
# Symbol shape from the source, so coerce here.
|
|
413
|
+
if (only = options[:only])
|
|
414
|
+
Array(only) & default
|
|
415
|
+
elsif (except = options[:except])
|
|
416
|
+
default - Array(except)
|
|
417
|
+
else
|
|
418
|
+
default
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def options_hash(node)
|
|
423
|
+
args = node.arguments&.arguments || []
|
|
424
|
+
last = args.last
|
|
425
|
+
return {} unless last.is_a?(Prism::KeywordHashNode)
|
|
426
|
+
|
|
427
|
+
last.elements.each_with_object({}) do |element, into|
|
|
428
|
+
next unless element.is_a?(Prism::AssocNode)
|
|
429
|
+
next unless element.key.is_a?(Prism::SymbolNode)
|
|
430
|
+
|
|
431
|
+
value = symbol_array(element.value) || symbol_value(element.value) || string_value(element.value)
|
|
432
|
+
into[element.key.unescaped.to_sym] = value
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def symbol_argument(node, index)
|
|
437
|
+
arg = (node.arguments&.arguments || [])[index]
|
|
438
|
+
symbol_value(arg)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def string_argument(node, index)
|
|
442
|
+
arg = (node.arguments&.arguments || [])[index]
|
|
443
|
+
string_value(arg)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def keyword_symbol(node, key)
|
|
447
|
+
options_hash(node)[key]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def symbol_value(node)
|
|
451
|
+
node.is_a?(Prism::SymbolNode) ? node.unescaped.to_sym : nil
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def string_value(node)
|
|
455
|
+
node.is_a?(Prism::StringNode) ? node.unescaped : nil
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def symbol_array(node)
|
|
459
|
+
return nil unless node.is_a?(Prism::ArrayNode)
|
|
460
|
+
|
|
461
|
+
values = node.elements.map { |e| symbol_value(e) }
|
|
462
|
+
values.all? ? values : nil
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def count_path_placeholders(path)
|
|
466
|
+
return 0 if path.nil?
|
|
467
|
+
|
|
468
|
+
path.scan(/:[a-z_][a-z0-9_]*/).size
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Shared with `Context::Inflector#singularize` — kept in
|
|
472
|
+
# sync until one of the two call sites can adopt the
|
|
473
|
+
# other.
|
|
474
|
+
UNCOUNTABLE = %w[
|
|
475
|
+
equipment information rice money species series fish
|
|
476
|
+
sheep jeans police news media settings
|
|
477
|
+
].to_set.freeze
|
|
478
|
+
|
|
479
|
+
def singularize_word(word)
|
|
480
|
+
return word if UNCOUNTABLE.include?(word)
|
|
481
|
+
return "#{word.chomp('ies')}y" if word.end_with?("ies") && word.length > 3
|
|
482
|
+
return word.chomp("es") if word.end_with?("ses") || word.end_with?("xes")
|
|
483
|
+
return word.chomp("s") if word.end_with?("s")
|
|
484
|
+
|
|
485
|
+
word
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "rails_routes/helper_table"
|
|
6
|
+
require_relative "rails_routes/routes_parser"
|
|
7
|
+
require_relative "rails_routes/analyzer"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
module Plugin
|
|
11
|
+
# rigor-rails-routes — validates Rails route-helper calls
|
|
12
|
+
# (`users_path`, `edit_user_path(@user)`, …) against the
|
|
13
|
+
# project's `config/routes.rb`.
|
|
14
|
+
#
|
|
15
|
+
# Tier 1A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
16
|
+
# Statically interprets the routes DSL via Prism — no
|
|
17
|
+
# `rails` runtime dependency. Recognised v0.1.0 surface:
|
|
18
|
+
#
|
|
19
|
+
# - `Rails.application.routes.draw do ... end`
|
|
20
|
+
# - `resources :name [, only: [...] | except: [...]]`
|
|
21
|
+
# - `resource :name`
|
|
22
|
+
# - `get/post/patch/put/delete "/path", to:, as:`
|
|
23
|
+
# - `root to: "..."` / `root "..."`
|
|
24
|
+
# - One level of `namespace :foo do ... end`
|
|
25
|
+
# - One level of nested `resources`
|
|
26
|
+
#
|
|
27
|
+
# The plugin publishes its parsed `:helper_table` through
|
|
28
|
+
# the ADR-9 cross-plugin fact store so future
|
|
29
|
+
# `rigor-actionpack` Phase 4 can consume it for
|
|
30
|
+
# route-helper validation in controller code.
|
|
31
|
+
#
|
|
32
|
+
# ## Configuration
|
|
33
|
+
#
|
|
34
|
+
# plugins:
|
|
35
|
+
# - gem: rigor-rails-routes
|
|
36
|
+
# config:
|
|
37
|
+
# routes_file: config/routes.rb # default; optional
|
|
38
|
+
#
|
|
39
|
+
# ## Limitations (v0.1.0)
|
|
40
|
+
#
|
|
41
|
+
# - `scope :path:` / `scope :module:` / `scope :as:` are
|
|
42
|
+
# not interpreted — helpers nested inside these
|
|
43
|
+
# constructs are silently skipped.
|
|
44
|
+
# - Constraints / format restrictions / mountable
|
|
45
|
+
# engines are out of scope.
|
|
46
|
+
# - The English inflector is intentionally tiny: it
|
|
47
|
+
# handles `posts` ↔ `post`, `users` ↔ `user`,
|
|
48
|
+
# `categories` ↔ `category`, `boxes` ↔ `box`. Custom
|
|
49
|
+
# inflections (`fish` ↔ `fish`, `child` ↔ `children`)
|
|
50
|
+
# are out of scope; users who need them ship a hand-
|
|
51
|
+
# written RBS for the affected helper.
|
|
52
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
53
|
+
manifest(
|
|
54
|
+
id: "rails-routes",
|
|
55
|
+
version: "0.1.0",
|
|
56
|
+
description: "Validates Rails route-helper calls against `config/routes.rb`.",
|
|
57
|
+
config_schema: {
|
|
58
|
+
"routes_file" => :string
|
|
59
|
+
},
|
|
60
|
+
produces: [:helper_table]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
DEFAULT_ROUTES_FILE = "config/routes.rb"
|
|
64
|
+
|
|
65
|
+
# Cached producer — reads `config/routes.rb` through
|
|
66
|
+
# the trusted `IoBoundary` and parses through
|
|
67
|
+
# {RoutesParser}. The descriptor's auto-collected
|
|
68
|
+
# `FileEntry` digest invalidates the cache on routes-
|
|
69
|
+
# file edits.
|
|
70
|
+
producer :helper_table do |_params|
|
|
71
|
+
contents = io_boundary.read_file(@routes_file)
|
|
72
|
+
RoutesParser.parse(contents)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def init(_services)
|
|
76
|
+
@routes_file = config.fetch("routes_file", DEFAULT_ROUTES_FILE)
|
|
77
|
+
@helper_table = nil
|
|
78
|
+
@load_error = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Publishes the parsed table to the cross-plugin fact
|
|
82
|
+
# store so future Tier 2 plugins (rigor-actionpack
|
|
83
|
+
# Phase 4) can read it via `services.fact_store.read`.
|
|
84
|
+
def prepare(services)
|
|
85
|
+
table = helper_table_or_nil
|
|
86
|
+
return if table.nil?
|
|
87
|
+
|
|
88
|
+
services.fact_store.publish(
|
|
89
|
+
plugin_id: manifest.id,
|
|
90
|
+
name: :helper_table,
|
|
91
|
+
value: table.to_h
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
96
|
+
table = helper_table_or_nil
|
|
97
|
+
if table.nil? && @load_error
|
|
98
|
+
return [] if @load_error_emitted
|
|
99
|
+
|
|
100
|
+
@load_error_emitted = true
|
|
101
|
+
return [load_error_diagnostic(path)]
|
|
102
|
+
end
|
|
103
|
+
return [] if table.nil? || table.empty?
|
|
104
|
+
|
|
105
|
+
Analyzer.diagnose(path: path, root: root, helper_table: table)
|
|
106
|
+
.map { |diag| build_diagnostic(diag) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# The load-error path used to emit the same warning on
|
|
112
|
+
# every analyzed file in the project. On large monorepos
|
|
113
|
+
# (Mastodon: 1,302 files; Solidus: ~1,000 files) and on
|
|
114
|
+
# legacy projects without a top-level `config/routes.rb`,
|
|
115
|
+
# this multiplied a single root cause into 1,000+
|
|
116
|
+
# identical diagnostics. The error is project-global —
|
|
117
|
+
# report it once per run.
|
|
118
|
+
|
|
119
|
+
def helper_table_or_nil
|
|
120
|
+
return @helper_table if @helper_table
|
|
121
|
+
|
|
122
|
+
# Read first so the IoBoundary's FileEntry digest
|
|
123
|
+
# captures into the descriptor before `cache_for`
|
|
124
|
+
# snapshots it (the same pattern documented in
|
|
125
|
+
# rigor-routes / rigor-activerecord).
|
|
126
|
+
io_boundary.read_file(@routes_file)
|
|
127
|
+
@helper_table = cache_for(:helper_table, params: {}).call
|
|
128
|
+
rescue Plugin::AccessDeniedError => e
|
|
129
|
+
@load_error = "rigor-rails-routes: #{e.message}"
|
|
130
|
+
nil
|
|
131
|
+
rescue Errno::ENOENT
|
|
132
|
+
@load_error = "rigor-rails-routes: routes file `#{@routes_file}` not found; route checks skipped"
|
|
133
|
+
nil
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
@load_error = "rigor-rails-routes: failed to parse `#{@routes_file}`: #{e.class}: #{e.message}"
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def load_error_diagnostic(path)
|
|
140
|
+
Rigor::Analysis::Diagnostic.new(
|
|
141
|
+
path: path, line: 1, column: 1,
|
|
142
|
+
message: @load_error,
|
|
143
|
+
severity: :warning,
|
|
144
|
+
rule: "load-error"
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_diagnostic(diag)
|
|
149
|
+
Rigor::Analysis::Diagnostic.new(
|
|
150
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
151
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Rigor::Plugin.register(RailsRoutes)
|
|
157
|
+
end
|
|
158
|
+
end
|