rigortype 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/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/baseline_command.rb +4 -3
- data/lib/rigor/cli/plugins_command.rb +308 -0
- data/lib/rigor/cli/plugins_renderer.rb +173 -0
- data/lib/rigor/cli.rb +44 -3
- 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 +181 -4
- 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 +199 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -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 +283 -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 +250 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -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 +37 -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 +478 -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 +353 -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 +175 -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 +350 -0
- 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 +164 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -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/scope.rbs +22 -0
- metadata +157 -1
|
@@ -0,0 +1,235 @@
|
|
|
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/helper_discoverer"
|
|
8
|
+
require_relative "rails_routes/analyzer"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Plugin
|
|
12
|
+
# rigor-rails-routes — validates Rails route-helper calls
|
|
13
|
+
# (`users_path`, `edit_user_path(@user)`, …) against the
|
|
14
|
+
# project's `config/routes.rb`.
|
|
15
|
+
#
|
|
16
|
+
# Tier 1A of the [Rails plugins roadmap](../../../../docs/design/20260508-rails-plugins-roadmap.md).
|
|
17
|
+
# Statically interprets the routes DSL via Prism — no
|
|
18
|
+
# `rails` runtime dependency. Recognised v0.1.0 surface:
|
|
19
|
+
#
|
|
20
|
+
# - `Rails.application.routes.draw do ... end`
|
|
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`
|
|
27
|
+
#
|
|
28
|
+
# The plugin publishes its parsed `:helper_table` through
|
|
29
|
+
# the ADR-9 cross-plugin fact store so future
|
|
30
|
+
# `rigor-actionpack` Phase 4 can consume it for
|
|
31
|
+
# route-helper validation in controller code.
|
|
32
|
+
#
|
|
33
|
+
# ## Configuration
|
|
34
|
+
#
|
|
35
|
+
# plugins:
|
|
36
|
+
# - gem: rigor-rails-routes
|
|
37
|
+
# config:
|
|
38
|
+
# routes_file: config/routes.rb # default; optional
|
|
39
|
+
#
|
|
40
|
+
# ## Limitations (v0.1.0)
|
|
41
|
+
#
|
|
42
|
+
# - `scope :path:` / `scope :module:` / `scope :as:` are
|
|
43
|
+
# not interpreted — helpers nested inside these
|
|
44
|
+
# constructs are silently skipped.
|
|
45
|
+
# - Constraints / format restrictions / mountable
|
|
46
|
+
# engines are out of scope.
|
|
47
|
+
# - The English inflector is intentionally tiny: it
|
|
48
|
+
# handles `posts` ↔ `post`, `users` ↔ `user`,
|
|
49
|
+
# `categories` ↔ `category`, `boxes` ↔ `box`. Custom
|
|
50
|
+
# inflections (`fish` ↔ `fish`, `child` ↔ `children`)
|
|
51
|
+
# are out of scope; users who need them ship a hand-
|
|
52
|
+
# written RBS for the affected helper.
|
|
53
|
+
class RailsRoutes < Rigor::Plugin::Base
|
|
54
|
+
manifest(
|
|
55
|
+
id: "rails-routes",
|
|
56
|
+
# Bumped 2026-05-28 — GitLab FOSS sweep adds: (a)
|
|
57
|
+
# `draw_all :name` support (action_dispatch-draw_all
|
|
58
|
+
# gem; single-file load semantics matching `draw :name`);
|
|
59
|
+
# (b) keyword-style `scope(path: ':project_id',
|
|
60
|
+
# as: :project)` — path read from the `:path` keyword,
|
|
61
|
+
# not only from the positional first arg; (c) detection
|
|
62
|
+
# of iterative `direct(name.sub(FROM, TO)) do ... end`
|
|
63
|
+
# alias-generation idiom — generates `<TO>_*` aliases
|
|
64
|
+
# for every registered `<FROM>_*` helper. GitLab uses
|
|
65
|
+
# this to shorten `namespace_project_*` → `project_*`.
|
|
66
|
+
version: "0.27.0",
|
|
67
|
+
description: "Validates Rails route-helper calls against `config/routes.rb`.",
|
|
68
|
+
config_schema: {
|
|
69
|
+
"routes_file" => :string,
|
|
70
|
+
"helper_paths" => :array
|
|
71
|
+
},
|
|
72
|
+
produces: [:helper_table]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
DEFAULT_ROUTES_FILE = "config/routes.rb"
|
|
76
|
+
|
|
77
|
+
# The directories `HelperDiscoverer` walks for project-
|
|
78
|
+
# defined `*_path` / `*_url` methods. Default to the whole
|
|
79
|
+
# `app/` tree — the suffix filter inside the discoverer
|
|
80
|
+
# keeps the registered set tight, and real-world Rails
|
|
81
|
+
# apps routinely keep URL builders under `app/controllers`
|
|
82
|
+
# (private `def page_url`, `def callback_url` shapes),
|
|
83
|
+
# `app/lib` (Mastodon's `TranslationService::DeepL#base_url`),
|
|
84
|
+
# `app/services` (`SoftwareUpdateCheckService#api_url`),
|
|
85
|
+
# `app/serializers`, `app/presenters`, `app/decorators`,
|
|
86
|
+
# not only `app/helpers/`. Walking the whole tree is the
|
|
87
|
+
# honest answer to "does this `_path` / `_url` name exist
|
|
88
|
+
# anywhere in the project?"; the cost is a one-time Prism
|
|
89
|
+
# parse per file at startup, which is bounded.
|
|
90
|
+
DEFAULT_HELPER_PATHS = ["app"].freeze
|
|
91
|
+
|
|
92
|
+
# Cached producer — reads `config/routes.rb` through
|
|
93
|
+
# the trusted `IoBoundary` and parses through
|
|
94
|
+
# {RoutesParser}. The descriptor's auto-collected
|
|
95
|
+
# `FileEntry` digest invalidates the cache on routes-
|
|
96
|
+
# file edits.
|
|
97
|
+
#
|
|
98
|
+
# Passes a `file_reader` lambda so the parser can follow
|
|
99
|
+
# `draw(:admin)` → `config/routes/admin.rb` partials.
|
|
100
|
+
producer :helper_table do |_params|
|
|
101
|
+
routes_dir = "#{File.dirname(@routes_file)}/routes"
|
|
102
|
+
file_reader = lambda do |name|
|
|
103
|
+
io_boundary.read_file("#{routes_dir}/#{name}")
|
|
104
|
+
rescue StandardError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
contents = io_boundary.read_file(@routes_file)
|
|
108
|
+
custom_helpers = discover_custom_helpers
|
|
109
|
+
RoutesParser.parse(contents, file_reader: file_reader, custom_helpers: custom_helpers)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def init(_services)
|
|
113
|
+
@routes_file = config.fetch("routes_file", DEFAULT_ROUTES_FILE)
|
|
114
|
+
@helper_paths = Array(config.fetch("helper_paths", DEFAULT_HELPER_PATHS)).map(&:to_s)
|
|
115
|
+
@helper_table = nil
|
|
116
|
+
@load_error = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Walks every configured `helper_paths:` directory
|
|
120
|
+
# through the trusted `IoBoundary` and returns the set
|
|
121
|
+
# of project-defined `*_path` / `*_url` method names
|
|
122
|
+
# for {HelperDiscoverer}. Each file digest is captured
|
|
123
|
+
# by the boundary so editing a helper file invalidates
|
|
124
|
+
# the `:helper_table` cache automatically. Returns the
|
|
125
|
+
# empty set when nothing under `helper_paths:` exists —
|
|
126
|
+
# the routes table still works.
|
|
127
|
+
def discover_custom_helpers
|
|
128
|
+
contents_per_path = {}
|
|
129
|
+
each_helper_file do |path|
|
|
130
|
+
contents_per_path[path] = io_boundary.read_file(path)
|
|
131
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
132
|
+
next
|
|
133
|
+
end
|
|
134
|
+
HelperDiscoverer.discover(contents_per_path)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def pre_read_helper_files
|
|
138
|
+
each_helper_file do |path|
|
|
139
|
+
io_boundary.read_file(path)
|
|
140
|
+
rescue Plugin::AccessDeniedError, Errno::ENOENT
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def each_helper_file(&)
|
|
146
|
+
@helper_paths.each do |dir|
|
|
147
|
+
absolute = File.expand_path(dir)
|
|
148
|
+
next unless File.directory?(absolute)
|
|
149
|
+
|
|
150
|
+
Dir.glob(File.join(absolute, "**", "*.rb")).each(&)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Publishes the parsed table to the cross-plugin fact
|
|
155
|
+
# store so future Tier 2 plugins (rigor-actionpack
|
|
156
|
+
# Phase 4) can read it via `services.fact_store.read`.
|
|
157
|
+
def prepare(services)
|
|
158
|
+
table = helper_table_or_nil
|
|
159
|
+
return if table.nil?
|
|
160
|
+
|
|
161
|
+
services.fact_store.publish(
|
|
162
|
+
plugin_id: manifest.id,
|
|
163
|
+
name: :helper_table,
|
|
164
|
+
value: table.to_h
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
169
|
+
table = helper_table_or_nil
|
|
170
|
+
if table.nil? && @load_error
|
|
171
|
+
return [] if @load_error_emitted
|
|
172
|
+
|
|
173
|
+
@load_error_emitted = true
|
|
174
|
+
return [load_error_diagnostic(path)]
|
|
175
|
+
end
|
|
176
|
+
return [] if table.nil? || table.empty?
|
|
177
|
+
|
|
178
|
+
Analyzer.diagnose(path: path, root: root, helper_table: table)
|
|
179
|
+
.map { |diag| build_diagnostic(diag) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# The load-error path used to emit the same warning on
|
|
185
|
+
# every analyzed file in the project. On large monorepos
|
|
186
|
+
# (Mastodon: 1,302 files; Solidus: ~1,000 files) and on
|
|
187
|
+
# legacy projects without a top-level `config/routes.rb`,
|
|
188
|
+
# this multiplied a single root cause into 1,000+
|
|
189
|
+
# identical diagnostics. The error is project-global —
|
|
190
|
+
# report it once per run.
|
|
191
|
+
|
|
192
|
+
def helper_table_or_nil
|
|
193
|
+
return @helper_table if @helper_table
|
|
194
|
+
|
|
195
|
+
# Read first so the IoBoundary's FileEntry digest
|
|
196
|
+
# captures into the descriptor before `cache_for`
|
|
197
|
+
# snapshots it (the same pattern documented in
|
|
198
|
+
# rigor-routes / rigor-activerecord). Helper files are
|
|
199
|
+
# pre-read for the same reason — editing a file under
|
|
200
|
+
# `app/helpers/` MUST invalidate the helper_table cache
|
|
201
|
+
# so the new custom-helper set is picked up.
|
|
202
|
+
io_boundary.read_file(@routes_file)
|
|
203
|
+
pre_read_helper_files
|
|
204
|
+
@helper_table = cache_for(:helper_table, params: {}).call
|
|
205
|
+
rescue Plugin::AccessDeniedError => e
|
|
206
|
+
@load_error = "rigor-rails-routes: #{e.message}"
|
|
207
|
+
nil
|
|
208
|
+
rescue Errno::ENOENT
|
|
209
|
+
@load_error = "rigor-rails-routes: routes file `#{@routes_file}` not found; route checks skipped"
|
|
210
|
+
nil
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
@load_error = "rigor-rails-routes: failed to parse `#{@routes_file}`: #{e.class}: #{e.message}"
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def load_error_diagnostic(path)
|
|
217
|
+
Rigor::Analysis::Diagnostic.new(
|
|
218
|
+
path: path, line: 1, column: 1,
|
|
219
|
+
message: @load_error,
|
|
220
|
+
severity: :warning,
|
|
221
|
+
rule: "load-error"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def build_diagnostic(diag)
|
|
226
|
+
Rigor::Analysis::Diagnostic.new(
|
|
227
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
228
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
Rigor::Plugin.register(RailsRoutes)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
# ADR-32 — bundled `rigor-rbs-inline` plugin.
|
|
6
|
+
#
|
|
7
|
+
# Synthesises RBS from project Ruby files that carry
|
|
8
|
+
# rbs-inline-shaped comments (`#: () -> T`, `# @rbs name: T`,
|
|
9
|
+
# `# @rbs return: T`, attribute `#:`, …) and contributes the
|
|
10
|
+
# result to the analysis environment through the
|
|
11
|
+
# `source_rbs_synthesizer:` manifest hook.
|
|
12
|
+
#
|
|
13
|
+
# By default (WD2) only files starting with the upstream
|
|
14
|
+
# `# rbs_inline: enabled` magic comment are processed; a host
|
|
15
|
+
# context can flip this off by setting `require_magic_comment:
|
|
16
|
+
# false` in the plugin config (WD10).
|
|
17
|
+
module Rigor
|
|
18
|
+
module Plugin
|
|
19
|
+
# The plugin gem requires `rbs/inline` at load time; without
|
|
20
|
+
# the upstream library the synthesizer can't do its job.
|
|
21
|
+
# Wrapped in a begin/rescue so the analyzer still loads if
|
|
22
|
+
# the user activated this plugin without installing the
|
|
23
|
+
# `rbs-inline` gem (loud-on-activation, fail-soft to no
|
|
24
|
+
# contribution).
|
|
25
|
+
begin
|
|
26
|
+
require "prism"
|
|
27
|
+
require "rbs/inline"
|
|
28
|
+
RBS_INLINE_AVAILABLE = true
|
|
29
|
+
rescue ::LoadError => e
|
|
30
|
+
warn(
|
|
31
|
+
"rigor-rbs-inline: failed to load `rbs/inline` " \
|
|
32
|
+
"(#{e.message}). The plugin will load but contribute no " \
|
|
33
|
+
"synthesised RBS. Install the `rbs-inline` gem to enable " \
|
|
34
|
+
"inline-RBS comment ingestion."
|
|
35
|
+
)
|
|
36
|
+
RBS_INLINE_AVAILABLE = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class RbsInline < Rigor::Plugin::Base
|
|
40
|
+
# Synthesizer callable invoked once per project Ruby
|
|
41
|
+
# source file by `Environment.for_project` at env-build
|
|
42
|
+
# time. Returns the synthesised RBS source as a String,
|
|
43
|
+
# or `nil` when the file contributes nothing (no magic
|
|
44
|
+
# comment in the default mode, empty annotation set,
|
|
45
|
+
# parse error per WD6).
|
|
46
|
+
class Synthesizer
|
|
47
|
+
# @param require_magic_comment [Boolean] when `true`
|
|
48
|
+
# (the default, WD2), only files with
|
|
49
|
+
# `# rbs_inline: enabled` at the top are processed.
|
|
50
|
+
# When `false` (WD10 host-context override), every
|
|
51
|
+
# file is treated as if it carried the magic comment.
|
|
52
|
+
def initialize(require_magic_comment:)
|
|
53
|
+
@require_magic_comment = require_magic_comment
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Return value contract:
|
|
58
|
+
# - `String` (non-empty) → successful synthesis
|
|
59
|
+
# - `nil` → no contribution
|
|
60
|
+
# - `[:error, message_string]` → parse failed, surface
|
|
61
|
+
# info diagnostic per ADR-32 WD6
|
|
62
|
+
def call(source_file_path)
|
|
63
|
+
return nil unless RBS_INLINE_AVAILABLE
|
|
64
|
+
return nil unless File.file?(source_file_path)
|
|
65
|
+
|
|
66
|
+
source = File.read(source_file_path)
|
|
67
|
+
return nil if source.empty?
|
|
68
|
+
|
|
69
|
+
result = ::Prism.parse(source)
|
|
70
|
+
# `opt_in: true` is rbs-inline's "require the magic
|
|
71
|
+
# comment" mode (per upstream parser.rb:62). The
|
|
72
|
+
# plugin's `require_magic_comment:` config knob maps
|
|
73
|
+
# directly onto it.
|
|
74
|
+
parsed = ::RBS::Inline::Parser.parse(result, opt_in: @require_magic_comment)
|
|
75
|
+
return nil if parsed.nil?
|
|
76
|
+
|
|
77
|
+
uses, decls, rbs_decls = parsed
|
|
78
|
+
rendered = ::RBS::Inline::Writer.write(uses, decls, rbs_decls)
|
|
79
|
+
return nil if rendered.nil? || rendered.strip.empty?
|
|
80
|
+
|
|
81
|
+
rendered
|
|
82
|
+
rescue ::StandardError => e
|
|
83
|
+
# WD6 fail-soft — surface a structured error tuple so
|
|
84
|
+
# the engine's `Environment.for_project` can emit a
|
|
85
|
+
# `source-rbs-synthesis-failed` info diagnostic
|
|
86
|
+
# naming the file + the upstream error message,
|
|
87
|
+
# without crashing analysis.
|
|
88
|
+
[:error, "#{e.class}: #{e.message.to_s.lines.first.to_s.strip}"]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
manifest(
|
|
93
|
+
id: "rbs-inline",
|
|
94
|
+
version: "0.1.0",
|
|
95
|
+
description: "Ingests rbs-inline-shaped comments " \
|
|
96
|
+
"(`# @rbs name: T`, `#: () -> T`, …) as RBS contributions.",
|
|
97
|
+
config_schema: { "require_magic_comment" => :boolean },
|
|
98
|
+
source_rbs_synthesizer: nil # set per-instance below
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Per-instance synthesizer — built from the manifest's
|
|
102
|
+
# default + the project's plugin config. The manifest
|
|
103
|
+
# `source_rbs_synthesizer:` is nil at the class level so
|
|
104
|
+
# the registry sees the instance's override (returned by
|
|
105
|
+
# `#manifest`, which `Plugin::Registry#source_rbs_synthesizers`
|
|
106
|
+
# consults via `plugin.manifest.source_rbs_synthesizer`).
|
|
107
|
+
#
|
|
108
|
+
# ADR-32 WD10 — `require_magic_comment` defaults to
|
|
109
|
+
# `true`. Setting it to `false` in `.rigor.yml` flips the
|
|
110
|
+
# synthesizer into "process every file" mode.
|
|
111
|
+
def initialize(services:, config: {})
|
|
112
|
+
super
|
|
113
|
+
@require_magic_comment = config.fetch("require_magic_comment", true) ? true : false
|
|
114
|
+
@synthesizer = Synthesizer.new(require_magic_comment: @require_magic_comment)
|
|
115
|
+
# Build the per-instance manifest eagerly (before
|
|
116
|
+
# `freeze`) so the registry's repeated reads return
|
|
117
|
+
# the same object and we don't need to mutate a
|
|
118
|
+
# frozen instance later.
|
|
119
|
+
base = self.class.manifest
|
|
120
|
+
@manifest_with_synth = build_manifest_with_synthesizer(base)
|
|
121
|
+
freeze
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
attr_reader :synthesizer
|
|
125
|
+
|
|
126
|
+
# Override the manifest-level `source_rbs_synthesizer:`
|
|
127
|
+
# (which is nil at the class level) with the per-instance
|
|
128
|
+
# synthesizer built from the merged config. The registry
|
|
129
|
+
# reads this through `plugin.manifest.source_rbs_synthesizer`.
|
|
130
|
+
def manifest
|
|
131
|
+
@manifest_with_synth
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def build_manifest_with_synthesizer(base)
|
|
137
|
+
Rigor::Plugin::Manifest.new(
|
|
138
|
+
id: base.id,
|
|
139
|
+
version: base.version,
|
|
140
|
+
description: base.description,
|
|
141
|
+
protocols: base.protocols,
|
|
142
|
+
config_schema: base.config_schema,
|
|
143
|
+
produces: base.produces,
|
|
144
|
+
consumes: base.consumes,
|
|
145
|
+
owns_receivers: base.owns_receivers,
|
|
146
|
+
open_receivers: base.open_receivers,
|
|
147
|
+
type_node_resolvers: base.type_node_resolvers,
|
|
148
|
+
block_as_methods: base.block_as_methods,
|
|
149
|
+
heredoc_templates: base.heredoc_templates,
|
|
150
|
+
trait_registries: base.trait_registries,
|
|
151
|
+
external_files: base.external_files,
|
|
152
|
+
hkt_registrations: base.hkt_registrations,
|
|
153
|
+
hkt_definitions: base.hkt_definitions,
|
|
154
|
+
signature_paths: base.signature_paths,
|
|
155
|
+
protocol_contracts: base.protocol_contracts,
|
|
156
|
+
source_rbs_synthesizer: @synthesizer
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Rigor::Plugin.register(RbsInline)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rigor-rbs-inline — ingests rbs-inline-shaped comments
|
|
4
|
+
# (`# @rbs name: T`, `#: () -> T`, `# @rbs return: T`, attribute
|
|
5
|
+
# `#:`, ivars, generics, override, …) as RBS contributions to
|
|
6
|
+
# the analysis environment.
|
|
7
|
+
#
|
|
8
|
+
# Per ADR-32 the plugin gates per file on the upstream
|
|
9
|
+
# `# rbs_inline: enabled` magic comment by default. A host
|
|
10
|
+
# context that owns the entire analysis scope (e.g. the
|
|
11
|
+
# ADR-29 browser playground) can set `require_magic_comment:
|
|
12
|
+
# false` in the plugin config to skip the gate — every file
|
|
13
|
+
# the synthesizer sees is treated as if it carried the magic
|
|
14
|
+
# comment.
|
|
15
|
+
#
|
|
16
|
+
# # .rigor.yml
|
|
17
|
+
# plugins:
|
|
18
|
+
# - id: rigor-rbs-inline
|
|
19
|
+
# config:
|
|
20
|
+
# require_magic_comment: false # default true
|
|
21
|
+
#
|
|
22
|
+
# The plugin depends on the upstream `rbs-inline` gem; Rigor's
|
|
23
|
+
# core `rigortype` gemspec stays zero-dep per ADR-0 / ADR-32 WD1.
|
|
24
|
+
require_relative "rigor/plugin/rbs_inline"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "scope_walker"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
class Rspec < Rigor::Plugin::Base
|
|
10
|
+
# Per-file walker that:
|
|
11
|
+
#
|
|
12
|
+
# 1. Collects every RSpec scope (each `RSpec.describe`
|
|
13
|
+
# plus its nested `describe` / `context` blocks)
|
|
14
|
+
# via {ScopeWalker}.
|
|
15
|
+
# 2. Reports duplicate `let(:name)` / `subject(:name)`
|
|
16
|
+
# declarations within the same scope (the second
|
|
17
|
+
# declaration wins at runtime — an easy
|
|
18
|
+
# copy-paste bug).
|
|
19
|
+
# 3. Reports recursive self-references —
|
|
20
|
+
# `let(:user) { user.something }` will infinite-loop
|
|
21
|
+
# at runtime — an easy oversight.
|
|
22
|
+
module Analyzer
|
|
23
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param path [String]
|
|
28
|
+
# @param root [Prism::Node]
|
|
29
|
+
# @return [Array<Diagnostic>]
|
|
30
|
+
def diagnose(path:, root:)
|
|
31
|
+
diagnostics = []
|
|
32
|
+
ScopeWalker.collect_scopes(root).each do |outer|
|
|
33
|
+
ScopeWalker.each_scope(outer) do |scope|
|
|
34
|
+
diagnostics.concat(duplicate_diagnostics(path, scope))
|
|
35
|
+
diagnostics.concat(self_reference_diagnostics(path, scope))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
diagnostics
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def duplicate_diagnostics(path, scope)
|
|
42
|
+
counts = Hash.new { |h, k| h[k] = [] }
|
|
43
|
+
scope.declarations.each { |decl| counts[decl.name] << decl }
|
|
44
|
+
counts.flat_map do |name, decls|
|
|
45
|
+
next [] if decls.size < 2
|
|
46
|
+
|
|
47
|
+
duplicate_diagnostics_for(path, name, decls)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def duplicate_diagnostics_for(path, name, decls)
|
|
52
|
+
# Report each subsequent occurrence; the first
|
|
53
|
+
# one is the "winner" only by literal source
|
|
54
|
+
# order, but RSpec lets the LAST declaration win
|
|
55
|
+
# at runtime, so flag everything after the first
|
|
56
|
+
# so the user can see the full list.
|
|
57
|
+
decls.drop(1).map do |decl|
|
|
58
|
+
Diagnostic.new(
|
|
59
|
+
path: path,
|
|
60
|
+
line: decl.location.start_line,
|
|
61
|
+
column: decl.location.start_column + 1,
|
|
62
|
+
severity: :warning,
|
|
63
|
+
rule: "duplicate-let",
|
|
64
|
+
message: "duplicate `#{decl.kind}(:#{name})` in this scope " \
|
|
65
|
+
"(first declared at line #{decls.first.location.start_line}); " \
|
|
66
|
+
"the last declaration wins at runtime"
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self_reference_diagnostics(path, scope)
|
|
72
|
+
scope.declarations.flat_map do |decl|
|
|
73
|
+
next [] unless self_references?(decl)
|
|
74
|
+
|
|
75
|
+
[self_reference_diagnostic(path, decl)]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Walks the declaration's block body looking for a
|
|
80
|
+
# call to its own name with no explicit receiver.
|
|
81
|
+
# Returns true if at least one such call exists.
|
|
82
|
+
def self_references?(decl)
|
|
83
|
+
body = decl.block_node&.body
|
|
84
|
+
return false if body.nil?
|
|
85
|
+
|
|
86
|
+
contains_self_reference?(body, decl.name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def contains_self_reference?(node, name)
|
|
90
|
+
return false unless node.is_a?(Prism::Node)
|
|
91
|
+
return true if node.is_a?(Prism::CallNode) && node.name == name && node.receiver.nil?
|
|
92
|
+
|
|
93
|
+
node.compact_child_nodes.any? { |child| contains_self_reference?(child, name) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self_reference_diagnostic(path, decl)
|
|
97
|
+
Diagnostic.new(
|
|
98
|
+
path: path,
|
|
99
|
+
line: decl.location.start_line,
|
|
100
|
+
column: decl.location.start_column + 1,
|
|
101
|
+
severity: :error,
|
|
102
|
+
rule: "self-reference",
|
|
103
|
+
message: "`#{decl.kind}(:#{decl.name})` references its own name `#{decl.name}` — " \
|
|
104
|
+
"this will infinite-loop at runtime"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|