rigortype 0.1.10 → 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/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli.rb +16 -3
- 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
- metadata +149 -1
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "activerecord/inflector"
|
|
6
|
+
require_relative "activerecord/schema_table"
|
|
7
|
+
require_relative "activerecord/schema_parser"
|
|
8
|
+
require_relative "activerecord/model_index"
|
|
9
|
+
require_relative "activerecord/model_discoverer"
|
|
10
|
+
require_relative "activerecord/analyzer"
|
|
11
|
+
|
|
12
|
+
module Rigor
|
|
13
|
+
module Plugin
|
|
14
|
+
# rigor-activerecord — types ActiveRecord finder + relation
|
|
15
|
+
# calls against the project's `db/schema.rb` and discovered
|
|
16
|
+
# AR model classes.
|
|
17
|
+
#
|
|
18
|
+
# ## Architecture
|
|
19
|
+
#
|
|
20
|
+
# Two cached producers per plugin run:
|
|
21
|
+
#
|
|
22
|
+
# 1. `:schema_table` reads `db/schema.rb` via the `IoBoundary`
|
|
23
|
+
# and parses it through {SchemaParser} into a
|
|
24
|
+
# {SchemaTable} mapping `table_name → { column_name →
|
|
25
|
+
# Column }`.
|
|
26
|
+
# 2. `:model_index` walks every `.rb` file under the
|
|
27
|
+
# configured `model_search_paths`, finds class declarations
|
|
28
|
+
# whose direct superclass is in `model_base_classes`, and
|
|
29
|
+
# composes them with the schema table into a {ModelIndex}.
|
|
30
|
+
#
|
|
31
|
+
# Both producers ride `Plugin::Base#cache_for`. The descriptor
|
|
32
|
+
# auto-includes the digests of every file the boundary read,
|
|
33
|
+
# so editing `db/schema.rb` or any model file invalidates
|
|
34
|
+
# exactly the right cache entry.
|
|
35
|
+
#
|
|
36
|
+
# The per-file `#diagnostics_for_file` hook delegates to
|
|
37
|
+
# {Analyzer}, which walks Prism and emits diagnostics for
|
|
38
|
+
# `Model.find` / `Model.find_by` / `Model.where` calls
|
|
39
|
+
# against the index.
|
|
40
|
+
#
|
|
41
|
+
# ## Configuration
|
|
42
|
+
#
|
|
43
|
+
# plugins:
|
|
44
|
+
# - gem: rigor-activerecord
|
|
45
|
+
# config:
|
|
46
|
+
# schema_file: "db/schema.rb"
|
|
47
|
+
# model_search_paths: ["app/models"]
|
|
48
|
+
# model_base_classes: ["ApplicationRecord", "ActiveRecord::Base"]
|
|
49
|
+
#
|
|
50
|
+
# All three keys default to the values shown above. The class
|
|
51
|
+
# name `Rigor::Plugin::Activerecord` (single capital R) is
|
|
52
|
+
# intentional — keeps the constant lookup distinct from
|
|
53
|
+
# `::ActiveRecord` even though the gem name is hyphenated.
|
|
54
|
+
#
|
|
55
|
+
# Note: this plugin is the seventh worked example. It does NOT
|
|
56
|
+
# require `active_record` at runtime — it only reads project
|
|
57
|
+
# source, the same way the other examples do. Rigor stays
|
|
58
|
+
# decoupled from Rails.
|
|
59
|
+
class Activerecord < Rigor::Plugin::Base
|
|
60
|
+
manifest(
|
|
61
|
+
id: "activerecord",
|
|
62
|
+
version: "0.1.0",
|
|
63
|
+
description: "Types ActiveRecord finders against the project's db/schema.rb and AR models.",
|
|
64
|
+
config_schema: {
|
|
65
|
+
"schema_file" => :string,
|
|
66
|
+
"model_search_paths" => :array,
|
|
67
|
+
"model_base_classes" => :array
|
|
68
|
+
},
|
|
69
|
+
produces: [:model_index],
|
|
70
|
+
# ADR-25 — the bundled `ActiveRecord::Relation` RBS, the
|
|
71
|
+
# type `flow_contribution_for`'s relation-typed call sites
|
|
72
|
+
# (`has_many` accessors, `Model.where`, scopes) dispatch
|
|
73
|
+
# against.
|
|
74
|
+
signature_paths: ["sig"],
|
|
75
|
+
# ADR-26 — `ActiveRecord::Relation` is an "open" receiver:
|
|
76
|
+
# it delegates an unbounded set of user-defined scopes /
|
|
77
|
+
# class methods to its model, so `call.undefined-method`
|
|
78
|
+
# must not fire for it. `CheckRules` reads this manifest
|
|
79
|
+
# field and skips the rule for the class.
|
|
80
|
+
open_receivers: ["ActiveRecord::Relation"]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
DEFAULT_SCHEMA_FILE = "db/schema.rb"
|
|
84
|
+
DEFAULT_MODEL_SEARCH_PATHS = ["app/models"].freeze
|
|
85
|
+
DEFAULT_MODEL_BASE_CLASSES = %w[ApplicationRecord ActiveRecord::Base].freeze
|
|
86
|
+
|
|
87
|
+
# The class the bundled `sig/active_record/relation.rbs`
|
|
88
|
+
# describes; `flow_contribution_for` contributes
|
|
89
|
+
# `ActiveRecord::Relation[Model]` for relation-returning
|
|
90
|
+
# call sites (`has_many` accessors, `Model.where`, scopes).
|
|
91
|
+
RELATION_CLASS_NAME = "ActiveRecord::Relation"
|
|
92
|
+
|
|
93
|
+
# Cached: parsed schema table. The producer reads `@schema_file`
|
|
94
|
+
# via `io_boundary.read_file` so the descriptor picks up the
|
|
95
|
+
# digest, then parses through {SchemaParser}.
|
|
96
|
+
producer :schema_table do |_params|
|
|
97
|
+
contents = io_boundary.read_file(@schema_file)
|
|
98
|
+
SchemaParser.parse(contents)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Cached: model index. Walks every model file, then composes
|
|
102
|
+
# the rows with the cached schema table.
|
|
103
|
+
producer :model_index do |_params|
|
|
104
|
+
rows = ModelDiscoverer.new(
|
|
105
|
+
io_boundary: io_boundary,
|
|
106
|
+
search_paths: @model_search_paths,
|
|
107
|
+
base_classes: @model_base_classes
|
|
108
|
+
).discover
|
|
109
|
+
ModelIndex.build(model_rows: rows, schema_table: schema_table_or_nil)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def init(_services)
|
|
113
|
+
@schema_file = config.fetch("schema_file", DEFAULT_SCHEMA_FILE)
|
|
114
|
+
@model_search_paths = Array(config.fetch("model_search_paths", DEFAULT_MODEL_SEARCH_PATHS)).map(&:to_s)
|
|
115
|
+
@model_base_classes = Array(config.fetch("model_base_classes", DEFAULT_MODEL_BASE_CLASSES)).map(&:to_s)
|
|
116
|
+
@schema_table = nil
|
|
117
|
+
@model_index = nil
|
|
118
|
+
@load_errors = []
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# ADR-9 cross-plugin publication. Builds the model index
|
|
122
|
+
# eagerly during the per-run `prepare(services)` pass and
|
|
123
|
+
# publishes a flat Hash form to the shared fact store so
|
|
124
|
+
# downstream Tier-2 consumers (rigor-actionpack Phase 1
|
|
125
|
+
# strong-parameter validation, rigor-factorybot Phase 1
|
|
126
|
+
# (c) attribute → column cross-check, future plugins
|
|
127
|
+
# that need to know "what columns does class `User`
|
|
128
|
+
# expose?") can read it without coupling to this
|
|
129
|
+
# plugin's carrier classes.
|
|
130
|
+
#
|
|
131
|
+
# The published shape:
|
|
132
|
+
#
|
|
133
|
+
# {
|
|
134
|
+
# "User" => { table: "users", columns: ["id", "name", "email"] },
|
|
135
|
+
# "Post" => { table: "posts", columns: ["id", "title", "body"] },
|
|
136
|
+
# ...
|
|
137
|
+
# }
|
|
138
|
+
#
|
|
139
|
+
# Consumers do `services.fact_store.read(plugin_id:
|
|
140
|
+
# "activerecord", name: :model_index)` and look up by
|
|
141
|
+
# class name. Discovery failures (missing schema,
|
|
142
|
+
# unparseable models) leave the fact unpublished — the
|
|
143
|
+
# consumer's own degrade path runs (typically a no-op).
|
|
144
|
+
def prepare(services)
|
|
145
|
+
index = model_index
|
|
146
|
+
return if index.nil? || index.empty?
|
|
147
|
+
|
|
148
|
+
services.fact_store.publish(
|
|
149
|
+
plugin_id: manifest.id,
|
|
150
|
+
name: :model_index,
|
|
151
|
+
value: index_to_published_hash(index)
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
156
|
+
index = model_index
|
|
157
|
+
if index.nil?
|
|
158
|
+
# Project-global error (missing `db/schema.rb`, parse
|
|
159
|
+
# failure, etc.) — emit once per run rather than once
|
|
160
|
+
# per analyzed file. On a Redmine-shape project that
|
|
161
|
+
# uses migrations only (no `schema.rb`), the old path
|
|
162
|
+
# produced 346 identical load-errors; on a Solidus
|
|
163
|
+
# monorepo (no top-level `schema.rb`), 999.
|
|
164
|
+
return [] if @load_errors_emitted
|
|
165
|
+
|
|
166
|
+
@load_errors_emitted = true
|
|
167
|
+
return load_error_diagnostics(path)
|
|
168
|
+
end
|
|
169
|
+
return [] if index.empty?
|
|
170
|
+
|
|
171
|
+
Analyzer.new(path: path, model_index: index).analyze(root).diagnostics
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# v0.1.2 — return-type contribution. `Model.find(id)`
|
|
175
|
+
# narrows the call site's return type to `Nominal[Model]`,
|
|
176
|
+
# so chained calls (`User.find(1).name`) resolve through
|
|
177
|
+
# the analyzer's normal dispatch instead of the RBS-level
|
|
178
|
+
# untyped fall-back. `Model.find_by(...)` narrows to
|
|
179
|
+
# `Nominal[Model] | nil` because Rails returns nil when no
|
|
180
|
+
# row matches. `where` / `find_or_*` are intentionally
|
|
181
|
+
# deferred — they return relations, and Rigor does not yet
|
|
182
|
+
# carry an Enumerable-backed relation shape that would be
|
|
183
|
+
# more precise than the RBS envelope.
|
|
184
|
+
def flow_contribution_for(call_node:, scope:)
|
|
185
|
+
return nil unless call_node.is_a?(Prism::CallNode)
|
|
186
|
+
return nil if call_node.receiver.nil?
|
|
187
|
+
|
|
188
|
+
index = model_index
|
|
189
|
+
return nil if index.nil? || index.empty?
|
|
190
|
+
|
|
191
|
+
return_type = class_call_return_type(call_node, index) ||
|
|
192
|
+
relation_call_return_type(call_node, scope, index) ||
|
|
193
|
+
instance_call_return_type(call_node, scope, index)
|
|
194
|
+
return nil if return_type.nil?
|
|
195
|
+
|
|
196
|
+
Rigor::FlowContribution.new(
|
|
197
|
+
return_type: return_type,
|
|
198
|
+
provenance: Rigor::FlowContribution::Provenance.new(
|
|
199
|
+
source_family: "plugin.#{manifest.id}",
|
|
200
|
+
plugin_id: manifest.id,
|
|
201
|
+
node: call_node,
|
|
202
|
+
descriptor: nil
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def class_call_return_type(call_node, index)
|
|
210
|
+
model_name = constant_receiver_name(call_node.receiver)
|
|
211
|
+
return nil if model_name.nil?
|
|
212
|
+
|
|
213
|
+
entry = index.find(model_name) || index.find("::#{model_name}")
|
|
214
|
+
return nil if entry.nil?
|
|
215
|
+
|
|
216
|
+
finder_return_type(call_node, entry) ||
|
|
217
|
+
class_scope_return_type(call_node, entry)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Class-side finders + the class-side relation entry points.
|
|
221
|
+
# `find` / `find_by!` return the model; `find_by` adds the
|
|
222
|
+
# `nil` arm; `where` / `all` / `order` / `limit` / `none`
|
|
223
|
+
# open a relation. The relation then carries its element
|
|
224
|
+
# type through any further chained query method via the
|
|
225
|
+
# bundled `ActiveRecord::Relation` RBS.
|
|
226
|
+
def finder_return_type(call_node, entry)
|
|
227
|
+
case call_node.name
|
|
228
|
+
when :find
|
|
229
|
+
return nil if call_argument_count(call_node).zero?
|
|
230
|
+
|
|
231
|
+
Rigor::Type::Combinator.nominal_of(entry.class_name)
|
|
232
|
+
when :find_by!
|
|
233
|
+
# The bang variant raises `RecordNotFound` instead of
|
|
234
|
+
# returning `nil`, so the result is non-nullable.
|
|
235
|
+
Rigor::Type::Combinator.nominal_of(entry.class_name)
|
|
236
|
+
when :find_by
|
|
237
|
+
Rigor::Type::Combinator.union(
|
|
238
|
+
Rigor::Type::Combinator.nominal_of(entry.class_name),
|
|
239
|
+
Rigor::Type::Combinator.constant_of(nil)
|
|
240
|
+
)
|
|
241
|
+
when :where, :all, :order, :limit, :none
|
|
242
|
+
relation_of(entry.class_name)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# `Post.published` / `Post.recent(5)` — a user-declared
|
|
247
|
+
# `scope` returns a relation of the model regardless of the
|
|
248
|
+
# arguments it takes.
|
|
249
|
+
def class_scope_return_type(call_node, entry)
|
|
250
|
+
return nil unless entry.scope?(call_node.name)
|
|
251
|
+
|
|
252
|
+
relation_of(entry.class_name)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# `ActiveRecord::Relation[Model]` — the type the bundled
|
|
256
|
+
# `sig/active_record/relation.rbs` describes. The class is
|
|
257
|
+
# declared `open_receivers` in the manifest, so a chained
|
|
258
|
+
# scope call the bundled RBS cannot enumerate does not
|
|
259
|
+
# surface as `call.undefined-method` (ADR-26).
|
|
260
|
+
def relation_of(model_class_name)
|
|
261
|
+
Rigor::Type::Combinator.nominal_of(
|
|
262
|
+
RELATION_CLASS_NAME,
|
|
263
|
+
type_args: [Rigor::Type::Combinator.nominal_of(model_class_name)]
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A scope invoked on an already-typed relation
|
|
268
|
+
# (`User.where(active: true).published`) keeps the relation
|
|
269
|
+
# type through the chain. The bundled `ActiveRecord::Relation`
|
|
270
|
+
# RBS cannot enumerate user-defined scopes, so without this
|
|
271
|
+
# the chain would lose its element type after the first
|
|
272
|
+
# scope call. Non-scope methods decline — the RBS tier
|
|
273
|
+
# resolves `where` / `order` / `each` / `first` precisely.
|
|
274
|
+
# Scopes may take arguments (`relation.recent(5)`), so —
|
|
275
|
+
# unlike `instance_call_return_type` — argument calls are
|
|
276
|
+
# not skipped.
|
|
277
|
+
#
|
|
278
|
+
# The cheap `scope_name?` pre-check is load-bearing: it
|
|
279
|
+
# gates the `scope.type_of(receiver)` call so the receiver
|
|
280
|
+
# type is computed ONLY when the method name could be a
|
|
281
|
+
# scope. `type_of` on a call receiver re-enters dispatch,
|
|
282
|
+
# and calling it for every call node in a long method chain
|
|
283
|
+
# is pathologically expensive — the pre-check keeps the
|
|
284
|
+
# cost off the hot path.
|
|
285
|
+
def relation_call_return_type(call_node, scope, index)
|
|
286
|
+
return nil if call_node.receiver.nil?
|
|
287
|
+
return nil unless scope_name?(call_node.name, index)
|
|
288
|
+
|
|
289
|
+
model_name = relation_element_class_name(scope.type_of(call_node.receiver))
|
|
290
|
+
return nil if model_name.nil?
|
|
291
|
+
|
|
292
|
+
entry = index.find(model_name) || index.find("::#{model_name}")
|
|
293
|
+
return nil if entry.nil?
|
|
294
|
+
return nil unless entry.scope?(call_node.name)
|
|
295
|
+
|
|
296
|
+
relation_of(model_name)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Whether `name` is a declared `scope` on ANY model in the
|
|
300
|
+
# index. A run-lifetime memoised Set so the per-call check
|
|
301
|
+
# in `relation_call_return_type` stays O(1).
|
|
302
|
+
def scope_name?(name, index)
|
|
303
|
+
@all_scope_names ||= index.entries.each_value.flat_map(&:scopes).to_set
|
|
304
|
+
@all_scope_names.include?(name.to_s)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# When `type` is `ActiveRecord::Relation[Nominal[Model]]`,
|
|
308
|
+
# returns the model class name; nil for any other type.
|
|
309
|
+
def relation_element_class_name(type)
|
|
310
|
+
return nil unless type.is_a?(Rigor::Type::Nominal)
|
|
311
|
+
return nil unless type.class_name == RELATION_CLASS_NAME
|
|
312
|
+
|
|
313
|
+
element = type.type_args&.first
|
|
314
|
+
element.class_name if element.is_a?(Rigor::Type::Nominal)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Instance-side navigation: when the call's receiver
|
|
318
|
+
# resolves to `Nominal[Model]` and the method name matches
|
|
319
|
+
# a discovered association OR a table column, the call site
|
|
320
|
+
# gets a precise return type. Calls with arguments are
|
|
321
|
+
# skipped — accessor / association calls take no args, and
|
|
322
|
+
# argument forms (`user.posts(limit: 10)`, `user.name = x`)
|
|
323
|
+
# route through Rails APIs this slice does not model.
|
|
324
|
+
def instance_call_return_type(call_node, scope, index)
|
|
325
|
+
return nil unless call_node.arguments.nil?
|
|
326
|
+
|
|
327
|
+
receiver_type = scope.type_of(call_node.receiver)
|
|
328
|
+
return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
|
|
329
|
+
|
|
330
|
+
entry = index.find(receiver_type.class_name) ||
|
|
331
|
+
index.find("::#{receiver_type.class_name}")
|
|
332
|
+
return nil if entry.nil?
|
|
333
|
+
|
|
334
|
+
association_return_type(entry, call_node.name) ||
|
|
335
|
+
column_return_type(entry, call_node.name)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The return type for an association accessor. A `belongs_to`
|
|
339
|
+
# / `has_one` singular association narrows to the target
|
|
340
|
+
# model — `belongs_to` is required (non-`nil`) by default
|
|
341
|
+
# since Rails 5 so it is `Nominal[Target]`, while `has_one`
|
|
342
|
+
# (and an `optional: true` / `required: false` `belongs_to`)
|
|
343
|
+
# adds the `nil` arm. A `has_many` / `has_and_belongs_to_many`
|
|
344
|
+
# collection narrows to `ActiveRecord::Relation[Target]` so
|
|
345
|
+
# chained query / iteration calls resolve. A polymorphic
|
|
346
|
+
# association has no single static target and declines
|
|
347
|
+
# rather than inventing a wrong type.
|
|
348
|
+
def association_return_type(entry, method_name)
|
|
349
|
+
association = entry.association(method_name)
|
|
350
|
+
return nil if association.nil?
|
|
351
|
+
return nil if association[:target].nil?
|
|
352
|
+
|
|
353
|
+
case association[:kind]
|
|
354
|
+
when :collection
|
|
355
|
+
relation_of(association[:target])
|
|
356
|
+
when :singular
|
|
357
|
+
target = Rigor::Type::Combinator.nominal_of(association[:target])
|
|
358
|
+
return target unless association[:nullable]
|
|
359
|
+
|
|
360
|
+
Rigor::Type::Combinator.union(target, Rigor::Type::Combinator.constant_of(nil))
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Instance-side column access. `user.name` on a
|
|
365
|
+
# `Nominal[User]` receiver narrows to the column's value
|
|
366
|
+
# type; `user.name?` (the ActiveRecord-generated predicate)
|
|
367
|
+
# narrows to `bool`.
|
|
368
|
+
#
|
|
369
|
+
# The contributed type is deliberately NON-nullable even
|
|
370
|
+
# though the DB column may permit `NULL`: Rails code calls
|
|
371
|
+
# column accessors directly (`user.email.downcase`) as a
|
|
372
|
+
# matter of course, and contributing `T | nil` would light
|
|
373
|
+
# up that idiom with `possible-nil-receiver` across an
|
|
374
|
+
# entire codebase. Under-reporting a nil column is a false
|
|
375
|
+
# negative; over-reporting it is a false positive — and the
|
|
376
|
+
# project ranks the latter as the worse failure.
|
|
377
|
+
def column_return_type(entry, method_name)
|
|
378
|
+
name = method_name.to_s
|
|
379
|
+
predicate = name.end_with?("?")
|
|
380
|
+
column_name = predicate ? name[0..-2] : name
|
|
381
|
+
|
|
382
|
+
column = entry.column(column_name)
|
|
383
|
+
return nil if column.nil?
|
|
384
|
+
return bool_type if predicate
|
|
385
|
+
|
|
386
|
+
ruby_type_to_type(column.ruby_type)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Maps a `SchemaTable::Column#ruby_type` string to a Rigor
|
|
390
|
+
# type. `"Object"` (json / jsonb / unrecognised column
|
|
391
|
+
# types) declines — `Nominal[Object]` would be NARROWER
|
|
392
|
+
# than the RBS-erased envelope and could surface false
|
|
393
|
+
# `call.undefined-method` on a value whose real shape the
|
|
394
|
+
# plugin cannot model.
|
|
395
|
+
def ruby_type_to_type(ruby_type)
|
|
396
|
+
case ruby_type
|
|
397
|
+
when "bool" then bool_type
|
|
398
|
+
when "Object", nil then nil
|
|
399
|
+
else Rigor::Type::Combinator.nominal_of(ruby_type)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# `true | false`, the structural shape RBS `bool` folds to.
|
|
404
|
+
def bool_type
|
|
405
|
+
@bool_type ||= Rigor::Type::Combinator.union(
|
|
406
|
+
Rigor::Type::Combinator.constant_of(true),
|
|
407
|
+
Rigor::Type::Combinator.constant_of(false)
|
|
408
|
+
)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def constant_receiver_name(node)
|
|
412
|
+
case node
|
|
413
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
414
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def constant_path_name(node)
|
|
419
|
+
parts = []
|
|
420
|
+
current = node
|
|
421
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
422
|
+
parts.unshift(current.name.to_s)
|
|
423
|
+
current = current.parent
|
|
424
|
+
end
|
|
425
|
+
case current
|
|
426
|
+
when nil then "::#{parts.join('::')}"
|
|
427
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def call_argument_count(node)
|
|
432
|
+
return 0 if node.arguments.nil?
|
|
433
|
+
|
|
434
|
+
node.arguments.arguments.size
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Marshal-clean Hash form for the cross-plugin fact
|
|
438
|
+
# store. Consumers (rigor-actionpack Phase 1,
|
|
439
|
+
# rigor-factorybot Phase 1 (c), ...) get a flat
|
|
440
|
+
# `class_name → { table:, columns: }` map without
|
|
441
|
+
# depending on this plugin's `ModelIndex` /
|
|
442
|
+
# `SchemaTable::Column` carrier classes.
|
|
443
|
+
def index_to_published_hash(index)
|
|
444
|
+
index.entries.transform_values do |entry|
|
|
445
|
+
{
|
|
446
|
+
table: entry.table_name,
|
|
447
|
+
columns: entry.columns.map(&:name).freeze,
|
|
448
|
+
associations: entry.associations,
|
|
449
|
+
enums: entry.enums,
|
|
450
|
+
scopes: entry.scopes,
|
|
451
|
+
validations: entry.validated_attributes,
|
|
452
|
+
callbacks: entry.callbacks,
|
|
453
|
+
aliases: entry.aliases
|
|
454
|
+
}.freeze
|
|
455
|
+
end.freeze
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def model_index
|
|
459
|
+
return @model_index if @model_index
|
|
460
|
+
|
|
461
|
+
table = schema_table_or_nil
|
|
462
|
+
return nil if table.nil?
|
|
463
|
+
|
|
464
|
+
# Walk model files first so the IoBoundary's digest list
|
|
465
|
+
# captures them BEFORE `cache_for` snapshots the
|
|
466
|
+
# descriptor (the same "read first, cache_for second"
|
|
467
|
+
# pattern documented at the top of rigor-routes).
|
|
468
|
+
ModelDiscoverer.new(
|
|
469
|
+
io_boundary: io_boundary,
|
|
470
|
+
search_paths: @model_search_paths,
|
|
471
|
+
base_classes: @model_base_classes
|
|
472
|
+
).discover
|
|
473
|
+
|
|
474
|
+
@model_index = cache_for(:model_index, params: {}).call
|
|
475
|
+
rescue StandardError => e
|
|
476
|
+
@load_errors << "model index build failed: #{e.class}: #{e.message}"
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def schema_table_or_nil
|
|
481
|
+
return @schema_table if @schema_table
|
|
482
|
+
|
|
483
|
+
# Same pattern: read schema file via boundary, then call
|
|
484
|
+
# cache_for so the descriptor includes the file digest.
|
|
485
|
+
io_boundary.read_file(@schema_file)
|
|
486
|
+
@schema_table = cache_for(:schema_table, params: {}).call
|
|
487
|
+
rescue Plugin::AccessDeniedError => e
|
|
488
|
+
@load_errors << "rigor-activerecord: #{e.message}"
|
|
489
|
+
nil
|
|
490
|
+
rescue Errno::ENOENT
|
|
491
|
+
@load_errors << "rigor-activerecord: schema file `#{@schema_file}` not found; AR call checks skipped"
|
|
492
|
+
nil
|
|
493
|
+
rescue StandardError => e
|
|
494
|
+
@load_errors << "rigor-activerecord: failed to parse `#{@schema_file}`: #{e.class}: #{e.message}"
|
|
495
|
+
nil
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def load_error_diagnostics(path)
|
|
499
|
+
@load_errors.uniq.map do |message|
|
|
500
|
+
Rigor::Analysis::Diagnostic.new(
|
|
501
|
+
path: path,
|
|
502
|
+
line: 1,
|
|
503
|
+
column: 1,
|
|
504
|
+
message: message,
|
|
505
|
+
severity: :warning,
|
|
506
|
+
rule: "load-error"
|
|
507
|
+
)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
Rigor::Plugin.register(Activerecord)
|
|
513
|
+
end
|
|
514
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Gem entry point. Required by Rigor's plugin loader when
|
|
4
|
+
# `.rigor.yml` lists `rigor-activerecord` under `plugins:`. The
|
|
5
|
+
# loader expects this `require` to side-effect a call to
|
|
6
|
+
# `Rigor::Plugin.register`, which the body of
|
|
7
|
+
# `lib/rigor/plugin/activerecord.rb` performs at load time.
|
|
8
|
+
require_relative "rigor/plugin/activerecord"
|