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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/baseline.rb +51 -15
  3. data/lib/rigor/analysis/erb_template_detector.rb +38 -0
  4. data/lib/rigor/analysis/runner.rb +6 -1
  5. data/lib/rigor/analysis/worker_session.rb +6 -1
  6. data/lib/rigor/cli/baseline_command.rb +4 -3
  7. data/lib/rigor/cli/plugins_command.rb +308 -0
  8. data/lib/rigor/cli/plugins_renderer.rb +173 -0
  9. data/lib/rigor/cli.rb +44 -3
  10. data/lib/rigor/inference/block_parameter_binder.rb +35 -0
  11. data/lib/rigor/inference/expression_typer.rb +69 -30
  12. data/lib/rigor/inference/indexed_narrowing.rb +187 -0
  13. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +24 -0
  14. data/lib/rigor/inference/method_dispatcher.rb +23 -0
  15. data/lib/rigor/inference/mutation_widening.rb +285 -0
  16. data/lib/rigor/inference/narrowing.rb +72 -4
  17. data/lib/rigor/inference/scope_indexer.rb +409 -12
  18. data/lib/rigor/inference/statement_evaluator.rb +256 -4
  19. data/lib/rigor/scope.rb +181 -4
  20. data/lib/rigor/version.rb +1 -1
  21. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  22. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  23. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  24. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  25. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  26. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +199 -0
  27. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +398 -0
  28. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +86 -0
  29. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +183 -0
  30. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  31. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +713 -0
  32. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +201 -0
  33. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +226 -0
  34. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +261 -0
  35. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  36. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  37. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  38. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  39. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  40. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  41. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +283 -0
  42. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  43. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  44. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  45. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +250 -0
  46. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +98 -0
  47. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +590 -0
  48. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  49. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  50. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  53. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  54. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  55. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +37 -0
  56. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  57. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +478 -0
  58. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  59. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  60. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  61. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  62. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  63. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  64. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  65. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  66. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  67. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  68. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  69. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  70. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  71. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  72. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  73. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  74. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  75. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  76. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  77. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  78. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  79. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  80. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  81. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  82. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  83. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  84. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  85. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  86. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  87. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  88. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  89. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  90. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  91. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  92. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  93. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +353 -0
  94. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  96. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +175 -0
  97. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  98. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +350 -0
  99. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +264 -0
  100. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +100 -0
  101. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +175 -0
  102. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +164 -0
  103. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1538 -0
  104. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +235 -0
  105. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  106. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  107. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  108. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  109. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  110. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  111. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  112. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  113. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  114. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  115. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  116. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  117. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  118. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  119. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  120. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  121. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  122. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  123. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  124. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  125. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  126. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  127. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  128. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  129. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  130. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  131. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  132. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  133. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  134. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  135. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  136. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  137. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  138. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  139. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  140. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  141. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  142. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  143. data/sig/rigor/scope.rbs +22 -0
  144. 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/rails_routes"
@@ -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