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,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class ShouldaMatchers < Rigor::Plugin::Base
|
|
8
|
+
# Walks every `RSpec.describe <ModelConst> do ... end` /
|
|
9
|
+
# `describe <ModelConst> do ... end` block and validates
|
|
10
|
+
# the shoulda-matchers calls inside its body (any depth
|
|
11
|
+
# of nested `describe` / `context`) against the
|
|
12
|
+
# `:model_index` published by `rigor-activerecord`.
|
|
13
|
+
#
|
|
14
|
+
# The "anchor" for cross-checking is the OUTERMOST
|
|
15
|
+
# describe block whose argument is a constant — that
|
|
16
|
+
# constant names the model being specced. Nested
|
|
17
|
+
# `describe ".some_method"` (String / Symbol args) does
|
|
18
|
+
# NOT change the anchor.
|
|
19
|
+
#
|
|
20
|
+
# ## Recognised matcher calls (v0.1.0)
|
|
21
|
+
#
|
|
22
|
+
# ### Column / db matchers
|
|
23
|
+
#
|
|
24
|
+
# validate_presence_of(:col)
|
|
25
|
+
# validate_uniqueness_of(:col)
|
|
26
|
+
# validate_length_of(:col)
|
|
27
|
+
# validate_numericality_of(:col)
|
|
28
|
+
# validate_acceptance_of(:col)
|
|
29
|
+
# validate_inclusion_of(:col)
|
|
30
|
+
# validate_exclusion_of(:col)
|
|
31
|
+
# validate_absence_of(:col)
|
|
32
|
+
# validate_format_of(:col)
|
|
33
|
+
# validate_confirmation_of(:col)
|
|
34
|
+
# allow_value(...).for(:col)
|
|
35
|
+
# have_db_column(:col)
|
|
36
|
+
# have_db_index(:col)
|
|
37
|
+
#
|
|
38
|
+
# All look up `:col` against the model's columns
|
|
39
|
+
# (`Entry#column?`). Unknown columns fire
|
|
40
|
+
# `shoulda-matchers.unknown-column`.
|
|
41
|
+
#
|
|
42
|
+
# ### Association matchers
|
|
43
|
+
#
|
|
44
|
+
# belong_to(:assoc) ← expects :singular
|
|
45
|
+
# have_one(:assoc) ← expects :singular
|
|
46
|
+
# have_many(:assoc) ← expects :collection
|
|
47
|
+
# have_and_belong_to_many(:assoc) ← expects :collection
|
|
48
|
+
#
|
|
49
|
+
# Unknown associations fire
|
|
50
|
+
# `shoulda-matchers.unknown-association`. Known
|
|
51
|
+
# associations with mismatched kind (`should belong_to(:posts)`
|
|
52
|
+
# where `:posts` is `has_many`) fire
|
|
53
|
+
# `shoulda-matchers.association-kind-mismatch`.
|
|
54
|
+
module Analyzer
|
|
55
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
56
|
+
|
|
57
|
+
# `(matcher_name) => (:column | :association_singular | :association_collection)`
|
|
58
|
+
# — the validation lane each matcher routes to.
|
|
59
|
+
MATCHER_TABLE = {
|
|
60
|
+
# Column matchers — validate the named column exists on the model.
|
|
61
|
+
validate_presence_of: :column,
|
|
62
|
+
validate_uniqueness_of: :column,
|
|
63
|
+
validate_length_of: :column,
|
|
64
|
+
validate_numericality_of: :column,
|
|
65
|
+
validate_acceptance_of: :column,
|
|
66
|
+
validate_inclusion_of: :column,
|
|
67
|
+
validate_exclusion_of: :column,
|
|
68
|
+
validate_absence_of: :column,
|
|
69
|
+
validate_format_of: :column,
|
|
70
|
+
validate_confirmation_of: :column,
|
|
71
|
+
have_db_column: :column,
|
|
72
|
+
have_db_index: :column,
|
|
73
|
+
# Association matchers — validate the association
|
|
74
|
+
# exists AND its kind matches the matcher.
|
|
75
|
+
belong_to: :association_singular,
|
|
76
|
+
have_one: :association_singular,
|
|
77
|
+
have_many: :association_collection,
|
|
78
|
+
have_and_belong_to_many: :association_collection
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
module_function
|
|
82
|
+
|
|
83
|
+
# @param path [String]
|
|
84
|
+
# @param root [Prism::Node]
|
|
85
|
+
# @param model_index [Object, nil] the `:model_index`
|
|
86
|
+
# fact value. When nil the analyzer falls silent.
|
|
87
|
+
# @return [Array<Diagnostic>]
|
|
88
|
+
def diagnose(path:, root:, model_index:)
|
|
89
|
+
return [] if model_index.nil?
|
|
90
|
+
|
|
91
|
+
diagnostics = []
|
|
92
|
+
walk_describe(root, anchor_model: nil) do |matcher_call, anchor|
|
|
93
|
+
entry = model_index.find(anchor)
|
|
94
|
+
next if entry.nil?
|
|
95
|
+
|
|
96
|
+
diagnostic = diagnostic_for(matcher_call, path, anchor, entry)
|
|
97
|
+
diagnostics << diagnostic if diagnostic
|
|
98
|
+
end
|
|
99
|
+
diagnostics
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Walks for `RSpec.describe(Const)` / `describe(Const)`
|
|
103
|
+
# blocks (the Const is the model anchor) and yields
|
|
104
|
+
# every matcher call found in their body.
|
|
105
|
+
#
|
|
106
|
+
# The anchor stays the OUTERMOST describe-with-const
|
|
107
|
+
# — nested describes / contexts inherit it without
|
|
108
|
+
# overriding (a nested `describe ".active"` is not a
|
|
109
|
+
# model constant). When a nested describe DOES name a
|
|
110
|
+
# different model, the nested anchor wins inside that
|
|
111
|
+
# subtree (rare; we still honour it).
|
|
112
|
+
def walk_describe(node, anchor_model:, &)
|
|
113
|
+
return unless node.is_a?(Prism::Node)
|
|
114
|
+
|
|
115
|
+
if describe_with_constant?(node)
|
|
116
|
+
inner_anchor = describe_const_name(node) || anchor_model
|
|
117
|
+
collect_matchers(node.block.body, inner_anchor, &) if node.block&.body
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
node.compact_child_nodes.each do |child|
|
|
122
|
+
walk_describe(child, anchor_model: anchor_model, &)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Walks the body of a describe block looking for:
|
|
127
|
+
# (a) matcher calls — `should MATCHER` or
|
|
128
|
+
# `expect(...).to MATCHER` chains; we yield the
|
|
129
|
+
# inner MATCHER call.
|
|
130
|
+
# (b) nested describe / context blocks — we recurse
|
|
131
|
+
# so deeper matchers are reachable.
|
|
132
|
+
def collect_matchers(body, anchor, &)
|
|
133
|
+
return unless body.is_a?(Prism::Node)
|
|
134
|
+
return if anchor.nil?
|
|
135
|
+
|
|
136
|
+
if matcher_invocation?(body)
|
|
137
|
+
yield body, anchor
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if describe_with_constant?(body)
|
|
142
|
+
inner_anchor = describe_const_name(body) || anchor
|
|
143
|
+
collect_matchers(body.block.body, inner_anchor, &) if body.block&.body
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
body.compact_child_nodes.each do |child|
|
|
148
|
+
collect_matchers(child, anchor, &)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A direct matcher invocation is a `CallNode` whose
|
|
153
|
+
# `name` is in `MATCHER_TABLE` and whose first argument
|
|
154
|
+
# is a `SymbolNode`. The chain shape (`should`,
|
|
155
|
+
# `expect(...).to`, `is_expected.to`) is irrelevant —
|
|
156
|
+
# we always recurse to the inner matcher, so a
|
|
157
|
+
# diagnostic fires on the matcher regardless of the
|
|
158
|
+
# surrounding chain.
|
|
159
|
+
def matcher_invocation?(node)
|
|
160
|
+
node.is_a?(Prism::CallNode) && MATCHER_TABLE.key?(node.name) && symbol_first_arg?(node)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def symbol_first_arg?(call_node)
|
|
164
|
+
args = call_node.arguments&.arguments || []
|
|
165
|
+
!args.empty? && args.first.is_a?(Prism::SymbolNode)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Detects `RSpec.describe(Const) do ... end` and
|
|
169
|
+
# `describe(Const) do ... end`. Either form opens a
|
|
170
|
+
# scope whose anchor is `Const`. The receiver shape
|
|
171
|
+
# (RSpec vs nil) is allowed in both cases.
|
|
172
|
+
def describe_with_constant?(node)
|
|
173
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
174
|
+
return false unless node.name == :describe
|
|
175
|
+
return false unless node.block.is_a?(Prism::BlockNode)
|
|
176
|
+
|
|
177
|
+
args = node.arguments&.arguments || []
|
|
178
|
+
first = args.first
|
|
179
|
+
first.is_a?(Prism::ConstantReadNode) || first.is_a?(Prism::ConstantPathNode)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def describe_const_name(node)
|
|
183
|
+
arg = node.arguments.arguments.first
|
|
184
|
+
render_constant_path(arg)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def render_constant_path(node)
|
|
188
|
+
case node
|
|
189
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
190
|
+
when Prism::ConstantPathNode
|
|
191
|
+
parts = []
|
|
192
|
+
current = node
|
|
193
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
194
|
+
parts.unshift(current.name.to_s)
|
|
195
|
+
current = current.parent
|
|
196
|
+
end
|
|
197
|
+
case current
|
|
198
|
+
when nil then "::#{parts.join('::')}"
|
|
199
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# --- diagnostics ---
|
|
205
|
+
|
|
206
|
+
def diagnostic_for(matcher_call, path, anchor, entry)
|
|
207
|
+
lane = MATCHER_TABLE.fetch(matcher_call.name)
|
|
208
|
+
target = matcher_call.arguments.arguments.first.unescaped.to_sym
|
|
209
|
+
|
|
210
|
+
case lane
|
|
211
|
+
when :column
|
|
212
|
+
column_diagnostic(matcher_call, path, anchor, entry, target)
|
|
213
|
+
when :association_singular
|
|
214
|
+
association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :singular)
|
|
215
|
+
when :association_collection
|
|
216
|
+
association_diagnostic(matcher_call, path, anchor, entry, target, expected_kind: :collection)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def column_diagnostic(matcher_call, path, anchor, entry, column_name)
|
|
221
|
+
return nil if entry.column?(column_name)
|
|
222
|
+
|
|
223
|
+
build_diagnostic(
|
|
224
|
+
matcher_call, path,
|
|
225
|
+
rule: "shoulda-matchers.unknown-column",
|
|
226
|
+
message: "#{matcher_call.name}(:#{column_name}) — no column `#{column_name}` on " \
|
|
227
|
+
"#{anchor} (columns: #{entry.column_names.sort.join(', ')})"
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def association_diagnostic(matcher_call, path, anchor, entry, assoc_name, expected_kind:)
|
|
232
|
+
if entry.association?(assoc_name)
|
|
233
|
+
actual = entry.association(assoc_name)[:kind]
|
|
234
|
+
return nil if actual == expected_kind
|
|
235
|
+
|
|
236
|
+
build_diagnostic(
|
|
237
|
+
matcher_call, path,
|
|
238
|
+
rule: "shoulda-matchers.association-kind-mismatch",
|
|
239
|
+
message: "#{matcher_call.name}(:#{assoc_name}) on #{anchor} — `#{assoc_name}` is " \
|
|
240
|
+
"a #{actual} association; #{matcher_call.name} expects #{expected_kind}"
|
|
241
|
+
)
|
|
242
|
+
else
|
|
243
|
+
build_diagnostic(
|
|
244
|
+
matcher_call, path,
|
|
245
|
+
rule: "shoulda-matchers.unknown-association",
|
|
246
|
+
message: "#{matcher_call.name}(:#{assoc_name}) — no association `#{assoc_name}` on " \
|
|
247
|
+
"#{anchor} (associations: #{entry.association_names.sort.join(', ')})"
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_diagnostic(call_node, path, rule:, message:)
|
|
253
|
+
location = call_node.message_loc || call_node.location
|
|
254
|
+
Diagnostic.new(
|
|
255
|
+
path: path,
|
|
256
|
+
line: location.start_line,
|
|
257
|
+
column: location.start_column + 1,
|
|
258
|
+
severity: :warning,
|
|
259
|
+
rule: rule,
|
|
260
|
+
message: message
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rigor/plugin"
|
|
4
|
+
|
|
5
|
+
require_relative "shoulda_matchers/analyzer"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
# rigor-shoulda-matchers — validates shoulda-matchers
|
|
10
|
+
# matchers against the `:model_index` cross-plugin fact
|
|
11
|
+
# (ADR-9) published by `rigor-activerecord`.
|
|
12
|
+
#
|
|
13
|
+
# The plugin walks every `RSpec.describe <ModelConst> do
|
|
14
|
+
# ... end` block and validates the matchers inside
|
|
15
|
+
# against the model's known columns / associations.
|
|
16
|
+
#
|
|
17
|
+
# ## Recognised matchers (v0.1.0)
|
|
18
|
+
#
|
|
19
|
+
# **Column matchers** (validate the named column exists on
|
|
20
|
+
# the model):
|
|
21
|
+
#
|
|
22
|
+
# validate_presence_of(:col), validate_uniqueness_of(:col),
|
|
23
|
+
# validate_length_of(:col), validate_numericality_of(:col),
|
|
24
|
+
# validate_acceptance_of(:col), validate_inclusion_of(:col),
|
|
25
|
+
# validate_exclusion_of(:col), validate_absence_of(:col),
|
|
26
|
+
# validate_format_of(:col), validate_confirmation_of(:col),
|
|
27
|
+
# have_db_column(:col), have_db_index(:col)
|
|
28
|
+
#
|
|
29
|
+
# **Association matchers** (validate the association exists
|
|
30
|
+
# AND its kind matches):
|
|
31
|
+
#
|
|
32
|
+
# belong_to(:assoc), have_one(:assoc) ← :singular
|
|
33
|
+
# have_many(:assoc), have_and_belong_to_many(:assoc) ← :collection
|
|
34
|
+
#
|
|
35
|
+
# ## Cross-plugin dependency
|
|
36
|
+
#
|
|
37
|
+
# The plugin consumes `:model_index` from `rigor-activerecord`.
|
|
38
|
+
# When `rigor-activerecord` is NOT loaded (or hasn't
|
|
39
|
+
# published an index for the analysed model), the plugin
|
|
40
|
+
# falls silent — the cross-check is opt-in. Adding
|
|
41
|
+
# `rigor-activerecord` to `.rigor.yml` unlocks the
|
|
42
|
+
# diagnostics.
|
|
43
|
+
#
|
|
44
|
+
# ## Limitations (v0.1.0)
|
|
45
|
+
#
|
|
46
|
+
# - **No chained-matcher arg validation.** The chain
|
|
47
|
+
# options on `validate_length_of(:col).is_at_most(50)`,
|
|
48
|
+
# `validate_inclusion_of(:col).in_array([...])`,
|
|
49
|
+
# `allow_value("foo").for(:col)`, etc. are NOT validated
|
|
50
|
+
# (the `.is_at_most` etc. terminals are runtime-only).
|
|
51
|
+
# - **No polymorphic / through validation.** `belong_to(:user).polymorphic`,
|
|
52
|
+
# `have_many(:posts, through: :memberships)` only check
|
|
53
|
+
# the named association; the chain modifiers are
|
|
54
|
+
# ignored.
|
|
55
|
+
# - **No nested-attribute matchers.** `accept_nested_attributes_for(:posts)`
|
|
56
|
+
# not yet covered.
|
|
57
|
+
# - **No callback matchers.** `callback(:before_save).before(:save)`
|
|
58
|
+
# would need a separate slice (overlaps with the
|
|
59
|
+
# model_index's `callbacks` column already exposed but
|
|
60
|
+
# no rspec-side recogniser yet).
|
|
61
|
+
class ShouldaMatchers < Rigor::Plugin::Base
|
|
62
|
+
manifest(
|
|
63
|
+
id: "shoulda-matchers",
|
|
64
|
+
version: "0.1.0",
|
|
65
|
+
description: "Validates shoulda-matchers matchers (validate_presence_of / belong_to / " \
|
|
66
|
+
"have_many / have_db_column / ...) against :model_index from " \
|
|
67
|
+
"rigor-activerecord.",
|
|
68
|
+
consumes: [
|
|
69
|
+
{ plugin_id: "activerecord", name: :model_index, optional: true }
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def init(services)
|
|
74
|
+
@services = services
|
|
75
|
+
@model_index = nil
|
|
76
|
+
@model_index_resolved = false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
80
|
+
index = model_index_or_nil
|
|
81
|
+
return [] if index.nil?
|
|
82
|
+
|
|
83
|
+
Analyzer.diagnose(
|
|
84
|
+
path: path, root: root, model_index: index
|
|
85
|
+
).map { |diag| build_diagnostic(diag) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Lazily resolves `:model_index` from
|
|
91
|
+
# `rigor-activerecord`. Returns nil when the plugin
|
|
92
|
+
# isn't loaded or no index has been published; the
|
|
93
|
+
# analyzer treats nil as "no cross-check available" and
|
|
94
|
+
# falls silent.
|
|
95
|
+
def model_index_or_nil
|
|
96
|
+
return @model_index if @model_index_resolved
|
|
97
|
+
|
|
98
|
+
@model_index = @services.fact_store.read(plugin_id: "activerecord", name: :model_index)
|
|
99
|
+
@model_index_resolved = true
|
|
100
|
+
@model_index
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_diagnostic(diag)
|
|
104
|
+
Rigor::Analysis::Diagnostic.new(
|
|
105
|
+
path: diag.path, line: diag.line, column: diag.column,
|
|
106
|
+
message: diag.message, severity: diag.severity, rule: diag.rule
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Rigor::Plugin.register(ShouldaMatchers)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class Sidekiq < Rigor::Plugin::Base
|
|
8
|
+
# Walks a parsed file's AST looking for
|
|
9
|
+
# `<WorkerClass>.perform_async(...)` /
|
|
10
|
+
# `.perform_inline(...)` / `.perform_in(time, ...)` /
|
|
11
|
+
# `.perform_at(time, ...)` calls and validates each
|
|
12
|
+
# against the {WorkerIndex}.
|
|
13
|
+
#
|
|
14
|
+
# Argument-shape rules:
|
|
15
|
+
#
|
|
16
|
+
# - `perform_async` / `perform_inline` — every
|
|
17
|
+
# argument is forwarded to `#perform`. Validate
|
|
18
|
+
# `actual == #perform.arity`.
|
|
19
|
+
# - `perform_in(interval, ...args)` /
|
|
20
|
+
# `perform_at(time, ...args)` — the FIRST argument
|
|
21
|
+
# is the schedule (a Time / Integer / ActiveSupport
|
|
22
|
+
# duration); the rest are forwarded to `#perform`.
|
|
23
|
+
# Validate `actual_args - 1 == #perform.arity`.
|
|
24
|
+
module Analyzer
|
|
25
|
+
# Methods that delegate to `#perform` 1:1.
|
|
26
|
+
DIRECT_ENTRY_METHODS = %i[perform_async perform_inline].freeze
|
|
27
|
+
|
|
28
|
+
# Methods whose first argument is a schedule (the
|
|
29
|
+
# remaining args are forwarded to `#perform`).
|
|
30
|
+
SCHEDULED_ENTRY_METHODS = %i[perform_in perform_at].freeze
|
|
31
|
+
|
|
32
|
+
ENTRY_METHODS = (DIRECT_ENTRY_METHODS + SCHEDULED_ENTRY_METHODS).freeze
|
|
33
|
+
|
|
34
|
+
Diagnostic = Struct.new(:path, :line, :column, :severity, :rule, :message, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param path [String]
|
|
39
|
+
# @param root [Prism::Node]
|
|
40
|
+
# @param worker_index [WorkerIndex]
|
|
41
|
+
# @return [Array<Diagnostic>]
|
|
42
|
+
def diagnose(path:, root:, worker_index:)
|
|
43
|
+
diagnostics = []
|
|
44
|
+
walk(root) do |call_node|
|
|
45
|
+
class_name = constant_receiver_name(call_node.receiver)
|
|
46
|
+
next if class_name.nil?
|
|
47
|
+
|
|
48
|
+
entry = worker_index.find(class_name) || worker_index.find("::#{class_name}")
|
|
49
|
+
next if entry.nil?
|
|
50
|
+
|
|
51
|
+
diagnostics << info_diagnostic(path, call_node, entry)
|
|
52
|
+
arity_diag = arity_check(path, call_node, entry)
|
|
53
|
+
diagnostics << arity_diag if arity_diag
|
|
54
|
+
end
|
|
55
|
+
diagnostics
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def walk(node, &)
|
|
59
|
+
return unless node.is_a?(Prism::Node)
|
|
60
|
+
|
|
61
|
+
yield node if node.is_a?(Prism::CallNode) && entry_call?(node)
|
|
62
|
+
node.compact_child_nodes.each { |child| walk(child, &) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def entry_call?(node)
|
|
66
|
+
ENTRY_METHODS.include?(node.name) &&
|
|
67
|
+
(node.receiver.is_a?(Prism::ConstantReadNode) || node.receiver.is_a?(Prism::ConstantPathNode))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def info_diagnostic(path, call_node, entry)
|
|
71
|
+
location = call_node.location
|
|
72
|
+
Diagnostic.new(
|
|
73
|
+
path: path,
|
|
74
|
+
line: location.start_line,
|
|
75
|
+
column: location.start_column + 1,
|
|
76
|
+
severity: :info,
|
|
77
|
+
rule: "worker-call",
|
|
78
|
+
message: "`#{entry.class_name}.#{call_node.name}` matches `#perform` " \
|
|
79
|
+
"(arity #{entry.arity_label})"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def arity_check(path, call_node, entry)
|
|
84
|
+
all_args = (call_node.arguments&.arguments || []).size
|
|
85
|
+
# Scheduled entries consume the first arg as the
|
|
86
|
+
# schedule; the rest are forwarded.
|
|
87
|
+
forwarded_count = SCHEDULED_ENTRY_METHODS.include?(call_node.name) ? all_args - 1 : all_args
|
|
88
|
+
|
|
89
|
+
if SCHEDULED_ENTRY_METHODS.include?(call_node.name) && all_args.zero?
|
|
90
|
+
return missing_schedule_diagnostic(path, call_node, entry)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return nil if forwarded_count.negative?
|
|
94
|
+
return nil if entry.accepts?(forwarded_count)
|
|
95
|
+
|
|
96
|
+
location = call_node.location
|
|
97
|
+
Diagnostic.new(
|
|
98
|
+
path: path,
|
|
99
|
+
line: location.start_line,
|
|
100
|
+
column: location.start_column + 1,
|
|
101
|
+
severity: :error,
|
|
102
|
+
rule: "wrong-arity",
|
|
103
|
+
message: "`#{entry.class_name}.#{call_node.name}` expects " \
|
|
104
|
+
"#{describe_expected(entry, call_node.name)} forwarded to `#perform` " \
|
|
105
|
+
"(arity #{entry.arity_label}), got #{forwarded_count}"
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def missing_schedule_diagnostic(path, call_node, entry)
|
|
110
|
+
location = call_node.location
|
|
111
|
+
Diagnostic.new(
|
|
112
|
+
path: path,
|
|
113
|
+
line: location.start_line,
|
|
114
|
+
column: location.start_column + 1,
|
|
115
|
+
severity: :error,
|
|
116
|
+
rule: "missing-schedule",
|
|
117
|
+
message: "`#{entry.class_name}.#{call_node.name}` requires a schedule " \
|
|
118
|
+
"(time / interval) as its first argument, got 0 arguments"
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def describe_expected(entry, method_name)
|
|
123
|
+
if SCHEDULED_ENTRY_METHODS.include?(method_name)
|
|
124
|
+
"#{entry.arity_label} argument(s) (after the schedule)"
|
|
125
|
+
else
|
|
126
|
+
"#{entry.arity_label} argument(s)"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def constant_receiver_name(node)
|
|
131
|
+
case node
|
|
132
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
133
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def constant_path_name(node)
|
|
138
|
+
parts = []
|
|
139
|
+
current = node
|
|
140
|
+
while current.is_a?(Prism::ConstantPathNode)
|
|
141
|
+
parts.unshift(current.name.to_s)
|
|
142
|
+
current = current.parent
|
|
143
|
+
end
|
|
144
|
+
case current
|
|
145
|
+
when nil then "::#{parts.join('::')}"
|
|
146
|
+
when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|